First Strategy
This guide walks through a complete working example: an SPY/QQQ/IEF weekly trend strategy that uses an SMA(100) crossover signal. When SPY's price is above its 100-day moving average, the strategy allocates 60 % to SPY and 40 % to QQQ; otherwise it moves entirely into IEF (intermediate Treasuries). The example uses a synthetic in-memory DataFeed so it runs without any external service.
Full source
The sample lives at scripts/docs/getting-started/first-strategy.ts. Read it alongside this page.
// Quick-start: build a tactical strategy, run a backtest, print the final
// portfolio. Self-contained — uses an in-memory synthetic DataFeed so the
// sample runs without any external service. The same code shape works
// against a real adapter (e.g. @livefolio/yfinance) — only the
// `dataFeed` parameter changes.
//
// npx tsx scripts/docs/getting-started/first-strategy.ts
import {
fromSpec,
runBacktest,
FeatureRuntime,
NYSEExchangeCalendar,
MemoryFeatureCache,
BacktestExecutor,
} from '@livefolio/sdk';
import type { TacticalSpec, Asset, Bar, DataFeed, DateRange, Frequency } from '@livefolio/sdk';
// --- 1. Define the strategy as a TacticalSpec ---------------------------
const SPY = { id: 'us:SPY', symbol: 'SPY' };
const QQQ = { id: 'us:QQQ', symbol: 'QQQ' };
const IEF = { id: 'us:IEF', symbol: 'IEF' };
const spec: TacticalSpec = {
kind: 'tactical/v1',
universe: [SPY, QQQ, IEF],
rebalance: { frequency: 'Weekly' },
features: [
{ id: 'spy_price', kind: 'price', asset: SPY },
{ id: 'spy_sma100', kind: 'sma', asset: SPY, period: 100 },
],
rules: {
op: 'if',
cond: { op: 'gt', left: { ref: 'spy_price' }, right: { ref: 'spy_sma100' } },
then: { op: 'allocate', weights: { 'us:SPY': 0.6, 'us:QQQ': 0.4 } },
else: { op: 'allocate', weights: { 'us:IEF': 1.0 } },
},
};
// --- 2. Build a DataFeed ------------------------------------------------
// In production you'd use @livefolio/yfinance or your own adapter.
// Here we synthesize bars so the sample is self-contained.
const utc = (s: string) => new Date(`${s}T00:00:00Z`);
function makeBars(start: Date, days: number, basePrice: number, drift: number): Bar[] {
const bars: Bar[] = [];
const MS_DAY = 86_400_000;
let price = basePrice;
for (let i = 0; i < days; i++) {
const t = new Date(start.getTime() + i * MS_DAY);
const dow = t.getUTCDay();
if (dow === 0 || dow === 6) continue;
price = price * (1 + drift + Math.sin(i / 8) * 0.005);
bars.push({ t, open: price, high: price * 1.005, low: price * 0.995, close: price, volume: 1_000_000 });
}
return bars;
}
const FIXTURES: Record<string, Bar[]> = {
'us:SPY': makeBars(utc('2023-01-02'), 800, 400, 0.0005),
'us:QQQ': makeBars(utc('2023-01-02'), 800, 300, 0.0007),
'us:IEF': makeBars(utc('2023-01-02'), 800, 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;
}
},
};
// --- 3. Wire the runtime layers -----------------------------------------
const calendar = new NYSEExchangeCalendar();
const featureCache = new MemoryFeatureCache();
const range: DateRange = { from: utc('2023-06-01'), to: utc('2024-12-01') };
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()} for ${asset.id}`);
return { t: next.t, price: next.open };
},
});
// --- 4. Hydrate the spec into a Strategy and run ------------------------
const strategy = fromSpec(spec, { runtime, calendar });
const result = await runBacktest({
strategy,
range,
initialPortfolio: { cash: 100_000, positions: [], t: range.from },
dataFeed,
executor,
calendar,
});
// --- 5. Inspect the result ---------------------------------------------
const sessions = result.snapshots.length;
const rebalances = result.snapshots.filter((s) => s.orders.length > 0).length;
const finalSnapshot = result.snapshots.at(-1);
console.log(`sessions : ${sessions}`);
console.log(`rebalances : ${rebalances}`);
console.log(`final cash : $${finalSnapshot?.portfolio.cash.toFixed(2)}`);
console.log('positions:');
for (const p of finalSnapshot?.portfolio.positions ?? []) {
console.log(` ${p.asset.symbol.padEnd(4)} qty=${p.quantity} basis=$${p.basis.toFixed(2)}`);
}Step 1 — Define the strategy as a TacticalSpec
const spec: TacticalSpec = {
kind: 'tactical/v1',
universe: [SPY, QQQ, IEF],
rebalance: { frequency: 'Weekly' },
features: [
{ id: 'spy_price', kind: 'price', asset: SPY },
{ id: 'spy_sma100', kind: 'sma', asset: SPY, period: 100 },
],
rules: {
op: 'if',
cond: { op: 'gt', left: { ref: 'spy_price' }, right: { ref: 'spy_sma100' } },
then: { op: 'allocate', weights: { 'us:SPY': 0.6, 'us:QQQ': 0.4 } },
else: { op: 'allocate', weights: { 'us:IEF': 1.0 } },
},
};TacticalSpec is plain data — a JSON-shaped object with no class instances or closures. This is intentional: you can serialize it to a database, version it with git, send it across an API boundary, or compare two specs with a deep-equality check. The SDK's runtime is responsible for turning this data into behaviour.
Key fields:
universe— the set of assets the strategy may allocate to. Each asset is{ id, symbol }, whereidis the canonical string key used in weight maps.features— a list of named indicators. Each entry declares what to compute (kind: 'price',kind: 'sma'), and binds the result to a stringid. Theidis the handle used in therulestree.rebalance— how often the strategy reconsiders its allocation.'Weekly'means once per trading week.rules— a tree ofif/elsenodes that resolves to a singleallocateleaf on each rebalance day. Thecondcompares two feature references;thenandelseare further nodes or leaf allocations.
See TacticalSpec and RuleNode in the API reference.
Step 2 — Build a DataFeed
const dataFeed: DataFeed = {
bars: async function* (asset, range, _freq) {
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;
}
},
};DataFeed is an interface with a single required method: bars. It is an async generator that yields Bar objects (OHLCV + timestamp) in ascending time order for a given (asset, range, frequency) tuple. The range is half-open: [from, to).
In this example the feed is entirely in-memory. In production you would replace it with @livefolio/yfinance or your own adapter — the strategy code does not change.
Step 3 — Wire the runtime layers
const calendar = new NYSEExchangeCalendar();
const featureCache = new MemoryFeatureCache();
const range: DateRange = { from: utc('2023-06-01'), to: utc('2024-12-01') };
const runtime = new FeatureRuntime({ dataFeed, featureCache, range, freq: '1d' });
const executor = new BacktestExecutor({
calendar,
nextOpen: async (asset, t) => { /* ... */ },
});Four runtime layers are required:
| Layer | Purpose | Reference impl used here |
|---|---|---|
Calendar | Trading-day arithmetic (sessions, next/prev day) | NYSEExchangeCalendar |
FeatureCache | Memoize indicator results by (spec, asset, date) | MemoryFeatureCache |
DataFeed | Provide OHLCV bars | synthetic in-memory feed |
Executor | Submit orders, return fills, track portfolio | BacktestExecutor |
FeatureRuntime wraps the DataFeed and FeatureCache together; it is the component that resolves feature specs into numeric values for a given date.
BacktestExecutor requires a nextOpen callback: given an asset and a timestamp, return the next trading session's open price and time. In the example this is satisfied from the same in-memory fixtures.
Step 4 — Hydrate and run
const strategy = fromSpec(spec, { runtime, calendar });
const result = await runBacktest({
strategy,
range,
initialPortfolio: { cash: 100_000, positions: [], t: range.from },
dataFeed,
executor,
calendar,
});fromSpec converts the plain TacticalSpec into a Strategy<F> — a typed object the runtime can drive. It does not fetch data or compute anything yet.
runBacktest is the runtime loop. It walks every trading session in range, computes features via FeatureRuntime, evaluates the rule tree, submits any required orders to the executor, applies fills, and records a BacktestSnapshot for each session. It returns { snapshots, finalPortfolio }.
See fromSpec and runBacktest.
Step 5 — Inspect results
const sessions = result.snapshots.length;
const rebalances = result.snapshots.filter((s) => s.orders.length > 0).length;
const finalSnapshot = result.snapshots.at(-1);result.snapshots is an array of BacktestSnapshot — one per trading session. Each snapshot records the portfolio state, any orders submitted, and the fills received. Rebalance sessions are those where orders.length > 0.
Run it
From the repository root:
npx tsx scripts/docs/getting-started/first-strategy.tsExpected output (values depend on the synthetic price series):
sessions : 378
rebalances : 7
final cash : $1243.58
positions:
SPY qty=142 basis=$52489.23
QQQ qty=61 basis=$46267.19What's next
- Concepts — the four-layer stack in detail, interface contracts, and how layers compose.
- Recipes — swap in a real data feed, implement a custom executor, or extend the feature library.