npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stratchai/backtest

v0.0.3

Published

Walk-forward backtesting primitives for systematic trading strategy validation. Composes with @stratchai/indicators.

Readme

@stratchai/backtest

npm version npm downloads license node

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.

License

MIT