Skip to content

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:

ts
type RuleNode = AllocateNode | IfNode;

AllocateNode — terminal

ts
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

ts
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

ts
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:

OperatorMeaning
'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

ts
type Tolerance = {
  value: number;
  mode: 'absolute' | 'relative';
};

Set tolerance on a Comparison to enable hysteresis. Two modes are available:

ModeDead 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 when left crosses below right − tolerance.
  • Signal currently inactive (prev = 0): it stays inactive until left crosses 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:

ts
{
  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):

ts
{
  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:

ts
{ op: 'allocate', weights: { 'us:SPY': 0.6, 'us:IEF': 0.3 } }
// 10 % stays in cash

Sample — 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.

ts
// 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

Released under the MIT License.