Skip to content

Anatomy of a TacticalSpec

A TacticalSpec is a plain TypeScript object — no classes, no closures — that describes everything needed to run a tactical allocation strategy. Because it is pure data, the same spec can drive a backtest today and live execution tomorrow without any code changes. This page walks through every field in the order it appears in the type definition.

The full type surface

ts
type TacticalSpec = {
  kind: 'tactical/v0' | 'tactical/v1';
  universe: AssetRef[];
  synthetics?: SyntheticAsset[];
  rebalance?: RebalanceConfig;
  features: TacticalFeatureSpec[];
  rules: RuleNode;
};

All fields are explained below. See the API reference for the generated type docs.


kind

The dialect identifier tells the runtime which version of the spec format you are using.

ValueStatus
'tactical/v1'Current. Use this for all new strategies.
'tactical/v0'Accepted but deprecated. Byte-for-byte equivalent to v1; emits a console warning once per process. Migrate by changing the string.

There is no functional difference between v0 and v1 at runtime. The distinction exists so the SDK can warn users who are copying old examples.


universe

An array of AssetRef objects — the complete set of assets the strategy can trade.

ts
type AssetRef = {
  id: AssetId;       // stable, exchange-scoped identifier  e.g. 'us:SPY'
  symbol: string;    // human-readable ticker               e.g. 'SPY'
  exchange?: string; // optional exchange tag               e.g. 'XNAS'
};

Why it exists: The runtime fetches prices and computes features only for universe members. Keeping the set explicit lets the SDK pre-warm the feature cache and validate that every weight in rules maps to a real asset.

Rules:

  • Every AssetId referenced in an AllocateNode weight map must appear in universe.
  • id is the canonical key; symbol is only used for display and DataFeed look-ups.
  • Synthetic assets can appear in universe — see the synthetics field and Synthetics guide.

synthetics (optional)

An array of SyntheticAsset definitions. Omit the field entirely if you only trade real tickers.

ts
type SyntheticAsset = {
  id: AssetId;
  symbol: string;
  underlying: AssetRef;   // the real asset to derive from
  leverage: number;       // e.g. 2 for 2x, -1 for inverse
  expense?: number;       // annual expense ratio in percent, e.g. 0.91
  tradeAs?: AssetRef;     // if set, orders route to this ticker instead
};

The SDK synthesises daily bars by applying daily-reset leverage compounding plus a fractional expense drag:

close_t = close_{t-1} × (1 + leverage × r_t) × (1 − expense / 252)

Use synthetics to model leveraged ETFs (SSO = 2× SPY) or fee-bearing wrappers in a backtest without needing the ETF's real price history. See the Synthetics guide for a worked example.


rebalance (optional)

A RebalanceConfig that controls how often the rule tree is evaluated.

ts
type RebalanceConfig = {
  frequency: RebalanceFrequency;
};

type RebalanceFrequency = 'Daily' | 'Weekly' | 'Monthly' | 'Quarterly' | 'Yearly';

Default: omitting rebalance is equivalent to { frequency: 'Daily' }.

The runtime calls isRebalanceDay on each trading session. A session is a rebalance day when it is the last trading day of its period (e.g. the last trading day of the week for 'Weekly'). Only on rebalance days does fromSpec invoke the rule tree and generate orders.

For a detailed trade-off discussion, see Rebalance schedules.


features

An array of named indicator definitions. Each entry is a TacticalFeatureSpec — a discriminated union keyed by kind.

ts
type TacticalFeatureSpec =
  | { id: string; kind: 'price';      asset: AssetRef; delay?: number }
  | { id: string; kind: 'sma';        asset: AssetRef; period: number; delay?: number }
  | { id: string; kind: 'ema';        asset: AssetRef; period: number; delay?: number }
  | { id: string; kind: 'rsi';        asset: AssetRef; period: number; delay?: number }
  | { id: string; kind: 'return';     asset: AssetRef; period: number; mode?: ReturnMode; delay?: number }
  | { id: string; kind: 'volatility'; asset: AssetRef; period: number; delay?: number }
  | { id: string; kind: 'drawdown';   asset: AssetRef; period: number; delay?: number };
KindDescription
priceRaw close price of asset on the evaluation date
smaSimple moving average over period trading days
emaExponential moving average over period trading days
rsiWilder-smoothed Relative Strength Index over period days
returnRolling total return over period days (mode: 'arithmetic' or 'log')
volatilityRolling annualised standard deviation of daily returns over period days
drawdownRolling maximum drawdown from peak over period days

The id string becomes the key in the feature value map. The rules tree references features via { ref: 'id' }. Feature results are memoised by the FeatureCache so they are computed at most once per (spec, asset, date) triple.


rules

A RuleNode — the root of a binary decision tree. At runtime, the tree is walked top-down; the first AllocateNode reached produces the target weights for that session.

ts
// Terminal node — produces a weight map
type AllocateNode = {
  op: 'allocate';
  weights: Record<AssetId, number>;
};

