Skip to content

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.

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

ts
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 }, where id is 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 string id. The id is the handle used in the rules tree.
  • rebalance — how often the strategy reconsiders its allocation. 'Weekly' means once per trading week.
  • rules — a tree of if/else nodes that resolves to a single allocate leaf on each rebalance day. The cond compares two feature references; then and else are further nodes or leaf allocations.

See TacticalSpec and RuleNode in the API reference.


Step 2 — Build a DataFeed

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

See DataFeed and Bar.


Step 3 — Wire the runtime layers

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

LayerPurposeReference impl used here
CalendarTrading-day arithmetic (sessions, next/prev day)NYSEExchangeCalendar
FeatureCacheMemoize indicator results by (spec, asset, date)MemoryFeatureCache
DataFeedProvide OHLCV barssynthetic in-memory feed
ExecutorSubmit orders, return fills, track portfolioBacktestExecutor

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

ts
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

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

bash
npx tsx scripts/docs/getting-started/first-strategy.ts

Expected 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.19

What'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.

Released under the MIT License.