Skip to content

Rebalance schedules

The rebalance field of a TacticalSpec controls how often the rule tree is evaluated and orders are generated. Choosing the right cadence is a trade-off between signal responsiveness and trading costs. This page explains the supported frequencies, how the runtime decides which sessions are rebalance days, and how to reason about the trade-offs.

Supported frequencies

ts
type RebalanceFrequency = 'Daily' | 'Weekly' | 'Monthly' | 'Quarterly' | 'Yearly';
FrequencyWhen the rule tree runs
'Daily'Every trading session
'Weekly'The last trading day of each ISO week (usually Friday)
'Monthly'The last trading day of each calendar month
'Quarterly'The last trading day of each calendar quarter (Mar/Jun/Sep/Dec)
'Yearly'The last trading day of each calendar year

If you omit the rebalance field entirely, the default is 'Daily'.

ts
// Equivalent — both rebalance every trading day
const specA: TacticalSpec = { kind: 'tactical/v1', universe: [...], features: [...], rules: ... };
const specB: TacticalSpec = { kind: 'tactical/v1', universe: [...], rebalance: { frequency: 'Daily' }, features: [...], rules: ... };

How isRebalanceDay works

The runtime calls isRebalanceDay on each trading session to decide whether to invoke the rule tree:

ts
function isRebalanceDay(t: Date, freq: RebalanceFrequency, calendar: Calendar): boolean;

For 'Daily' it always returns true. For all other frequencies it checks whether today is the last trading day of its period: it computes the periodKey for today and for the next trading day, and returns true when they differ. Because the check uses calendar.next(t), it correctly accounts for exchange-specific holidays — a Friday before a long weekend is treated as the end of the week even if the nominal last day would be a non-trading day.

The Calendar you pass to fromSpec (and subsequently to runBacktest) determines which days count as trading sessions. The reference implementations NYSEExchangeCalendar and LSEExchangeCalendar encode each exchange's full holiday schedule.


Trade-off analysis

Turnover and costs

More frequent rebalancing means more orders per year. Each order incurs transaction costs (brokerage commissions, bid-ask spread, market impact). In a backtest the BacktestExecutor fills at next-open prices, so costs are implicit in the slippage from signal-time price to execution price.

FrequencyApproximate rebalances/yearTurnover
Daily~252Very high
Weekly~52High
Monthly~12Moderate
Quarterly~4Low
Yearly~1Very low

Rule of thumb

For trend-following strategies based on slow indicators (SMA 50–200), 'Weekly' or 'Monthly' is typically sufficient. The signal changes slowly enough that daily rebalancing adds no information but doubles or triples turnover.

Signal staleness

The trade-off runs the other way for faster signals. A strategy using a 5-day RSI and rebalancing monthly is measuring a fast indicator but acting on a slow schedule — the signal may have reversed multiple times before the next rebalance fires.

Hysteresis as a complement

Even at 'Weekly' rebalancing, price oscillation around a threshold can cause flip-flopping. Hysteresis (see Rule trees) addresses this orthogonally: it suppresses whipsaw without changing the rebalance frequency. Combining a moderate frequency ('Weekly') with a hysteresis band (tolerance: { value: 2, mode: 'relative' }) is the recommended pattern for trend strategies.


Mixing features and cadence

Feature indicators are always computed at daily granularity by the FeatureRuntime — the rebalance frequency only controls when the rule tree is evaluated. A monthly rebalancing strategy still benefits from daily price data feeding the SMA computation; it just acts on the signal at most once per month.

This means a 'Monthly' strategy with { kind: 'sma', period: 200 } uses all 200 daily closes for the SMA computation but generates at most 12 rebalance events per year.


Sample — weekly vs monthly event count

The sample below runs the same SPY/IEF trend strategy at 'Weekly' and 'Monthly' cadences and reports the total number of rebalance events and orders for each. The output illustrates the turnover difference directly.

ts
// Rebalance schedules — weekly vs monthly comparison.
// Runs the same trend-following strategy at two cadences and counts how many
// rebalance events each produces. Shows that more frequent rebalancing means
// more trading opportunities but also more turnover.
//
//   npx tsx scripts/docs/guides-authoring/rebalance-weekly.ts

