Rule trees
The rules field of a TacticalSpec is the strategy's decision logic. It is a binary tree of RuleNode values evaluated on every rebalance day. This page covers the two node types, the comparison operators, and hysteresis — the mechanism that prevents a strategy from whipsawing when prices oscillate near a threshold.
Node types
A RuleNode is a discriminated union:
type RuleNode = AllocateNode | IfNode;AllocateNode — terminal
type AllocateNode = {
op: 'allocate';
weights: Record<AssetId, number>;
};An AllocateNode is a leaf. When the tree reaches one, evaluation stops and the weight map becomes the strategy's target for that session. Weights are fractions of NAV. Any unallocated fraction (total < 1.0) stays as uninvested cash.
IfNode — branch
type IfNode = {
op: 'if';
cond: Comparison;
then: RuleNode;
else: RuleNode;
};An IfNode evaluates cond and walks either then (when true) or else (when false). Both branches are themselves RuleNode values, so trees can be nested to any depth.
Comparisons
type Comparison = {
op: ComparisonOp;
left: FeatureRef | number;
right: FeatureRef | number;
tolerance?: Tolerance;
id?: string;
};
type ComparisonOp = 'gt' | 'lt' | 'gte' | 'lte';
type FeatureRef = { ref: string };Both left and right can be a feature reference ({ ref: 'feature_id' }) or a literal number. The comparison evaluates the resolved values using the given operator:
| Operator | Meaning |
|---|---|
'gt' | left > right |
'lt' | left < right |
'gte' | left >= right |
'lte' | left <= right |
Feature references look up the named feature in the value map built from TacticalSpec.features. If a referenced feature has no value (e.g. insufficient history for an SMA), the entire rule tree evaluation is skipped for that session — the portfolio is left unchanged rather than generating an error.
Hysteresis
Without special handling, a strategy can whipsaw: when a price oscillates just above and below a threshold it triggers a buy on Monday and a sell on Thursday, week after week. Hysteresis prevents this by introducing a dead band around the threshold — once a signal is active, it stays active until the market moves far enough in the opposite direction.
How it works
type Tolerance = {
value: number;
mode: 'absolute' | 'relative';
};Set tolerance on a Comparison to enable hysteresis. Two modes are available:
| Mode | Dead band |
|---|---|
'absolute' | [right − value, right + value] |
'relative' | [right × (1 − value/100), right × (1 + value/100)] |
The runtime maintains a per-comparison state bit (0 = false, 1 = true). On each rebalance the previous state determines which edge of the band applies:
- Signal currently active (
prev = 1): it stays active as long as the market has not crossed the lower edge of the band. It switches off only whenleftcrosses belowright − tolerance. - Signal currently inactive (
prev = 0): it stays inactive untilleftcrosses above the upper edge of the band.
For 'gt' with a 2 % relative band:
- Flip on:
left > right × 1.02 - Flip off:
left < right × 0.98
The id field
When tolerance is set, id is required. The id string keys the state entry inside RuleTreeState — a ReadonlyMap<string, 0 | 1> that fromSpec carries across rebalances. If you omit id on a comparison that has tolerance, the runtime throws at evaluation time.
Choosing an id
Pick a descriptive, stable string — e.g. 'spy_trend'. Changing an id mid-backtest is equivalent to losing the prior state bit: the comparison initialises as if it had never been evaluated before.
Tolerance only works with gt / lt
Using tolerance with 'gte' or 'lte' is an error. The >= and <= operators are used for exact-threshold comparisons where hysteresis does not make semantic sense.
Fallback patterns
Every IfNode requires both a then branch and an else branch, which means the tree always produces a concrete allocation. There are no implicit fallbacks or null states.
Defensive else
The most common pattern is a two-branch tree where the else branch holds a safe-haven asset:
{
op: 'if',
cond: { op: 'gt', left: { ref: 'spy_price' }, right: { ref: 'spy_sma200' } },
then: { op: 'allocate', weights: { 'us:SPY': 1.0 } },
else: { op: 'allocate', weights: { 'us:IEF': 1.0 } }, // defensive fallback
}Multi-condition rules
Nest IfNode values to encode AND logic (both conditions must be true to reach an allocation):
{
op: 'if',
cond: { op: 'gt', left: { ref: 'spy_price' }, right: { ref: 'spy_sma200' } },
then: {
op: 'if',
// Secondary filter: RSI not overbought
cond: { op: 'lt', left: { ref: 'spy_rsi14' }, right: 75 },
then: { op: 'allocate', weights: { 'us:SPY': 0.8, 'us:QQQ': 0.2 } },
else: { op: 'allocate', weights: { 'us:SPY': 0.5, 'us:IEF': 0.5 } },
},
else: { op: 'allocate', weights: { 'us:IEF': 1.0 } },
}Partial allocation
Weights do not have to sum to 1.0. The remainder stays as cash, which can be useful when you want to size down risk deliberately:
{ op: 'allocate', weights: { 'us:SPY': 0.6, 'us:IEF': 0.3 } }
// 10 % stays in cashSample — hysteresis in action
The sample below runs the same price series through two strategies — one without hysteresis and one with a 2 % relative band — and prints how many allocation flips each produces. The hysteresis version should show noticeably fewer flips.
// Rule trees — hysteresis demo.
// Shows a two-branch strategy where the trend comparison uses a tolerance band
// so that small oscillations around the threshold do not cause whipsaw trades.
// Prints the rebalance history to show that hysteresis suppresses flip-flopping.
//
// npx tsx scripts/docs/guides-authoring/rule-trees-hysteresis.ts
import {
fromSpec,
runBacktest,
FeatureRuntime,
NYSEExchangeCalendar,
MemoryFeatureCache,
BacktestExecutor,
} from '@livefolio/sdk';
import type { TacticalSpec, Asset, Bar, DataFeed, DateRange, Frequency } from '@livefolio/sdk';
const utc = (s: string) => new Date(`${s}T00:00:00Z`);
// ─── Synthetic bars that oscillate around the SMA threshold ──────────────────
// The price series is designed to cross the SMA repeatedly without hysteresis,
// but the 2 % band will hold the allocation stable through small wiggles.
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;
// Slow drift + oscillation designed to hover near the SMA
price *= 1 + drift + Math.sin(i / 4) * 0.008;
out.push({ t, open: price, high: price * 1.003, low: price * 0.997, close: price, volume: 1_000_000 });
}
return out;
}
const SPY = { id: 'us:SPY', symbol: 'SPY' };
const IEF = { id: 'us:IEF', symbol: 'IEF' };
const FIXTURES: Record<string, Bar[]> = {
'us:SPY': makeBars(utc('2023-01-02'), 400, 400, 0.0001),
'us:IEF': makeBars(utc('2023-01-02'), 400, 100, 0.00005),
};
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;
}
},
};
// ─── Spec WITHOUT hysteresis ──────────────────────────────────────────────────
const specNoHysteresis: TacticalSpec = {
kind: 'tactical/v1',
universe: [SPY, IEF],
rebalance: { frequency: 'Weekly' },
features: [
{ id: 'spy_price', kind: 'price', asset: SPY },
// Short SMA so the price oscillates around it
{ id: 'spy_sma20', kind: 'sma', asset: SPY, period: 20 },
],
rules: {
op: 'if',
// Plain comparison — no tolerance band
cond: { op: 'gt', left: { ref: 'spy_price' }, right: { ref: 'spy_sma20' } },
then: { op: 'allocate', weights: { 'us:SPY': 1.0 } },
else: { op: 'allocate', weights: { 'us:IEF': 1.0 } },
},
};
// ─── Spec WITH hysteresis ─────────────────────────────────────────────────────
const specWithHysteresis: TacticalSpec = {
kind: 'tactical/v1',
universe: [SPY, IEF],
rebalance: { frequency: 'Weekly' },
features: [
{ id: 'spy_price', kind: 'price', asset: SPY },
{ id: 'spy_sma20', kind: 'sma', asset: SPY, period: 20 },
],
rules: {
op: 'if',
cond: {
op: 'gt',
left: { ref: 'spy_price' },
right: { ref: 'spy_sma20' },
// 2 % relative band: once the signal fires, it won't flip until price
// moves more than 2 % in the opposite direction from the threshold.
tolerance: { value: 2, mode: 'relative' },
// id is mandatory when tolerance is set — keys the hysteresis state
// across rebalances so the runtime remembers the previous decision.
id: 'spy_trend',
},
then: { op: 'allocate', weights: { 'us:SPY': 1.0 } },
else: { op: 'allocate', weights: { 'us:IEF': 1.0 } },
},
};
// ─── Run both backtests and compare flip counts ───────────────────────────────
async function run(spec: TacticalSpec, label: string): Promise<void> {
const calendar = new NYSEExchangeCalendar();
const range: DateRange = { from: utc('2023-02-01'), to: utc('2023-12-01') };
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 strategy = fromSpec(spec, { runtime, calendar });
const result = await runBacktest({
strategy,
range,
initialPortfolio: { cash: 100_000, positions: [], t: range.from },
dataFeed,
executor,
calendar,
});
const rebalances = result.snapshots.filter((s) => s.orders.length > 0);
console.log(`\n[${label}]`);
console.log(` Rebalance events : ${rebalances.length}`);
// Count allocation flips (SPY → IEF or IEF → SPY)
let flips = 0;
let prevHeldSPY: boolean | undefined;
for (const snap of rebalances) {
const heldSPY = snap.portfolio.positions.some((p) => p.asset.id === 'us:SPY' && p.quantity > 0);
if (prevHeldSPY !== undefined && heldSPY !== prevHeldSPY) flips++;
prevHeldSPY = heldSPY;
}
console.log(` Allocation flips : ${flips}`);
}
await run(specNoHysteresis, 'Without hysteresis');
await run(specWithHysteresis, 'With hysteresis (2 % band)');
console.log('\nHysteresis keeps flip count lower despite identical price data.');What's next
- Synthetics — model leveraged ETFs and fee drag.
- Rebalance schedules — how often the rule tree runs and the turnover implications.
- API:
RuleNode,Comparison,evaluateRuleTree