@calculator53295/backtest-engine
v0.1.0
Published
Pure TypeScript backtest and retirement-simulation engine with zero runtime dependencies: daily TWR/IRR backtesting, Monte Carlo retirement simulation, and a historical cohort engine with nine withdrawal strategies.
Maintainers
Readme
@calculator53295/backtest-engine
A pure-TypeScript, zero-runtime-dependency engine for portfolio backtesting and retirement simulation. Everything ships as plain functions over plain data — no network, no filesystem, no globals — so the same inputs produce the same numbers in a browser, a worker, or Node.
Three surfaces in one package:
- Daily backtest engine —
runBacktestwalks a portfolio over the intersection of its tickers' trading days, handling dividends (reinvested or accrued as cash), splits, monthly contributions, and rebalancing. It reports a time-weighted-return index (flows stripped out), a money-weighted IRR, plus drawdown, volatility, annual/monthly/rolling returns, and per-holding breakdowns. A two-asset efficient-frontier helper (twoAssetFrontier,minVarianceIndex,maxSharpeIndex) rounds it out. - Monte Carlo retirement simulator —
runHistoricalSequence,runBootstrap, andmaxSafeWithdrawalrun withdrawal plans over historical or block-bootstrapped real-return sequences and summarize success rate, balance and income percentiles, and pay-cut risk. Everything is in real (inflation-adjusted) dollars. - Historical cohort retirement engine (
retirement.*) — a bucketed stocks/bonds/cash simulator with glide paths, income and extra-withdrawal streams, fees, and a registry of nine withdrawal strategies (constant dollar, percent-of-portfolio, 1/N, VPW, Guyton-Klinger, CAPE-based, 95%-rule, endowment, Vanguard dynamic — plus legacy adapters).runAllCohortssweeps every historical start date;solveSwrback-solves the safe withdrawal rate.
A namespaced canonical module (canonical.*) provides a forward-compatible
type vocabulary and pure adapters that reconcile the two engine dialects lifted
into this repo, so consumers can share one vocabulary as the surfaces converge.
How this engine is tested
This package exists to be trusted by three production apps, so its behavior is pinned by tests at three levels — hand-computed synthetic fixtures for exact correctness, real-market regression bands for realism, and a golden parity fixture that locks results to the app the code was extracted from. The engine itself carries no personal financial data: only synthetic tuples and public market/Shiller data are used as fixtures.
| Fixture class | What it pins | Concrete examples (from the test suite) |
| --- | --- | --- |
| Hand-computed synthetic fixtures | Exact arithmetic of the daily engine — every value is derived by hand in the test, no tolerance beyond float epsilon. | A flat/linear price path 100 → 110 → 99 on a single asset must yield portfolio values 10 000 → 11 000 → 9 900 and totalReturn = −0.01 (engine/__tests__/backtest.test.ts). A $2 dividend where unadjusted close drops 100 → 99 yields 10 100 reinvested and 10 100 with $200 accrued cash when not. A 2:1 split (100 → 51, real +2%) must neutralize to 10 200. Two-asset 60/40 monthly rebalancing lands on 11 024; TWR strips a $1 000 monthly contribution so the index matches the no-contribution run exactly. |
| Real-data regression bands | The engine reproduces known market history within approximate ranges (vendor adjustment methods differ slightly). Runs against gitignored Tiingo data; auto-skips when absent. | SPY 1994–2023 CAGR ∈ (9%, 11%); GFC max drawdown ∈ (−58%, −50%) troughing in 2009-03 with a non-null recovery; 2008 annual return ∈ (−40%, −34%) (≈ −37%); AAPL 2020 4:1 split continuity — no daily move outside (0.85×, 1.15×); a 60/40 SPY/BND mix shows lower volatility and shallower drawdown than pure SPY and clamps its range to BND's 2007 inception (engine/__tests__/realdata.test.ts). |
| Golden Shiller cohort parity | The retirement cohort engine matches the app it was ported from, bit-for-bit. | The first 48 rows of retirement-sim's public shiller.csv (1871-01…1874-12) run through runAllCohorts must reproduce captured summary quantiles (real and nominal end balances) to 10 decimal places, with count = 3, successRate = 1, start dates 1871/1872/1873-01, worst cohort 1872-01, best 1871-01, and every year-1 real withdrawal ≈ $4 000 (retirement/__tests__/golden-cohort.test.ts). |
Consumers
One engine, three production apps by the same author — extracting the shared math here means simulation results are identical everywhere the same inputs appear, with no per-app drift:
- retirement-sim — the cohort retirement engine and withdrawal-strategy registry were ported from it.
- Fathom stock-analysis suite — the daily backtest and Monte Carlo engines
were lifted verbatim from its fixture-guarded
src/engine+src/montecarlo. - finance-master — shares the same engine.
Quickstart
Daily backtest — runBacktest
import { runBacktest } from "@calculator53295/backtest-engine";
const spy /* : TickerSeries */ = {
ticker: "SPY",
records: [
{ date: "2020-01-02", close: 100, adjClose: 100, divCash: 0, splitFactor: 1 },
{ date: "2020-01-03", close: 110, adjClose: 110, divCash: 0, splitFactor: 1 },
{ date: "2020-01-06", close: 99, adjClose: 99, divCash: 0, splitFactor: 1 },
],
};
const result = runBacktest(
[spy],
{ name: "All SPY", allocations: [{ ticker: "SPY", weight: 100 }] },
{ initialAmount: 10_000, monthlyContribution: 0, rebalance: "none", reinvestDividends: true },
);
result.values; // portfolio value per trading day
result.metrics.cagr; // + totalReturn, irr, volatility, drawdown, annualReturns…
result.twrIndex; // flow-stripped time-weighted-return indexRetirement Monte Carlo — runHistoricalSequence / maxSafeWithdrawal
import {
runHistoricalSequence,
maxSafeWithdrawal,
type RealReturnSeries,
type SimParams,
} from "@calculator53295/backtest-engine";
// Real (inflation-adjusted) monthly total returns, oldest first.
const series: RealReturnSeries = { dates: ["1994-01", /* … */], returns: [0.003, /* … */] };
const params: Omit<SimParams, "withdrawalRate"> = {
initialBalance: 1_000_000,
strategy: "fixedReal", // or "fixedPercent" | "vpw" | "guardrails"
horizonYears: 30,
feeRate: 0.001,
};
const sim = runHistoricalSequence(series, { ...params, withdrawalRate: 0.04 });
sim.successRate; // fraction of historical cohorts that survived
sim.percentiles.p50; // median balance path, today's dollars
// Or back-solve the highest rate meeting a 95% success target:
const swr = maxSafeWithdrawal(series, params, 0.95);Historical cohort engine — retirement.simulate
import { retirement } from "@calculator53295/backtest-engine";
// or: import { simulate, runAllCohorts } from "@calculator53295/backtest-engine/retirement";
// [date, spReturn, bondReturn, cashReturn, cpi] → HistoricalRow[]
const rows: retirement.HistoricalRow[] = [
{ date: "1871-01", spReturn: 0.0184, bondReturn: 0.0042, cashReturn: 0.0011, cpi: 12.4641 },
// …monthly public Shiller / asset-class rows…
];
const trial = retirement.simulate(
{
startDate: "1871-01",
horizonYears: 30,
stepFreq: "monthly",
initialBalance: 100_000,
initialAllocation: { stocks: 0.6, bonds: 0.3, cash: 0.1 },
strategy: { kind: "constant-dollar", annualAmount: 40_000, inflationAdjusted: true, sourcing: "proportional" },
rebalance: "yearly",
},
rows,
);
trial.endBalanceReal; // ending balance, today's dollars
trial.ranOutAtPeriod; // null if the plan survived
// Sweep every start date in the dataset:
const { summary } = retirement.runAllCohorts({ /* config without startDate */ }, rows);
summary.successRate;The cohort engine takes an injectable random source (RandomSource) for its
sampling helpers (e.g. pickRandomStart), so simulations stay deterministic and
testable — pass a seeded RNG like the exported mulberry32.
API stability
This package is 0.x: minor releases may include breaking changes to the two
flat engine dialects while the surfaces converge. The canonical module is
the forward-compatible vocabulary — prefer it for long-lived integrations. The
exported VERSION constant tracks the package version.
License
MIT
