Synthetics
A SyntheticAsset lets you model a derived instrument — a leveraged ETF, an inverse fund, or a fee-bearing wrapper — entirely from the underlying asset's price history, without needing the real product's data. This is useful when backtesting hypothetical leveraged strategies or when you want to measure the drag of a particular expense ratio against an unencumbered index.
What is a SyntheticAsset?
type SyntheticAsset = {
id: AssetId; // stable id for this synthetic, e.g. 'us:SSO'
symbol: string; // display ticker, e.g. 'SSO'
underlying: AssetRef; // the real asset to derive bars from, e.g. SPY
leverage: number; // daily leverage multiplier (2 = 2x, -1 = inverse)
expense?: number; // annual expense ratio in percent, e.g. 0.91 for 0.91 %
tradeAs?: AssetRef; // if set, orders route to this real ticker at execution
};withSynthetics wraps any DataFeed so that bar requests for a synthetic id are transparently intercepted and synthesised from the underlying asset's bars. The raw DataFeed never needs to know about synthetic tickers.
The daily compounding math
Leveraged ETFs reset their exposure daily. The SDK replicates this exactly:
close_t = close_{t-1} × (1 + leverage × r_t) × (1 − expense / 252)Where:
r_t = (close_t_underlying − close_{t-1}_underlying) / close_{t-1}_underlyingis the underlying's daily return.leveragescales the return, including sign (use-1for an inverse fund).expense / 252is the per-day fee drag (expense ratio in percent, divided by trading days per year).
On the first bar there is no prior close, so the synthetic close equals the underlying close — effectively anchoring the series to the same starting price.
Daily reset and long-horizon volatility decay
Because leverage is applied daily rather than held constant, a 2× leveraged product does not deliver 2× the long-run return of its index. High-volatility periods erode levered returns through compounding. This is faithful to how real leveraged ETFs work, but it means the backtest result diverges from a naive 2× multiplier over multi-year horizons.
When to use synthetics
| Scenario | Recommended approach |
|---|---|
| Backtest a real leveraged ETF (SSO, TQQQ, etc.) | Use a synthetic. The SDK's compounding matches how these products actually behave. |
| Compare levered vs unlevered on the same underlying | Use a synthetic for the levered leg; real asset for the unlevered leg. |
| Model fee drag of index funds with different expense ratios | Use a SyntheticAsset with leverage: 1 and the target expense ratio. |
| Trade a real ETF live, but backtest it synthetically | Set tradeAs on the synthetic to route live orders to the real ticker. |
| The real product's history exists and is long enough | Use the real asset — no synthetic needed. |
Validation rules
The runtime enforces these constraints when a spec includes synthetics:
- No self-reference:
synthetic.idcannot equalsynthetic.underlying.id. - Universe collision check: if a synthetic shares an
idwith a universeAssetRef, they must have the samesymbol, and the underlying must itself be declared inuniverse. - No duplicate ids:
withSyntheticsthrows if the sameidappears twice in the synthetics array.
These checks run inside fromSpec (for the spec-level synthetics field) and inside withSynthetics (for the DataFeed wrapper). They fire at construction time, not at first bar, so misconfiguration surfaces immediately.
Wiring it together
Pass the synthetics both to the spec and to withSynthetics:
import { withSynthetics, fromSpec } from '@livefolio/sdk';
const SSO: SyntheticAsset = {
id: 'us:SSO',
symbol: 'SSO',
underlying: { id: 'us:SPY', symbol: 'SPY' },
leverage: 2,
expense: 0.91,
};
// Wrap the DataFeed — bar requests for 'us:SSO' are now synthesised.
const dataFeed = withSynthetics(rawDataFeed, [SSO]);
// Declare the synthetic in the spec so fromSpec knows it isn't a real ticker.
const spec: TacticalSpec = {
kind: 'tactical/v1',
universe: [{ id: 'us:SSO', symbol: 'SSO' }],
synthetics: [SSO],
rebalance: { frequency: 'Monthly' },
features: [],
rules: { op: 'allocate', weights: { 'us:SSO': 1.0 } },
};
const strategy = fromSpec(spec, { runtime, calendar });The DataFeed wrapper is the key step: without it, FeatureRuntime would request bars for 'us:SSO' from the raw feed and get an error (or wrong data).
Sample — unlevered vs 2× levered
The sample below defines an SSO synthetic (2× SPY, 0.91 % expense), runs a fully-invested backtest for each, and prints the final NAVs. It shows both how to wire withSynthetics and how the compounding diverges from a simple 2× multiplier.
// Synthetics — 2x leveraged SPY comparison backtest.
// Defines a SyntheticAsset that models SSO (ProShares Ultra S&P 500, 2x SPY)
// without needing the real ETF's price history. Runs two strategies side-by-side
// (unlevered vs levered) and prints final NAVs to show the compounding effect.
//
// npx tsx scripts/docs/guides-authoring/synthetics-leverage.ts
import {
fromSpec,
runBacktest,
withSynthetics,
FeatureRuntime,
NYSEExchangeCalendar,
MemoryFeatureCache,
BacktestExecutor,
} from '@livefolio/sdk';
import type { TacticalSpec, SyntheticAsset, Asset, Bar, DataFeed, DateRange, Frequency } from '@livefolio/sdk';
const utc = (s: string) => new Date(`${s}T00:00:00Z`);
// ─── Asset references ─────────────────────────────────────────────────────────
const SPY_REF = { id: 'us:SPY', symbol: 'SPY' };
// SSO is our synthetic — same exchange prefix, distinct id and symbol
const SSO_REF = { id: 'us:SSO', symbol: 'SSO' };
// ─── SyntheticAsset definition ────────────────────────────────────────────────
// leverage: 2 → each 1 % SPY move becomes a ~2 % SSO move (daily reset)
// expense: 0.91 → 0.91 % annual fee (matching SSO's real expense ratio),
// deducted as (0.91 / 252) per trading day
const SSO: SyntheticAsset = {
id: 'us:SSO',
symbol: 'SSO',
underlying: SPY_REF,
leverage: 2,
expense: 0.91,
};
// ─── In-memory DataFeed ───────────────────────────────────────────────────────
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 / 15) * 0.003;
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.0005),
};
// The raw DataFeed only knows about SPY.
// withSynthetics wraps it to intercept requests for SSO and synthesize bars.
const rawFeed: 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;
}
},
};
// withSynthetics returns a new DataFeed that transparently serves SSO bars.
const dataFeed = withSynthetics(rawFeed, [SSO]);
// ─── Strategy specs ───────────────────────────────────────────────────────────
// Unlevered: 100 % SPY (always-invested benchmark)
const specUnlevered: TacticalSpec = {
kind: 'tactical/v1',
universe: [SPY_REF],
rebalance: { frequency: 'Monthly' },
features: [],
rules: { op: 'allocate', weights: { 'us:SPY': 1.0 } },
};
// Levered: 100 % SSO (synthetic 2x SPY)
// Note: SSO must appear in the universe and in synthetics on the spec.
const specLevered: TacticalSpec = {
kind: 'tactical/v1',
universe: [SSO_REF],
synthetics: [SSO],
rebalance: { frequency: 'Monthly' },
features: [],
rules: { op: 'allocate', weights: { 'us:SSO': 1.0 } },
};
// ─── Run helper ───────────────────────────────────────────────────────────────
const calendar = new NYSEExchangeCalendar();
const range: DateRange = { from: utc('2022-06-01'), to: utc('2024-01-01') };
async function runSpec(spec: TacticalSpec): Promise<number> {
const featureCache = new MemoryFeatureCache();
const runtime = new FeatureRuntime({ dataFeed, featureCache, range, freq: '1d' });
const executor = new BacktestExecutor({
calendar,
nextOpen: async (asset, t) => {
// SSO bars are served by the synthetic DataFeed
const assetBars: Bar[] = [];
for await (const bar of dataFeed.bars(asset, { from: t, to: range.to }, '1d')) {
if (bar.t.getTime() > t.getTime()) {
assetBars.push(bar);
break;
}
}
const next = assetBars[0];
if (!next) throw new Error(`no bar after ${t.toISOString()} for ${asset.id}`);
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 last = result.snapshots.at(-1);
if (!last) return 0;
// NAV = cash + sum(qty * price). Prices approximate via position basis.
let nav = last.portfolio.cash;
for (const pos of last.portfolio.positions) {
nav += pos.quantity * pos.basis;
}
return nav;
}
const navUnlevered = await runSpec(specUnlevered);
const navLevered = await runSpec(specLevered);
console.log(`Unlevered SPY final NAV : $${navUnlevered.toFixed(2)}`);
console.log(`Levered 2x SSO final NAV : $${navLevered.toFixed(2)}`);
console.log(`Leverage ratio (approx) : ${(navLevered / navUnlevered).toFixed(2)}x`);
console.log('\nNote: daily-reset compounding and expense drag mean the levered');
console.log('ratio diverges from exactly 2x over longer holding periods.');What's next
- Rule trees — use synthetic feature values in comparisons (e.g. compare the synthetic's RSI against the underlying's RSI).
- Anatomy of a TacticalSpec —
syntheticsfield in context. - API:
SyntheticAsset,withSynthetics