import {
  fromSpec,
  runBacktest,
  FeatureRuntime,
  NYSEExchangeCalendar,
  MemoryFeatureCache,
  BacktestExecutor,
} from '@livefolio/sdk';
import type { TacticalSpec, RebalanceFrequency, Asset, Bar, DataFeed, DateRange, Frequency } from '@livefolio/sdk';

const utc = (s: string) => new Date(`${s}T00:00:00Z`);

// ─── Synthetic DataFeed ───────────────────────────────────────────────────────

const SPY = { id: 'us:SPY', symbol: 'SPY' };
const IEF = { id: 'us:IEF', symbol: 'IEF' };

function makeBars(start: Date, days: number, base: number, drift: number): Bar[] {
  const out: Bar[] = [];
  let price = base;
  for (let i = 0; i < days; i++) {
    const t = new Date(start.getTime() + i * 86_400_000);
    if (t.getUTCDay() === 0 || t.getUTCDay() === 6) continue;
    price *= 1 + drift + Math.sin(i / 12) * 0.005;
    out.push({ t, open: price, high: price * 1.004, low: price * 0.996, close: price, volume: 1_000_000 });
  }
  return out;
}

const FIXTURES: Record<string, Bar[]> = {
  'us:SPY': makeBars(utc('2021-01-04'), 1200, 370, 0.0004),
  'us:IEF': makeBars(utc('2021-01-04'), 1200, 115, 0.00003),
};

const dataFeed: DataFeed = {
  bars: async function* (asset: Asset, range: DateRange, _freq: Frequency) {
    const bars = FIXTURES[asset.id];
    if (!bars) throw new Error(`no fixture for ${asset.id}`);
    for (const bar of bars) {
      if (bar.t >= range.from && bar.t < range.to) yield bar;
    }
  },
};

// ─── Build a spec for a given frequency ──────────────────────────────────────

function buildSpec(frequency: RebalanceFrequency): TacticalSpec {
  return {
    kind: 'tactical/v1',
    universe: [SPY, IEF],
    // The only difference between the two runs is this field.
    rebalance: { frequency },
    features: [
      { id: 'spy_price', kind: 'price', asset: SPY },
      { id: 'spy_sma50', kind: 'sma', asset: SPY, period: 50 },
    ],
    rules: {
      op: 'if',
      cond: { op: 'gt', left: { ref: 'spy_price' }, right: { ref: 'spy_sma50' } },
      then: { op: 'allocate', weights: { 'us:SPY': 1.0 } },
      else: { op: 'allocate', weights: { 'us:IEF': 1.0 } },
    },
  };
}

// ─── Run helper ───────────────────────────────────────────────────────────────

const range: DateRange = { from: utc('2021-06-01'), to: utc('2024-01-01') };

async function runFrequency(frequency: RebalanceFrequency): Promise<void> {
  const calendar = new NYSEExchangeCalendar();
  const featureCache = new MemoryFeatureCache();
  const runtime = new FeatureRuntime({ dataFeed, featureCache, range, freq: '1d' });

  const executor = new BacktestExecutor({
    calendar,
    nextOpen: async (asset, t) => {
      const bars = FIXTURES[asset.id];
      if (!bars) throw new Error(`no fixture for ${asset.id}`);
      const next = bars.find((b) => b.t.getTime() > t.getTime());
      if (!next) throw new Error(`no bar after ${t.toISOString()}`);
      return { t: next.t, price: next.open };
    },
  });

  const spec = buildSpec(frequency);
  const strategy = fromSpec(spec, { runtime, calendar });

  const result = await runBacktest({
    strategy,
    range,
    initialPortfolio: { cash: 100_000, positions: [], t: range.from },
    dataFeed,
    executor,
    calendar,
  });

  const totalSessions = result.snapshots.length;
  const rebalanceSessions = result.snapshots.filter((s) => s.orders.length > 0).length;
  const orderCount = result.snapshots.reduce((sum, s) => sum + s.orders.length, 0);

  console.log(`\n[${frequency} rebalance]`);
  console.log(`  Total sessions    : ${totalSessions}`);
  console.log(`  Rebalance events  : ${rebalanceSessions}`);
  console.log(`  Total orders      : ${orderCount}`);
}

await runFrequency('Weekly');
await runFrequency('Monthly');

console.log('\nMonthly rebalancing produces fewer events and lower turnover.');
console.log('Weekly rebalancing reacts faster to trend changes.');

What's next

Released under the MIT License.