backtest-kit 11.6.0 โ†’ 11.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,7 +80,7 @@ Install the core library and peer dependencies manually. Use this approach when
80
80
  - ๐Ÿ”Œ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
81
81
  - ๐Ÿ—ƒ๏ธ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes โ€” exchange rejection rolls back the operation atomically.
82
82
  - โฐ **Built-in Crontab**: Register periodic or fire-once jobs that fire on virtual-time boundaries with singleshot coordination across parallel backtests โ€” one handler invocation per boundary, no double-fires.
83
- - ๐Ÿงช **Tested**: 770+ unit/integration tests for validation, recovery, and events.
83
+ - ๐Ÿงช **Tested**: 775+ unit/integration tests for validation, recovery, and events.
84
84
  - ๐Ÿ”“ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
85
85
 
86
86
  ## ๐Ÿ“‹ Supported Order Types
@@ -1984,7 +1984,7 @@ Python-based (WASI) strategy that uses EMA(9) and EMA(21) crossover signals exec
1984
1984
 
1985
1985
  ## โœ… Tested & Reliable
1986
1986
 
1987
- 770+ tests cover validation, recovery, reports, and events.
1987
+ 775+ tests cover validation, recovery, reports, and events.
1988
1988
 
1989
1989
  ## ๐Ÿค Contribute
1990
1990
 