// Branch node — evaluates a Comparison, then walks `then` or `else`
type IfNode = {
  op: 'if';
  cond: Comparison;
  then: RuleNode;
  else: RuleNode;
};

The weights map must reference only asset ids present in universe. Weights should sum to ≤ 1.0; any remainder stays as uninvested cash.

For full rule-tree semantics, operator details, and hysteresis bands, see Rule trees.


Annotated example

The sample below declares a complete spec with every field annotated. It then runs a short backtest with a synthetic DataFeed so it is fully self-contained.

ts
// Anatomy of a TacticalSpec — annotated complete example.
// Demonstrates every top-level field with comments explaining each choice.
// Self-contained: the DataFeed is synthetic so no external service is needed.
//
//   npx tsx scripts/docs/guides-authoring/anatomy.ts

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

// ─── Asset references ────────────────────────────────────────────────────────
// AssetRef binds a human-readable symbol to a stable, exchange-scoped id.
// The id is what appears in allocate weight maps; the symbol is for display.
const SPY = { id: 'us:SPY', symbol: 'SPY' };
const QQQ = { id: 'us:QQQ', symbol: 'QQQ' };
const IEF = { id: 'us:IEF', symbol: 'IEF' };

// ─── The spec ────────────────────────────────────────────────────────────────
const spec: TacticalSpec = {
  // kind — dialect identifier. Always 'tactical/v1' for current strategies.
  // 'tactical/v0' is accepted but emits a deprecation warning.
  kind: 'tactical/v1',

  // universe — the tradeable assets. Every asset referenced in `rules` weights
  // must appear here. Order does not matter for execution.
  universe: [SPY, QQQ, IEF],

  // rebalance — how often the rule tree runs. Omit for daily rebalancing.
  // Valid frequencies: 'Daily' | 'Weekly' | 'Monthly' | 'Quarterly' | 'Yearly'
  rebalance: { frequency: 'Weekly' },

  // features — named indicators. Each entry gets an `id` that `rules` can
  // reference via { ref: 'id' }. Supported kinds:
  //   'price'      — raw close price
  //   'sma'        — simple moving average (requires `period`)
  //   'ema'        — exponential moving average (requires `period`)
  //   'rsi'        — relative strength index (requires `period`)
  //   'return'     — rolling return (requires `period`, optional `mode`)
  //   'volatility' — rolling annualised volatility (requires `period`)
  //   'drawdown'   — rolling max drawdown (requires `period`)
  features: [
    // Current close price of SPY
    { id: 'spy_price', kind: 'price', asset: SPY },
    // 200-day SMA — the classic trend filter
    { id: 'spy_sma200', kind: 'sma', asset: SPY, period: 200 },
    // 14-day RSI for a secondary momentum check
    { id: 'spy_rsi14', kind: 'rsi', asset: SPY, period: 14 },
  ],

  // rules — a binary decision tree of IfNode / AllocateNode nodes.
  // The tree is evaluated top-down; the first matching AllocateNode wins.
  // Weights are fractions of NAV (must sum to ≤ 1.0; remainder stays in cash).
  rules: {
    op: 'if',
    // Primary trend condition: SPY price above its 200-day SMA
    cond: { op: 'gt', left: { ref: 'spy_price' }, right: { ref: 'spy_sma200' } },
    then: {
      op: 'if',
      // Secondary filter: not deeply overbought (RSI < 80)
      cond: { op: 'lt', left: { ref: 'spy_rsi14' }, right: 80 },
      then: {
        // Risk-on: growth tilt
        op: 'allocate',
        weights: { 'us:SPY': 0.6, 'us:QQQ': 0.4 },
      },
      else: {
        // Overbought even in uptrend — trim risk slightly
        op: 'allocate',
        weights: { 'us:SPY': 0.5, 'us:QQQ': 0.2, 'us:IEF': 0.3 },
      },
    },
    else: {
      // Downtrend: move to bonds
      op: 'allocate',
      weights: { 'us:IEF': 1.0 },
    },
  },
};

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

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

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 / 10) * 0.004;
    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('2022-01-03'), 800, 460, 0.0004),
  'us:QQQ': makeBars(utc('2022-01-03'), 800, 380, 0.0005),
  'us:IEF': makeBars(utc('2022-01-03'), 800, 110, 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;
    }
  },
};

// ─── Runtime wiring & backtest ────────────────────────────────────────────────

const calendar = new NYSEExchangeCalendar();
const range: DateRange = { from: utc('2022-06-01'), to: utc('2024-01-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).length;
console.log(`Sessions  : ${result.snapshots.length}`);
console.log(`Rebalances: ${rebalances}`);
console.log(`Final NAV : $${result.snapshots.at(-1)?.portfolio.cash.toFixed(2)} cash`);

What's next

  • Rule trees — operator semantics, hysteresis bands, multi-condition patterns.
  • Synthetics — modelling leveraged ETFs and fee drag.
  • Rebalance schedules — trade-off analysis and turnover implications.

Released under the MIT License.