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
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.
| Value | Status |
|---|---|
'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.
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
AssetIdreferenced in anAllocateNodeweight map must appear inuniverse. idis the canonical key;symbolis only used for display andDataFeedlook-ups.- Synthetic assets can appear in
universe— see thesyntheticsfield and Synthetics guide.
synthetics (optional)
An array of SyntheticAsset definitions. Omit the field entirely if you only trade real tickers.
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.
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.
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 };| Kind | Description |
|---|---|
price | Raw close price of asset on the evaluation date |
sma | Simple moving average over period trading days |
ema | Exponential moving average over period trading days |
rsi | Wilder-smoothed Relative Strength Index over period days |
return | Rolling total return over period days (mode: 'arithmetic' or 'log') |
volatility | Rolling annualised standard deviation of daily returns over period days |
drawdown | Rolling 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.
// 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.
// 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.