package/build/index.cjs CHANGED
@@ -23978,13 +23978,19 @@ let ReportStorage$a = class ReportStorage {
23978
23978
  // mark-to-market low); equity then moves to the realized close.
23979
23979
  // If equity (at trough or close) goes โ‰ค 0 (e.g. leveraged loss < -100%) โ€” account
23980
23980
  // blown, fix DD at 100% and stop walking the curve.
23981
+ // Walk the equity curve in chronological close order. Storage is
23982
+ // newest-first (unshift on addSignal); reverse-storage iteration normally
23983
+ // gives chronological order, but explicitly sorting by closeTimestamp
23984
+ // removes the dependency on insertion-order matching close-order (which
23985
+ // can break under crash recovery, signal backfill, or disk replays).
23986
+ const orderedSignals = [...validSignals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
23981
23987
  let equity = 1;
23982
23988
  let peak = 1;
23983
23989
  let equityMaxDrawdown = 0;
23984
23990
  let blown = false;
23985
- for (let i = validSignals.length - 1; i >= 0; i--) {
23991
+ for (const s of orderedSignals) {
23986
23992
  // Intra-trade trough โ€” mark-to-market low while the position was open.
23987
- const fallPct = validSignals[i].signal.maxDrawdown?.pnlPercentage;
23993
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
23988
23994
  if (typeof fallPct === "number" && fallPct < 0) {
23989
23995
  const trough = equity * (1 + fallPct / 100);
23990
23996
  if (trough <= 0) {
@@ -23997,7 +24003,7 @@ let ReportStorage$a = class ReportStorage {
23997
24003
  equityMaxDrawdown = troughDd;
23998
24004
  }
23999
24005
  // Realized close โ€” book the final per-trade result.
24000
- equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
24006
+ equity *= 1 + s.pnl.pnlPercentage / 100;
24001
24007
  if (equity <= 0) {
24002
24008
  equityMaxDrawdown = 100;
24003
24009
  blown = true;
@@ -25142,14 +25148,20 @@ let ReportStorage$9 = class ReportStorage {
25142
25148
  // snapshot, โ‰ค 0) is applied as a trough BEFORE booking the realized close. Without it
25143
25149
  // the curve only steps at close, so a trade that dipped to -18% and recovered to +2%
25144
25150
  // would register zero drawdown โ€” understating DD and inflating Calmar/Recovery.
25145
- const chronological = [];
25146
- for (let i = validClosed.length - 1; i >= 0; i--) {
25147
- const fall = validClosed[i].fallPnl;
25148
- chronological.push({
25149
- r: validClosed[i].pnl,
25150
- fall: typeof fall === "number" ? fall : null,
25151
- });
25152
- }
25151
+ // Walk the equity curve in chronological close order. Reverse-storage
25152
+ // iteration (newest-first storage โ†’ reverse) normally yields chronological
25153
+ // order for live ingest, but explicitly sorting by event.timestamp removes
25154
+ // the dependency on insertion-order matching close-order. This matters
25155
+ // under crash recovery (events reloaded from disk in arbitrary order) and
25156
+ // when ingest latency reorders closed events relative to wall-clock time.
25157
+ const chronological = validClosed
25158
+ .map((e) => ({
25159
+ r: e.pnl,
25160
+ fall: typeof e.fallPnl === "number" ? e.fallPnl : null,
25161
+ ts: e.timestamp,
25162
+ }))
25163
+ .sort((a, b) => a.ts - b.ts)
25164
+ .map(({ r, fall }) => ({ r, fall }));
25153
25165
  let equity = 1;
25154
25166
  let peak = 1;
25155
25167
  let equityMaxDrawdown = 0;
@@ -27223,11 +27235,18 @@ class HeatmapStorage {
27223
27235
  let equityFinal = 1;
27224
27236
  let blown = false;
27225
27237
  if (signals.length > 0) {
27238
+ // Walk the per-symbol equity curve in chronological close order.
27239
+ // Storage is newest-first (unshift on addSignal), but if signals were
27240
+ // ingested out-of-order (e.g. Live + crash recovery loading from disk in
27241
+ // arbitrary order, or a backfill replay), reverse-storage iteration
27242
+ // would misplace peak/trough and silently distort maxDrawdown. Sorting
27243
+ // by closeTimestamp explicitly removes that dependency.
27244
+ const ordered = [...signals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
27226
27245
  let equity = 1;
27227
27246
  let peak = 1;
27228
27247
  let maxDD = 0;
27229
- for (let i = signals.length - 1; i >= 0; i--) {
27230
- const fallPct = signals[i].signal.maxDrawdown?.pnlPercentage;
27248
+ for (const s of ordered) {
27249
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
27231
27250
  if (typeof fallPct === "number" && fallPct < 0) {
27232
27251
  const trough = equity * (1 + fallPct / 100);
27233
27252
  if (trough <= 0) {
@@ -27239,7 +27258,7 @@ class HeatmapStorage {
27239
27258
  if (troughDd > maxDD)
27240
27259
  maxDD = troughDd;
27241
27260
  }
27242
- equity *= 1 + signals[i].pnl.pnlPercentage / 100;
27261
+ equity *= 1 + s.pnl.pnlPercentage / 100;
27243
27262
  if (equity <= 0) {
27244
27263
  maxDD = 100;
27245
27264
  blown = true;
@@ -27676,23 +27695,28 @@ class HeatmapStorage {
27676
27695
  let portfolioCertaintyRatio = null;
27677
27696
  let portfolioExpectedYearlyReturns = null;
27678
27697
  let portfolioTradesPerYear = null;
27679
- const allReturns = [];
27680
- // Parallel array of intra-trade troughs (โ‰ค 0), aligned 1:1 with allReturns,
27681
- // used for mark-to-market DD in the pooled equity curve below.
27682
- const allFalls = [];
27698
+ const pooledTrades = [];
27683
27699
  let poolFirstPendingAt = Infinity;
27684
27700
  let poolLastCloseAt = -Infinity;
27685
27701
  for (const signals of this.symbolData.values()) {
27686
27702
  for (const s of signals) {
27687
- allReturns.push(s.pnl.pnlPercentage);
27688
27703
  const fall = s.signal.maxDrawdown?.pnlPercentage;
27689
- allFalls.push(typeof fall === "number" ? fall : null);
27704
+ pooledTrades.push({
27705
+ r: s.pnl.pnlPercentage,
27706
+ fall: typeof fall === "number" ? fall : null,
27707
+ closeAt: s.closeTimestamp,
27708
+ });
27690
27709
  if (s.signal.pendingAt < poolFirstPendingAt)
27691
27710
  poolFirstPendingAt = s.signal.pendingAt;
27692
27711
  if (s.closeTimestamp > poolLastCloseAt)
27693
27712
  poolLastCloseAt = s.closeTimestamp;
27694
27713
  }
27695
27714
  }
27715
+ pooledTrades.sort((a, b) => a.closeAt - b.closeAt);
27716
+ const allReturns = pooledTrades.map((t) => t.r);
27717
+ // Parallel array of intra-trade troughs (โ‰ค 0), aligned 1:1 with allReturns,
27718
+ // used for mark-to-market DD in the pooled equity curve below.
27719
+ const allFalls = pooledTrades.map((t) => t.fall);
27696
27720
  if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
27697
27721
  const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27698
27722
  const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
@@ -28066,7 +28090,7 @@ class HeatmapStorage {
28066
28090
  `*Peak Profit PNL / Max Drawdown PNL: extremes โ€” the best best-case and worst worst-case observed across all trades. Tail behaviour the averages hide.*`,
28067
28091
  `*Avg Duration / Avg Win Duration / Avg Loss Duration: mean hold time in minutes (closeTimestamp - pendingAt). Winner-shorter-than-loser is a red flag ("cut winners short, let losers run").*`,
28068
28092
  `*Avg Consecutive Win/Loss PNL: average sum of pnlPercentage across consecutive streaks. Pairs with max streak length to show the typical (not worst-case) streak magnitude. Portfolio uses trade-count-weighted mean of per-symbol streak averages โ€” concatenating streaks across symbols would be meaningless (different markets, different timeframes).*`,
28069
- `*Max Drawdown: mark-to-market โ€” both the per-symbol and pooled equity curves apply each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery. NOTE: the pooled curve orders trades by storage sequence, not wall-clock time, so simultaneous cross-symbol drawdowns are not modelled.*`,
28093
+ `*Max Drawdown: mark-to-market โ€” both the per-symbol and pooled equity curves apply each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery. The pooled curve walks trades chronologically by closeTimestamp; simultaneous cross-symbol drawdowns within the same minute are still serialised (one trade applied at a time), so genuine same-instant tail correlation is not modelled.*`,
28070
28094
  `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
28071
28095
  `*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized return / drawdown will be roughly X/100 of the reported figures โ€” these metrics represent a theoretical upper bound under full allocation.*`,
28072
28096
  `*Negative values for Sharpe / Annualized Sharpe / Sortino / Calmar / Recovery / Expectancy / Expected Yearly Returns indicate a losing symbol (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies โ€” closer to zero is less bad, positive is profitable.*`,
package/build/index.mjs CHANGED
@@ -23958,13 +23958,19 @@ let ReportStorage$a = class ReportStorage {
23958
23958
  // mark-to-market low); equity then moves to the realized close.
23959
23959
  // If equity (at trough or close) goes โ‰ค 0 (e.g. leveraged loss < -100%) โ€” account
23960
23960
  // blown, fix DD at 100% and stop walking the curve.
23961
+ // Walk the equity curve in chronological close order. Storage is
23962
+ // newest-first (unshift on addSignal); reverse-storage iteration normally
23963
+ // gives chronological order, but explicitly sorting by closeTimestamp
23964
+ // removes the dependency on insertion-order matching close-order (which
23965
+ // can break under crash recovery, signal backfill, or disk replays).
23966
+ const orderedSignals = [...validSignals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
23961
23967
  let equity = 1;
23962
23968
  let peak = 1;
23963
23969
  let equityMaxDrawdown = 0;
23964
23970
  let blown = false;
23965
- for (let i = validSignals.length - 1; i >= 0; i--) {
23971
+ for (const s of orderedSignals) {
23966
23972
  // Intra-trade trough โ€” mark-to-market low while the position was open.
23967
- const fallPct = validSignals[i].signal.maxDrawdown?.pnlPercentage;
23973
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
23968
23974
  if (typeof fallPct === "number" && fallPct < 0) {
23969
23975
  const trough = equity * (1 + fallPct / 100);
23970
23976
  if (trough <= 0) {
@@ -23977,7 +23983,7 @@ let ReportStorage$a = class ReportStorage {
23977
23983
  equityMaxDrawdown = troughDd;
23978
23984
  }
23979
23985
  // Realized close โ€” book the final per-trade result.
23980
- equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
23986
+ equity *= 1 + s.pnl.pnlPercentage / 100;
23981
23987
  if (equity <= 0) {
23982
23988
  equityMaxDrawdown = 100;
23983
23989
  blown = true;
@@ -25122,14 +25128,20 @@ let ReportStorage$9 = class ReportStorage {
25122
25128
  // snapshot, โ‰ค 0) is applied as a trough BEFORE booking the realized close. Without it
25123
25129
  // the curve only steps at close, so a trade that dipped to -18% and recovered to +2%
25124
25130
  // would register zero drawdown โ€” understating DD and inflating Calmar/Recovery.
25125
- const chronological = [];
25126
- for (let i = validClosed.length - 1; i >= 0; i--) {
25127
- const fall = validClosed[i].fallPnl;
25128
- chronological.push({
25129
- r: validClosed[i].pnl,
25130
- fall: typeof fall === "number" ? fall : null,
25131
- });
25132
- }
25131
+ // Walk the equity curve in chronological close order. Reverse-storage
25132
+ // iteration (newest-first storage โ†’ reverse) normally yields chronological
25133
+ // order for live ingest, but explicitly sorting by event.timestamp removes
25134
+ // the dependency on insertion-order matching close-order. This matters
25135
+ // under crash recovery (events reloaded from disk in arbitrary order) and
25136
+ // when ingest latency reorders closed events relative to wall-clock time.
25137
+ const chronological = validClosed
25138
+ .map((e) => ({
25139
+ r: e.pnl,
25140
+ fall: typeof e.fallPnl === "number" ? e.fallPnl : null,
25141
+ ts: e.timestamp,
25142
+ }))
25143
+ .sort((a, b) => a.ts - b.ts)
25144
+ .map(({ r, fall }) => ({ r, fall }));
25133
25145
  let equity = 1;
25134
25146
  let peak = 1;
25135
25147
  let equityMaxDrawdown = 0;
@@ -27203,11 +27215,18 @@ class HeatmapStorage {
27203
27215
  let equityFinal = 1;
27204
27216
  let blown = false;
27205
27217
  if (signals.length > 0) {
27218
+ // Walk the per-symbol equity curve in chronological close order.
27219
+ // Storage is newest-first (unshift on addSignal), but if signals were
27220
+ // ingested out-of-order (e.g. Live + crash recovery loading from disk in
27221
+ // arbitrary order, or a backfill replay), reverse-storage iteration
27222
+ // would misplace peak/trough and silently distort maxDrawdown. Sorting
27223
+ // by closeTimestamp explicitly removes that dependency.
27224
+ const ordered = [...signals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
27206
27225
  let equity = 1;
27207
27226
  let peak = 1;
27208
27227
  let maxDD = 0;
27209
- for (let i = signals.length - 1; i >= 0; i--) {
27210
- const fallPct = signals[i].signal.maxDrawdown?.pnlPercentage;
27228
+ for (const s of ordered) {
27229
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
27211
27230
  if (typeof fallPct === "number" && fallPct < 0) {
27212
27231
  const trough = equity * (1 + fallPct / 100);
27213
27232
  if (trough <= 0) {
@@ -27219,7 +27238,7 @@ class HeatmapStorage {
27219
27238
  if (troughDd > maxDD)
27220
27239
  maxDD = troughDd;
27221
27240
  }
27222
- equity *= 1 + signals[i].pnl.pnlPercentage / 100;
27241
+ equity *= 1 + s.pnl.pnlPercentage / 100;
27223
27242
  if (equity <= 0) {
27224
27243
  maxDD = 100;
27225
27244
  blown = true;
@@ -27656,23 +27675,28 @@ class HeatmapStorage {
27656
27675
  let portfolioCertaintyRatio = null;
27657
27676
  let portfolioExpectedYearlyReturns = null;
27658
27677
  let portfolioTradesPerYear = null;
27659
- const allReturns = [];
27660
- // Parallel array of intra-trade troughs (โ‰ค 0), aligned 1:1 with allReturns,
27661
- // used for mark-to-market DD in the pooled equity curve below.
27662
- const allFalls = [];
27678
+ const pooledTrades = [];
27663
27679
  let poolFirstPendingAt = Infinity;
27664
27680
  let poolLastCloseAt = -Infinity;
27665
27681
  for (const signals of this.symbolData.values()) {
27666
27682
  for (const s of signals) {
27667
- allReturns.push(s.pnl.pnlPercentage);
27668
27683
  const fall = s.signal.maxDrawdown?.pnlPercentage;
27669
- allFalls.push(typeof fall === "number" ? fall : null);
27684
+ pooledTrades.push({
27685
+ r: s.pnl.pnlPercentage,
27686
+ fall: typeof fall === "number" ? fall : null,
27687
+ closeAt: s.closeTimestamp,
27688
+ });
27670
27689
  if (s.signal.pendingAt < poolFirstPendingAt)
27671
27690
  poolFirstPendingAt = s.signal.pendingAt;
27672
27691
  if (s.closeTimestamp > poolLastCloseAt)
27673
27692
  poolLastCloseAt = s.closeTimestamp;
27674
27693
  }
27675
27694
  }
27695
+ pooledTrades.sort((a, b) => a.closeAt - b.closeAt);
27696
+ const allReturns = pooledTrades.map((t) => t.r);
27697
+ // Parallel array of intra-trade troughs (โ‰ค 0), aligned 1:1 with allReturns,
27698
+ // used for mark-to-market DD in the pooled equity curve below.
27699
+ const allFalls = pooledTrades.map((t) => t.fall);
27676
27700
  if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
27677
27701
  const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27678
27702
  const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
@@ -28046,7 +28070,7 @@ class HeatmapStorage {
28046
28070
  `*Peak Profit PNL / Max Drawdown PNL: extremes โ€” the best best-case and worst worst-case observed across all trades. Tail behaviour the averages hide.*`,
28047
28071
  `*Avg Duration / Avg Win Duration / Avg Loss Duration: mean hold time in minutes (closeTimestamp - pendingAt). Winner-shorter-than-loser is a red flag ("cut winners short, let losers run").*`,
28048
28072
  `*Avg Consecutive Win/Loss PNL: average sum of pnlPercentage across consecutive streaks. Pairs with max streak length to show the typical (not worst-case) streak magnitude. Portfolio uses trade-count-weighted mean of per-symbol streak averages โ€” concatenating streaks across symbols would be meaningless (different markets, different timeframes).*`,
28049
- `*Max Drawdown: mark-to-market โ€” both the per-symbol and pooled equity curves apply each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery. NOTE: the pooled curve orders trades by storage sequence, not wall-clock time, so simultaneous cross-symbol drawdowns are not modelled.*`,
28073
+ `*Max Drawdown: mark-to-market โ€” both the per-symbol and pooled equity curves apply each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery. The pooled curve walks trades chronologically by closeTimestamp; simultaneous cross-symbol drawdowns within the same minute are still serialised (one trade applied at a time), so genuine same-instant tail correlation is not modelled.*`,
28050
28074
  `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
28051
28075
  `*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized return / drawdown will be roughly X/100 of the reported figures โ€” these metrics represent a theoretical upper bound under full allocation.*`,
28052
28076
  `*Negative values for Sharpe / Annualized Sharpe / Sortino / Calmar / Recovery / Expectancy / Expected Yearly Returns indicate a losing symbol (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies โ€” closer to zero is less bad, positive is profitable.*`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "11.6.0",
3
+ "version": "11.7.0",
4
4
  "description": "A TypeScript library for trading system backtest",
5
5
  "author": {
6
6
  "name": "Petr Tripolsky",