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
type RebalanceFrequency = 'Daily' | 'Weekly' | 'Monthly' | 'Quarterly' | 'Yearly';| Frequency | When 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'.
// 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:
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.
| Frequency | Approximate rebalances/year | Turnover |
|---|---|---|
| Daily | ~252 | Very high |
| Weekly | ~52 | High |
| Monthly | ~12 | Moderate |
| Quarterly | ~4 | Low |
| Yearly | ~1 | Very 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.
// 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
- Rule trees — use hysteresis to reduce flip-flopping at any rebalance frequency.
- Anatomy of a TacticalSpec — full field reference including
rebalance. - API:
RebalanceConfig,isRebalanceDay