@stratchai/backtest
v0.0.3
Published
Walk-forward backtesting primitives for systematic trading strategy validation. Composes with @stratchai/indicators.
Maintainers
Readme
@stratchai/backtest
Walk-forward backtesting primitives for systematic trading strategies. Honest out-of-sample validation in a small composable API — no hidden look-ahead bias, no in-sample-optimized "edges" that vanish in production.
const { walkForward, loadCohort } = require('@stratchai/backtest');
const { calcRSI, calcSMA } = require('@stratchai/indicators');
const cohort = loadCohort({ dir: './candles', match: f => f.match(/(\w+)\.json$/)?.[1], minCandles: 250 });
const result = walkForward({
cohort,
simulate: (candles, params) => /* your entry/exit logic */,
paramGrid: { rsi_oversold: [25, 28, 32], sl_pct: [-3, -5, -8] },
feeRoundTripPct: 0.10, // realistic broker fees
splitFraction: 0.6, // 60/40 chronological per product
defensible: { minN: 10, minMean: 0, minWinRate: 0.45 },
});
console.log(`${result.defensible.length} combos held up out-of-sample`);Why walk-forward?
Most public "backtest libraries" let you fit a strategy to history, declare victory, and ship something that fails on day one. They don't help with the things that actually matter:
- Look-ahead bias. Easy to leak future data into "historical" signals without noticing.
- Fee modeling. A 2% gross edge looks great until 1.5% round-trip fees turn it into −0.5% per trade.
- In-sample fragility. Sweep enough parameters on enough history and you'll always find a winner — that doesn't mean it works.
- Cohort degeneracy. A "top-10 winner" with 80% of trades from one product isn't a strategy, it's a single bet.
This package's walkForward orchestrator addresses each:
- Chronological 60/40 split per product (no random shuffling, no global splits that put short histories entirely in one bucket).
- Top-K winners selected on in-sample only, then re-evaluated on out-of-sample with no re-tuning.
- Configurable cohort gates (
minTradesPerCombo,minProductsPerCombo,minTradesPerProduct) to refuse rubber-stamp rankings. - Defensible filter (
OOS n ≥ minN ∧ mean > 0 ∧ win ≥ minWinRate) — three explicit thresholds, not one composite "score." - Fee application happens once per trade, idempotently, before stats are computed.
If a combo survives all that and shows positive OOS expectancy with reasonable cohort breadth, you've got evidence — not just a curve fit.
Install
npm install @stratchai/backtest @stratchai/indicators@stratchai/indicators is a peer dependency. You'll want it for RSI, MFI, SMA, Bollinger Bands, MACD, Ichimoku, etc.
Stratchai ecosystem
@stratchai/backtest composes with two sibling packages:
| Package | Purpose |
|---|---|
| @stratchai/indicators | 37 technical indicators (RSI, MFI, MACD, Bollinger, Ichimoku, etc.) + 12 series variants for backtesting |
| @stratchai/strategy-spec | Declarative strategy specs → generated JavaScript |
| @stratchai/backtest | Walk-forward audit primitives (this package) |
Typical workflow: write a spec with @stratchai/strategy-spec, audit it on history with @stratchai/backtest, then deploy the generated code into whatever live-trading runtime you use.
End-to-end example
const bt = require('@stratchai/backtest');
const { calcRSISeries, calcSMASeries } = require('@stratchai/indicators');
// 1. Load a cohort of candle series.
// match() returns a product id (e.g. "BTC-USD") or null to skip.
const cohort = bt.loadCohort({
dir: '/path/to/candles',
match: f => {
const m = f.match(/^([A-Z]+)_daily\.json$/);
return m ? m[1] : null;
},
minCandles: 250,
});
// 2. Define the entry/exit simulator.
// Return an array of { entryIdx, exitIdx, pnl_pct, reason, ... }.
function simulate(candles, params) {
const closes = candles.map(c => c.close);
const rsi = calcRSISeries(closes, 14);
const sma200 = calcSMASeries(closes, 200);
const trades = [];
for (let i = 200; i < candles.length; i++) {
if (rsi[i] != null && rsi[i] < params.rsi_oversold && closes[i] > sma200[i]) {
// Simple SL/PF exit — replace with your own
for (let j = i + 1; j < Math.min(i + 30, candles.length); j++) {
const pnl = (candles[j].close - candles[i].close) / candles[i].close * 100;
if (pnl <= params.sl_pct || pnl >= params.tp_pct || j === i + 29) {
trades.push({ entryIdx: i, exitIdx: j, pnl_pct: pnl });
i = j; break;
}
}
}
}
return trades;
}
// 3. Walk-forward sweep with cohort gates and fee model.
const result = bt.walkForward({
cohort,
simulate,
paramGrid: {
rsi_oversold: [25, 28, 32, 35],
sl_pct: [-3, -5, -8],
tp_pct: [10, 15, 20],
},
splitFraction: 0.6,
feeRoundTripPct: 0.10, // Alpaca-realistic; use 1.5 for Coinbase low-volume
minTradesPerCombo: 15,
minProductsPerCombo: 3,
minTradesPerProduct: 2,
rankBy: 'mean_sqrt_n', // robust to single-trade flukes; see API docs
topK: 10,
});
console.log(`Eligible: ${result.combos_eligible} / ${result.combos_total}`);
console.log(`Defensible: ${result.defensible.length}`);
for (const d of result.defensible) {
console.log(d.params, `in=${d.in_sample.mean.toFixed(2)}% out=${d.out_sample.mean.toFixed(2)}%`);
}API
Candle loading
| Function | Description |
|---|---|
| loadCandles(filePath) | Load + normalize a single candle file. Handles {candles: [...]} and bare arrays; timestamps in epoch ms, epoch seconds, or ISO strings. |
| loadCohort({ dir, match, minCandles }) | Build a { productId: candles[] } cohort by scanning a directory. |
| normalizeCandle(rawRow) | Convert one raw row to { ts, open, high, low, close, volume }. |
Walk-forward
| Function | Description |
|---|---|
| splitByDate(candles, fraction) | Compute the chronological split index. |
| walkForward({ cohort, simulate, paramGrid, ... }) | Full orchestrator. Returns { universe, combos_total, combos_eligible, combos_positive_in_sample, winners, defensible }. |
Statistics + ranking
| Function | Description |
|---|---|
| aggregate(trades) | { n, mean, median, win_rate, total, std }. |
| applyFees(trades, feeRoundTripPct) | Subtract round-trip fee once per trade. Idempotent. |
| rankCombos(combos, metric) | Sort by 'mean', 'median', 'mean_sqrt_n', or 'sharpe'. |
| topK(combos, k, metric) | Convenience wrapper around rankCombos. |
| defensibleFilter(winners, opts) | Keep only OOS survivors. Defaults: minN=10, minMean=0, minWinRate=0.45. |
Parameter grids
| Function | Description |
|---|---|
| paramCombinations(grid) | Generator yielding { paramName: value, ... } for every combination. |
| countCombinations(grid) | Cardinality without materializing. |
Methodology defaults
| Setting | Default | Rationale |
|---|---|---|
| Split | 60/40 chronological per product | Each product gets its own splitIdx; avoids a single global split that buckets short-history products entirely in one half. |
| Fee model | 1.5% round-trip | Calibrated against Coinbase low-volume taker fees. For Alpaca paper, use 0.10. |
| Defensible | OOS n ≥ 10 ∧ mean > 0 ∧ win_rate ≥ 0.45 | Three-condition AND, not OR. |
| Cohort gates | minTradesPerCombo=5, minProductsPerCombo=3, minTradesPerProduct=1 | At small samples, raise these to prevent rubber-stamp rankings. |
Status
v0.0.x. Plain JavaScript, zero runtime dependencies (besides the @stratchai/indicators peer dep). API may evolve as usage shapes it; semver patch bumps will not break the documented surface above.
