backtest-kit 10.2.0 → 11.2.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/build/index.cjs CHANGED
@@ -818,6 +818,13 @@ const beforeStartSubject = new functoolsKit.Subject();
818
818
  * Emits when the engine has completed processing a signal.
819
819
  */
820
820
  const afterEndSubject = new functoolsKit.Subject();
821
+ /**
822
+ * Emitter for `@backtest-kit/cli`, which notifies the application
823
+ * that all modules have been initialized.
824
+ *
825
+ * Send entry absolute path to the consumer
826
+ */
827
+ const entrySubject = new functoolsKit.BehaviorSubject();
821
828
 
822
829
  var emitters = /*#__PURE__*/Object.freeze({
823
830
  __proto__: null,
@@ -829,6 +836,7 @@ var emitters = /*#__PURE__*/Object.freeze({
829
836
  doneBacktestSubject: doneBacktestSubject,
830
837
  doneLiveSubject: doneLiveSubject,
831
838
  doneWalkerSubject: doneWalkerSubject,
839
+ entrySubject: entrySubject,
832
840
  errorEmitter: errorEmitter,
833
841
  exitEmitter: exitEmitter,
834
842
  highestProfitSubject: highestProfitSubject,
@@ -6584,7 +6592,7 @@ const INTERVAL_MINUTES$8 = {
6584
6592
  * Used to indicate that the actual pendingAt will be set upon activation.
6585
6593
  */
6586
6594
  const SCHEDULED_SIGNAL_PENDING_MOCK = 0;
6587
- const TIMEOUT_SYMBOL = Symbol('timeout');
6595
+ const TIMEOUT_SYMBOL$1 = Symbol('timeout');
6588
6596
  /**
6589
6597
  * Calls onSignalSync callback for signal-open event.
6590
6598
  *
@@ -7006,7 +7014,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
7006
7014
  const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
7007
7015
  const signal = await Promise.race([
7008
7016
  self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
7009
- functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL),
7017
+ functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
7010
7018
  ]);
7011
7019
  if (typeof signal === "symbol") {
7012
7020
  throw new Error(`Timeout for ${self.params.method.context.strategyName} symbol=${self.params.execution.context.symbol}`);
@@ -37581,8 +37589,9 @@ function getActionSchema(actionName) {
37581
37589
  }
37582
37590
 
37583
37591
  const WAIT_FOR_READY_METHOD_NAME = "init.waitForReady";
37584
- const MAX_WAIT_SECONDS = 10;
37592
+ const MAX_WAIT_SECONDS = 45;
37585
37593
  const SECOND_DELAY = 1000;
37594
+ const TIMEOUT_SYMBOL = Symbol('timeout');
37586
37595
  /**
37587
37596
  * Blocks until the schema registries needed to start trading are populated.
37588
37597
  *
@@ -37620,6 +37629,18 @@ const SECOND_DELAY = 1000;
37620
37629
  */
37621
37630
  async function waitForReady(isBacktest = true) {
37622
37631
  backtest.loggerService.info(WAIT_FOR_READY_METHOD_NAME, { isBacktest });
37632
+ if (entrySubject.data) {
37633
+ return;
37634
+ }
37635
+ if (entrySubject.hasListeners) {
37636
+ backtest.loggerService.debug(`${WAIT_FOR_READY_METHOD_NAME} waiting for entrySubject`);
37637
+ const result = await Promise.race([
37638
+ entrySubject.toPromise(),
37639
+ functoolsKit.sleep(MAX_WAIT_SECONDS * SECOND_DELAY).then(() => TIMEOUT_SYMBOL)
37640
+ ]);
37641
+ typeof result === "symbol" && console.log("waitForReady timeout");
37642
+ return;
37643
+ }
37623
37644
  for (let i = 0; i !== MAX_WAIT_SECONDS; i++) {
37624
37645
  const [exchangeList, frameList, strategyList] = await Promise.all([
37625
37646
  backtest.exchangeValidationService.list(),
@@ -37650,6 +37671,9 @@ async function waitForReady(isBacktest = true) {
37650
37671
  await functoolsKit.sleep(SECOND_DELAY);
37651
37672
  continue;
37652
37673
  }
37674
+ if (i === MAX_WAIT_SECONDS - 1) {
37675
+ console.log("waitForReady timeout");
37676
+ }
37653
37677
  break;
37654
37678
  }
37655
37679
  }
@@ -63835,6 +63859,34 @@ class CronUtils {
63835
63859
  * on successful settle.
63836
63860
  */
63837
63861
  this._firedOnce = new Set();
63862
+ /**
63863
+ * Last interval boundary already fired per periodic slot.
63864
+ *
63865
+ * Key shape (no `alignedMs` segment — one entry per logical slot, not per
63866
+ * boundary; always carries the generation suffix `:g${generation}`, and the
63867
+ * `:${symbol}` scope only in fan-out mode):
63868
+ * - Periodic global: `${name}${genSuffix}`.
63869
+ * - Periodic fan-out: `${name}:${symbol}${genSuffix}`.
63870
+ *
63871
+ * Value is the aligned-boundary epoch ms (`alignedMs`) most recently opened
63872
+ * for that slot. `_tick` fires a periodic entry whenever the incoming tick's
63873
+ * aligned boundary is **strictly greater** than the stored value, instead of
63874
+ * requiring the tick to land *exactly* on the boundary. This fixes the
63875
+ * dropped-boundary bug: when virtual time jumps over a boundary (e.g. a
63876
+ * `5m`-driven loop skipping from 00:14 to 00:29 never lands on the `15m`
63877
+ * 00:15 boundary), the old `ts === alignedMs` check silently lost the tick.
63878
+ * With the watermark, the next tick whose `alignedMs` advanced past the
63879
+ * stored value fires once for the newest crossed boundary (catch-up
63880
+ * collapses multiple skipped boundaries into a single invocation at the
63881
+ * latest one).
63882
+ *
63883
+ * Written synchronously in `_tick` at slot-open time (before the `await`),
63884
+ * so a still-in-flight handler does not let a later tick re-open the same
63885
+ * (or an already-passed) boundary. Fire-once entries never touch this map —
63886
+ * they use `_firedOnce`. Pruned by `_clearBoundaryFor` on
63887
+ * `register`/`unregister` and wiped by `dispose`.
63888
+ */
63889
+ this._lastBoundary = new Map();
63838
63890
  /**
63839
63891
  * Register a periodic cron entry.
63840
63892
  *
@@ -63880,6 +63932,7 @@ class CronUtils {
63880
63932
  }
63881
63933
  }
63882
63934
  this._clearFiredOnceFor(entry.name);
63935
+ this._clearBoundaryFor(entry.name);
63883
63936
  const generation = ++this._generationCounter;
63884
63937
  this._entries.set(entry.name, { entry, generation });
63885
63938
  return () => this.unregister(entry.name);
@@ -63896,6 +63949,7 @@ class CronUtils {
63896
63949
  LOGGER_SERVICE$1.info(CRON_METHOD_NAME_UNREGISTER, { name });
63897
63950
  this._entries.delete(name);
63898
63951
  this._clearFiredOnceFor(name);
63952
+ this._clearBoundaryFor(name);
63899
63953
  };
63900
63954
  /**
63901
63955
  * Clear fire-once marks so that fire-once entries can fire again.
@@ -63972,11 +64026,24 @@ class CronUtils {
63972
64026
  * - Slot key: `${name}:once` (+ scope) (+ gen).
63973
64027
  * - `aligned` = the 1-minute-aligned `when` from step 0.
63974
64028
  * 5. **Periodic** (`entry.interval` set):
63975
- * - Align `when` further to the entry's interval via {@link alignToInterval}.
63976
- * - If `ts !== alignedMs`, the tick is mid-interval skip.
63977
- * (This is the "remainder === 0" boundary check from the spec;
63978
- * since `ts` is already on the 1-minute boundary, the check is exact
63979
- * for `1m` and consistent for higher intervals.)
64029
+ * - Align `when` to the entry's interval via {@link alignToInterval} to
64030
+ * get `alignedMs`, the boundary this tick belongs to.
64031
+ * - Compare against the slot's watermark in `_lastBoundary` (keyed by
64032
+ * `${name}` + scope + gen, without the `alignedMs` segment). If a
64033
+ * watermark exists and `alignedMs <= lastBoundary`, this boundary was
64034
+ * already fired — skip.
64035
+ * - This **watermark** check replaces the old exact `ts === alignedMs`
64036
+ * match. The exact match required virtual time to land *precisely* on
64037
+ * the boundary; when a tick jumped clean over a boundary (e.g. a `5m`
64038
+ * loop going 00:14 → 00:29 never touching the `15m` 00:15 boundary)
64039
+ * the boundary was silently lost. With the watermark, the first tick
64040
+ * whose `alignedMs` advanced past the stored value fires once, at the
64041
+ * newest crossed boundary (catch-up collapses several skipped
64042
+ * boundaries into a single invocation at the latest one).
64043
+ * - The watermark is advanced to `alignedMs` synchronously when the slot
64044
+ * is opened (before the `await`), so a concurrent tick on the same or
64045
+ * an already-passed boundary cannot open a duplicate slot while the
64046
+ * handler is still in flight.
63980
64047
  * - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
63981
64048
  * 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
63982
64049
  * already exists, `await` the same promise. Otherwise invoke
@@ -64025,6 +64092,9 @@ class CronUtils {
64025
64092
  let alignedMs;
64026
64093
  let slotKey;
64027
64094
  let firedKey;
64095
+ // Periodic-only watermark key (no `alignedMs` segment); null for
64096
+ // fire-once entries, which coordinate via `_firedOnce` instead.
64097
+ let boundaryKey;
64028
64098
  if (entry.interval === undefined) {
64029
64099
  const onceKey = `${entry.name}${scope}${genSuffix}`;
64030
64100
  if (this._firedOnce.has(onceKey)) {
@@ -64034,11 +64104,18 @@ class CronUtils {
64034
64104
  alignedMs = ts;
64035
64105
  slotKey = `${entry.name}:once${scope}${genSuffix}`;
64036
64106
  firedKey = onceKey;
64107
+ boundaryKey = null;
64037
64108
  }
64038
64109
  else {
64039
64110
  aligned = alignToInterval(when, entry.interval);
64040
64111
  alignedMs = aligned.getTime();
64041
- if (ts !== alignedMs) {
64112
+ boundaryKey = `${entry.name}${scope}${genSuffix}`;
64113
+ const lastBoundary = this._lastBoundary.get(boundaryKey);
64114
+ // Fire when the tick's aligned boundary has advanced past the last one
64115
+ // we fired for this slot. Using `>` instead of the old `ts === alignedMs`
64116
+ // means a virtual-time jump that skips clean over a boundary still
64117
+ // fires once, at the newest crossed boundary, rather than dropping it.
64118
+ if (lastBoundary !== undefined && alignedMs <= lastBoundary) {
64042
64119
  continue;
64043
64120
  }
64044
64121
  slotKey = `${entry.name}:${alignedMs}${scope}${genSuffix}`;
@@ -64046,6 +64123,13 @@ class CronUtils {
64046
64123
  }
64047
64124
  let pending = this._inFlight.get(slotKey);
64048
64125
  if (!pending) {
64126
+ // Advance the watermark synchronously at slot-open time, before the
64127
+ // await below. Otherwise a later tick on the same (or an already
64128
+ // crossed) boundary, arriving while this handler is still in flight,
64129
+ // would see the stale watermark and open a duplicate slot.
64130
+ if (boundaryKey !== null) {
64131
+ this._lastBoundary.set(boundaryKey, alignedMs);
64132
+ }
64049
64133
  pending = this._runEntry(entry, symbol, aligned, alignedMs, slotKey, firedKey, backtest);
64050
64134
  this._inFlight.set(slotKey, pending);
64051
64135
  }
@@ -64137,7 +64221,10 @@ class CronUtils {
64137
64221
  * 3. Wipes `_firedOnce` — all fire-once marks are dropped, so any future
64138
64222
  * re-registration of the same `name` fires again on the next matching
64139
64223
  * tick.
64140
- * 4. Does **not** touch `_inFlight` — in-flight handlers continue to
64224
+ * 4. Wipes `_lastBoundary` — all periodic watermarks are dropped, so a
64225
+ * re-registered periodic entry starts firing from its next crossed
64226
+ * boundary again.
64227
+ * 5. Does **not** touch `_inFlight` — in-flight handlers continue to
64141
64228
  * settle in the background and clear their own slots via `.finally()`.
64142
64229
  * Their final `_firedOnce.add(firedKey)` writes carry old-generation
64143
64230
  * keys and are harmless (lookup uses the post-dispose generation).
@@ -64156,8 +64243,29 @@ class CronUtils {
64156
64243
  this.disable();
64157
64244
  this._entries.clear();
64158
64245
  this._firedOnce.clear();
64246
+ this._lastBoundary.clear();
64159
64247
  };
64160
64248
  }
64249
+ /**
64250
+ * Garbage-collect every `_lastBoundary` key that belongs to the entry `name`
64251
+ * (any generation, global or fan-out).
64252
+ *
64253
+ * Called from `register`/`unregister` alongside `_clearFiredOnceFor`. Like
64254
+ * that helper this is memory hygiene, not correctness — the generation suffix
64255
+ * already isolates re-registrations, so a stale watermark from an old
64256
+ * generation can never gate a new entry.
64257
+ */
64258
+ _clearBoundaryFor(name) {
64259
+ if (!name) {
64260
+ return;
64261
+ }
64262
+ const prefix = `${name}:`;
64263
+ for (const key of this._lastBoundary.keys()) {
64264
+ if (key === name || key.startsWith(prefix)) {
64265
+ this._lastBoundary.delete(key);
64266
+ }
64267
+ }
64268
+ }
64161
64269
  /**
64162
64270
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`
64163
64271
  * (any generation, global or fan-out).
package/build/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createActivator } from 'di-kit';
2
2
  import { scoped } from 'di-scoped';
3
3
  import { singleton } from 'di-singleton';
4
- import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, not, errorData, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, Source, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, BehaviorSubject, waitForNext, singlerun } from 'functools-kit';
4
+ import { Subject, BehaviorSubject, makeExtendable, singleshot, getErrorMessage, memoize, not, errorData, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, Source, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$2, compose, waitForNext, singlerun } from 'functools-kit';
5
5
  import * as fs from 'fs/promises';
6
6
  import fs__default from 'fs/promises';
7
7
  import path, { join, dirname } from 'path';
@@ -798,6 +798,13 @@ const beforeStartSubject = new Subject();
798
798
  * Emits when the engine has completed processing a signal.
799
799
  */
800
800
  const afterEndSubject = new Subject();
801
+ /**
802
+ * Emitter for `@backtest-kit/cli`, which notifies the application
803
+ * that all modules have been initialized.
804
+ *
805
+ * Send entry absolute path to the consumer
806
+ */
807
+ const entrySubject = new BehaviorSubject();
801
808
 
802
809
  var emitters = /*#__PURE__*/Object.freeze({
803
810
  __proto__: null,
@@ -809,6 +816,7 @@ var emitters = /*#__PURE__*/Object.freeze({
809
816
  doneBacktestSubject: doneBacktestSubject,
810
817
  doneLiveSubject: doneLiveSubject,
811
818
  doneWalkerSubject: doneWalkerSubject,
819
+ entrySubject: entrySubject,
812
820
  errorEmitter: errorEmitter,
813
821
  exitEmitter: exitEmitter,
814
822
  highestProfitSubject: highestProfitSubject,
@@ -6564,7 +6572,7 @@ const INTERVAL_MINUTES$8 = {
6564
6572
  * Used to indicate that the actual pendingAt will be set upon activation.
6565
6573
  */
6566
6574
  const SCHEDULED_SIGNAL_PENDING_MOCK = 0;
6567
- const TIMEOUT_SYMBOL = Symbol('timeout');
6575
+ const TIMEOUT_SYMBOL$1 = Symbol('timeout');
6568
6576
  /**
6569
6577
  * Calls onSignalSync callback for signal-open event.
6570
6578
  *
@@ -6986,7 +6994,7 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
6986
6994
  const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
6987
6995
  const signal = await Promise.race([
6988
6996
  self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
6989
- sleep(timeoutMs).then(() => TIMEOUT_SYMBOL),
6997
+ sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
6990
6998
  ]);
6991
6999
  if (typeof signal === "symbol") {
6992
7000
  throw new Error(`Timeout for ${self.params.method.context.strategyName} symbol=${self.params.execution.context.symbol}`);
@@ -23252,7 +23260,7 @@ class MarkdownFileBase {
23252
23260
  timestamp: getContextTimestamp(),
23253
23261
  }) + "\n";
23254
23262
  const status = await this[WRITE_SAFE_SYMBOL$1](line);
23255
- if (status === TIMEOUT_SYMBOL$1) {
23263
+ if (status === TIMEOUT_SYMBOL$2) {
23256
23264
  throw new Error(`Timeout writing to markdown ${this.markdownName}`);
23257
23265
  }
23258
23266
  }
@@ -23545,7 +23553,7 @@ class ReportBase {
23545
23553
  timestamp: getContextTimestamp(),
23546
23554
  }) + "\n";
23547
23555
  const status = await this[WRITE_SAFE_SYMBOL$1](line);
23548
- if (status === TIMEOUT_SYMBOL$1) {
23556
+ if (status === TIMEOUT_SYMBOL$2) {
23549
23557
  throw new Error(`Timeout writing to report ${this.reportName}`);
23550
23558
  }
23551
23559
  }
@@ -37561,8 +37569,9 @@ function getActionSchema(actionName) {
37561
37569
  }
37562
37570
 
37563
37571
  const WAIT_FOR_READY_METHOD_NAME = "init.waitForReady";
37564
- const MAX_WAIT_SECONDS = 10;
37572
+ const MAX_WAIT_SECONDS = 45;
37565
37573
  const SECOND_DELAY = 1000;
37574
+ const TIMEOUT_SYMBOL = Symbol('timeout');
37566
37575
  /**
37567
37576
  * Blocks until the schema registries needed to start trading are populated.
37568
37577
  *
@@ -37600,6 +37609,18 @@ const SECOND_DELAY = 1000;
37600
37609
  */
37601
37610
  async function waitForReady(isBacktest = true) {
37602
37611
  backtest.loggerService.info(WAIT_FOR_READY_METHOD_NAME, { isBacktest });
37612
+ if (entrySubject.data) {
37613
+ return;
37614
+ }
37615
+ if (entrySubject.hasListeners) {
37616
+ backtest.loggerService.debug(`${WAIT_FOR_READY_METHOD_NAME} waiting for entrySubject`);
37617
+ const result = await Promise.race([
37618
+ entrySubject.toPromise(),
37619
+ sleep(MAX_WAIT_SECONDS * SECOND_DELAY).then(() => TIMEOUT_SYMBOL)
37620
+ ]);
37621
+ typeof result === "symbol" && console.log("waitForReady timeout");
37622
+ return;
37623
+ }
37603
37624
  for (let i = 0; i !== MAX_WAIT_SECONDS; i++) {
37604
37625
  const [exchangeList, frameList, strategyList] = await Promise.all([
37605
37626
  backtest.exchangeValidationService.list(),
@@ -37630,6 +37651,9 @@ async function waitForReady(isBacktest = true) {
37630
37651
  await sleep(SECOND_DELAY);
37631
37652
  continue;
37632
37653
  }
37654
+ if (i === MAX_WAIT_SECONDS - 1) {
37655
+ console.log("waitForReady timeout");
37656
+ }
37633
37657
  break;
37634
37658
  }
37635
37659
  }
@@ -55421,7 +55445,7 @@ class LogJsonlUtils {
55421
55445
  await this[WAIT_FOR_INIT_SYMBOL]();
55422
55446
  const line = JSON.stringify(entry) + "\n";
55423
55447
  const status = await this[WRITE_SAFE_SYMBOL](line);
55424
- if (status === TIMEOUT_SYMBOL$1) {
55448
+ if (status === TIMEOUT_SYMBOL$2) {
55425
55449
  throw new Error(`LogJsonlUtils timeout writing to file=${this._filePath}`);
55426
55450
  }
55427
55451
  };
@@ -63815,6 +63839,34 @@ class CronUtils {
63815
63839
  * on successful settle.
63816
63840
  */
63817
63841
  this._firedOnce = new Set();
63842
+ /**
63843
+ * Last interval boundary already fired per periodic slot.
63844
+ *
63845
+ * Key shape (no `alignedMs` segment — one entry per logical slot, not per
63846
+ * boundary; always carries the generation suffix `:g${generation}`, and the
63847
+ * `:${symbol}` scope only in fan-out mode):
63848
+ * - Periodic global: `${name}${genSuffix}`.
63849
+ * - Periodic fan-out: `${name}:${symbol}${genSuffix}`.
63850
+ *
63851
+ * Value is the aligned-boundary epoch ms (`alignedMs`) most recently opened
63852
+ * for that slot. `_tick` fires a periodic entry whenever the incoming tick's
63853
+ * aligned boundary is **strictly greater** than the stored value, instead of
63854
+ * requiring the tick to land *exactly* on the boundary. This fixes the
63855
+ * dropped-boundary bug: when virtual time jumps over a boundary (e.g. a
63856
+ * `5m`-driven loop skipping from 00:14 to 00:29 never lands on the `15m`
63857
+ * 00:15 boundary), the old `ts === alignedMs` check silently lost the tick.
63858
+ * With the watermark, the next tick whose `alignedMs` advanced past the
63859
+ * stored value fires once for the newest crossed boundary (catch-up
63860
+ * collapses multiple skipped boundaries into a single invocation at the
63861
+ * latest one).
63862
+ *
63863
+ * Written synchronously in `_tick` at slot-open time (before the `await`),
63864
+ * so a still-in-flight handler does not let a later tick re-open the same
63865
+ * (or an already-passed) boundary. Fire-once entries never touch this map —
63866
+ * they use `_firedOnce`. Pruned by `_clearBoundaryFor` on
63867
+ * `register`/`unregister` and wiped by `dispose`.
63868
+ */
63869
+ this._lastBoundary = new Map();
63818
63870
  /**
63819
63871
  * Register a periodic cron entry.
63820
63872
  *
@@ -63860,6 +63912,7 @@ class CronUtils {
63860
63912
  }
63861
63913
  }
63862
63914
  this._clearFiredOnceFor(entry.name);
63915
+ this._clearBoundaryFor(entry.name);
63863
63916
  const generation = ++this._generationCounter;
63864
63917
  this._entries.set(entry.name, { entry, generation });
63865
63918
  return () => this.unregister(entry.name);
@@ -63876,6 +63929,7 @@ class CronUtils {
63876
63929
  LOGGER_SERVICE$1.info(CRON_METHOD_NAME_UNREGISTER, { name });
63877
63930
  this._entries.delete(name);
63878
63931
  this._clearFiredOnceFor(name);
63932
+ this._clearBoundaryFor(name);
63879
63933
  };
63880
63934
  /**
63881
63935
  * Clear fire-once marks so that fire-once entries can fire again.
@@ -63952,11 +64006,24 @@ class CronUtils {
63952
64006
  * - Slot key: `${name}:once` (+ scope) (+ gen).
63953
64007
  * - `aligned` = the 1-minute-aligned `when` from step 0.
63954
64008
  * 5. **Periodic** (`entry.interval` set):
63955
- * - Align `when` further to the entry's interval via {@link alignToInterval}.
63956
- * - If `ts !== alignedMs`, the tick is mid-interval skip.
63957
- * (This is the "remainder === 0" boundary check from the spec;
63958
- * since `ts` is already on the 1-minute boundary, the check is exact
63959
- * for `1m` and consistent for higher intervals.)
64009
+ * - Align `when` to the entry's interval via {@link alignToInterval} to
64010
+ * get `alignedMs`, the boundary this tick belongs to.
64011
+ * - Compare against the slot's watermark in `_lastBoundary` (keyed by
64012
+ * `${name}` + scope + gen, without the `alignedMs` segment). If a
64013
+ * watermark exists and `alignedMs <= lastBoundary`, this boundary was
64014
+ * already fired — skip.
64015
+ * - This **watermark** check replaces the old exact `ts === alignedMs`
64016
+ * match. The exact match required virtual time to land *precisely* on
64017
+ * the boundary; when a tick jumped clean over a boundary (e.g. a `5m`
64018
+ * loop going 00:14 → 00:29 never touching the `15m` 00:15 boundary)
64019
+ * the boundary was silently lost. With the watermark, the first tick
64020
+ * whose `alignedMs` advanced past the stored value fires once, at the
64021
+ * newest crossed boundary (catch-up collapses several skipped
64022
+ * boundaries into a single invocation at the latest one).
64023
+ * - The watermark is advanced to `alignedMs` synchronously when the slot
64024
+ * is opened (before the `await`), so a concurrent tick on the same or
64025
+ * an already-passed boundary cannot open a duplicate slot while the
64026
+ * handler is still in flight.
63960
64027
  * - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
63961
64028
  * 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
63962
64029
  * already exists, `await` the same promise. Otherwise invoke
@@ -64005,6 +64072,9 @@ class CronUtils {
64005
64072
  let alignedMs;
64006
64073
  let slotKey;
64007
64074
  let firedKey;
64075
+ // Periodic-only watermark key (no `alignedMs` segment); null for
64076
+ // fire-once entries, which coordinate via `_firedOnce` instead.
64077
+ let boundaryKey;
64008
64078
  if (entry.interval === undefined) {
64009
64079
  const onceKey = `${entry.name}${scope}${genSuffix}`;
64010
64080
  if (this._firedOnce.has(onceKey)) {
@@ -64014,11 +64084,18 @@ class CronUtils {
64014
64084
  alignedMs = ts;
64015
64085
  slotKey = `${entry.name}:once${scope}${genSuffix}`;
64016
64086
  firedKey = onceKey;
64087
+ boundaryKey = null;
64017
64088
  }
64018
64089
  else {
64019
64090
  aligned = alignToInterval(when, entry.interval);
64020
64091
  alignedMs = aligned.getTime();
64021
- if (ts !== alignedMs) {
64092
+ boundaryKey = `${entry.name}${scope}${genSuffix}`;
64093
+ const lastBoundary = this._lastBoundary.get(boundaryKey);
64094
+ // Fire when the tick's aligned boundary has advanced past the last one
64095
+ // we fired for this slot. Using `>` instead of the old `ts === alignedMs`
64096
+ // means a virtual-time jump that skips clean over a boundary still
64097
+ // fires once, at the newest crossed boundary, rather than dropping it.
64098
+ if (lastBoundary !== undefined && alignedMs <= lastBoundary) {
64022
64099
  continue;
64023
64100
  }
64024
64101
  slotKey = `${entry.name}:${alignedMs}${scope}${genSuffix}`;
@@ -64026,6 +64103,13 @@ class CronUtils {
64026
64103
  }
64027
64104
  let pending = this._inFlight.get(slotKey);
64028
64105
  if (!pending) {
64106
+ // Advance the watermark synchronously at slot-open time, before the
64107
+ // await below. Otherwise a later tick on the same (or an already
64108
+ // crossed) boundary, arriving while this handler is still in flight,
64109
+ // would see the stale watermark and open a duplicate slot.
64110
+ if (boundaryKey !== null) {
64111
+ this._lastBoundary.set(boundaryKey, alignedMs);
64112
+ }
64029
64113
  pending = this._runEntry(entry, symbol, aligned, alignedMs, slotKey, firedKey, backtest);
64030
64114
  this._inFlight.set(slotKey, pending);
64031
64115
  }
@@ -64117,7 +64201,10 @@ class CronUtils {
64117
64201
  * 3. Wipes `_firedOnce` — all fire-once marks are dropped, so any future
64118
64202
  * re-registration of the same `name` fires again on the next matching
64119
64203
  * tick.
64120
- * 4. Does **not** touch `_inFlight` — in-flight handlers continue to
64204
+ * 4. Wipes `_lastBoundary` — all periodic watermarks are dropped, so a
64205
+ * re-registered periodic entry starts firing from its next crossed
64206
+ * boundary again.
64207
+ * 5. Does **not** touch `_inFlight` — in-flight handlers continue to
64121
64208
  * settle in the background and clear their own slots via `.finally()`.
64122
64209
  * Their final `_firedOnce.add(firedKey)` writes carry old-generation
64123
64210
  * keys and are harmless (lookup uses the post-dispose generation).
@@ -64136,8 +64223,29 @@ class CronUtils {
64136
64223
  this.disable();
64137
64224
  this._entries.clear();
64138
64225
  this._firedOnce.clear();
64226
+ this._lastBoundary.clear();
64139
64227
  };
64140
64228
  }
64229
+ /**
64230
+ * Garbage-collect every `_lastBoundary` key that belongs to the entry `name`
64231
+ * (any generation, global or fan-out).
64232
+ *
64233
+ * Called from `register`/`unregister` alongside `_clearFiredOnceFor`. Like
64234
+ * that helper this is memory hygiene, not correctness — the generation suffix
64235
+ * already isolates re-registrations, so a stale watermark from an old
64236
+ * generation can never gate a new entry.
64237
+ */
64238
+ _clearBoundaryFor(name) {
64239
+ if (!name) {
64240
+ return;
64241
+ }
64242
+ const prefix = `${name}:`;
64243
+ for (const key of this._lastBoundary.keys()) {
64244
+ if (key === name || key.startsWith(prefix)) {
64245
+ this._lastBoundary.delete(key);
64246
+ }
64247
+ }
64248
+ }
64141
64249
  /**
64142
64250
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`
64143
64251
  * (any generation, global or fan-out).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "10.2.0",
3
+ "version": "11.2.0",
4
4
  "description": "A TypeScript library for trading system backtest",
5
5
  "author": {
6
6
  "name": "Petr Tripolsky",
package/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as di_scoped from 'di-scoped';
2
2
  import * as functools_kit from 'functools-kit';
3
- import { Subject } from 'functools-kit';
3
+ import { Subject, BehaviorSubject } from 'functools-kit';
4
4
  import { WriteStream } from 'fs';
5
5
 
6
6
  /**
@@ -26322,6 +26322,44 @@ declare class CronUtils {
26322
26322
  * on successful settle.
26323
26323
  */
26324
26324
  private readonly _firedOnce;
26325
+ /**
26326
+ * Last interval boundary already fired per periodic slot.
26327
+ *
26328
+ * Key shape (no `alignedMs` segment — one entry per logical slot, not per
26329
+ * boundary; always carries the generation suffix `:g${generation}`, and the
26330
+ * `:${symbol}` scope only in fan-out mode):
26331
+ * - Periodic global: `${name}${genSuffix}`.
26332
+ * - Periodic fan-out: `${name}:${symbol}${genSuffix}`.
26333
+ *
26334
+ * Value is the aligned-boundary epoch ms (`alignedMs`) most recently opened
26335
+ * for that slot. `_tick` fires a periodic entry whenever the incoming tick's
26336
+ * aligned boundary is **strictly greater** than the stored value, instead of
26337
+ * requiring the tick to land *exactly* on the boundary. This fixes the
26338
+ * dropped-boundary bug: when virtual time jumps over a boundary (e.g. a
26339
+ * `5m`-driven loop skipping from 00:14 to 00:29 never lands on the `15m`
26340
+ * 00:15 boundary), the old `ts === alignedMs` check silently lost the tick.
26341
+ * With the watermark, the next tick whose `alignedMs` advanced past the
26342
+ * stored value fires once for the newest crossed boundary (catch-up
26343
+ * collapses multiple skipped boundaries into a single invocation at the
26344
+ * latest one).
26345
+ *
26346
+ * Written synchronously in `_tick` at slot-open time (before the `await`),
26347
+ * so a still-in-flight handler does not let a later tick re-open the same
26348
+ * (or an already-passed) boundary. Fire-once entries never touch this map —
26349
+ * they use `_firedOnce`. Pruned by `_clearBoundaryFor` on
26350
+ * `register`/`unregister` and wiped by `dispose`.
26351
+ */
26352
+ private readonly _lastBoundary;
26353
+ /**
26354
+ * Garbage-collect every `_lastBoundary` key that belongs to the entry `name`
26355
+ * (any generation, global or fan-out).
26356
+ *
26357
+ * Called from `register`/`unregister` alongside `_clearFiredOnceFor`. Like
26358
+ * that helper this is memory hygiene, not correctness — the generation suffix
26359
+ * already isolates re-registrations, so a stale watermark from an old
26360
+ * generation can never gate a new entry.
26361
+ */
26362
+ private _clearBoundaryFor;
26325
26363
  /**
26326
26364
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`
26327
26365
  * (any generation, global or fan-out).
@@ -26443,11 +26481,24 @@ declare class CronUtils {
26443
26481
  * - Slot key: `${name}:once` (+ scope) (+ gen).
26444
26482
  * - `aligned` = the 1-minute-aligned `when` from step 0.
26445
26483
  * 5. **Periodic** (`entry.interval` set):
26446
- * - Align `when` further to the entry's interval via {@link alignToInterval}.
26447
- * - If `ts !== alignedMs`, the tick is mid-interval skip.
26448
- * (This is the "remainder === 0" boundary check from the spec;
26449
- * since `ts` is already on the 1-minute boundary, the check is exact
26450
- * for `1m` and consistent for higher intervals.)
26484
+ * - Align `when` to the entry's interval via {@link alignToInterval} to
26485
+ * get `alignedMs`, the boundary this tick belongs to.
26486
+ * - Compare against the slot's watermark in `_lastBoundary` (keyed by
26487
+ * `${name}` + scope + gen, without the `alignedMs` segment). If a
26488
+ * watermark exists and `alignedMs <= lastBoundary`, this boundary was
26489
+ * already fired — skip.
26490
+ * - This **watermark** check replaces the old exact `ts === alignedMs`
26491
+ * match. The exact match required virtual time to land *precisely* on
26492
+ * the boundary; when a tick jumped clean over a boundary (e.g. a `5m`
26493
+ * loop going 00:14 → 00:29 never touching the `15m` 00:15 boundary)
26494
+ * the boundary was silently lost. With the watermark, the first tick
26495
+ * whose `alignedMs` advanced past the stored value fires once, at the
26496
+ * newest crossed boundary (catch-up collapses several skipped
26497
+ * boundaries into a single invocation at the latest one).
26498
+ * - The watermark is advanced to `alignedMs` synchronously when the slot
26499
+ * is opened (before the `await`), so a concurrent tick on the same or
26500
+ * an already-passed boundary cannot open a duplicate slot while the
26501
+ * handler is still in flight.
26451
26502
  * - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
26452
26503
  * 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
26453
26504
  * already exists, `await` the same promise. Otherwise invoke
@@ -26541,7 +26592,10 @@ declare class CronUtils {
26541
26592
  * 3. Wipes `_firedOnce` — all fire-once marks are dropped, so any future
26542
26593
  * re-registration of the same `name` fires again on the next matching
26543
26594
  * tick.
26544
- * 4. Does **not** touch `_inFlight` — in-flight handlers continue to
26595
+ * 4. Wipes `_lastBoundary` — all periodic watermarks are dropped, so a
26596
+ * re-registered periodic entry starts firing from its next crossed
26597
+ * boundary again.
26598
+ * 5. Does **not** touch `_inFlight` — in-flight handlers continue to
26545
26599
  * settle in the background and clear their own slots via `.finally()`.
26546
26600
  * Their final `_firedOnce.add(firedKey)` writes carry old-generation
26547
26601
  * keys and are harmless (lookup uses the post-dispose generation).
@@ -29091,6 +29145,13 @@ declare const beforeStartSubject: Subject<BeforeStartContract>;
29091
29145
  * Emits when the engine has completed processing a signal.
29092
29146
  */
29093
29147
  declare const afterEndSubject: Subject<AfterEndContract>;
29148
+ /**
29149
+ * Emitter for `@backtest-kit/cli`, which notifies the application
29150
+ * that all modules have been initialized.
29151
+ *
29152
+ * Send entry absolute path to the consumer
29153
+ */
29154
+ declare const entrySubject: BehaviorSubject<string>;
29094
29155
 
29095
29156
  declare const emitters_activePingSubject: typeof activePingSubject;
29096
29157
  declare const emitters_afterEndSubject: typeof afterEndSubject;
@@ -29100,6 +29161,7 @@ declare const emitters_breakevenSubject: typeof breakevenSubject;
29100
29161
  declare const emitters_doneBacktestSubject: typeof doneBacktestSubject;
29101
29162
  declare const emitters_doneLiveSubject: typeof doneLiveSubject;
29102
29163
  declare const emitters_doneWalkerSubject: typeof doneWalkerSubject;
29164
+ declare const emitters_entrySubject: typeof entrySubject;
29103
29165
  declare const emitters_errorEmitter: typeof errorEmitter;
29104
29166
  declare const emitters_exitEmitter: typeof exitEmitter;
29105
29167
  declare const emitters_highestProfitSubject: typeof highestProfitSubject;
@@ -29124,7 +29186,7 @@ declare const emitters_walkerCompleteSubject: typeof walkerCompleteSubject;
29124
29186
  declare const emitters_walkerEmitter: typeof walkerEmitter;
29125
29187
  declare const emitters_walkerStopSubject: typeof walkerStopSubject;
29126
29188
  declare namespace emitters {
29127
- export { emitters_activePingSubject as activePingSubject, emitters_afterEndSubject as afterEndSubject, emitters_backtestScheduleOpenSubject as backtestScheduleOpenSubject, emitters_beforeStartSubject as beforeStartSubject, emitters_breakevenSubject as breakevenSubject, emitters_doneBacktestSubject as doneBacktestSubject, emitters_doneLiveSubject as doneLiveSubject, emitters_doneWalkerSubject as doneWalkerSubject, emitters_errorEmitter as errorEmitter, emitters_exitEmitter as exitEmitter, emitters_highestProfitSubject as highestProfitSubject, emitters_idlePingSubject as idlePingSubject, emitters_maxDrawdownSubject as maxDrawdownSubject, emitters_partialLossSubject as partialLossSubject, emitters_partialProfitSubject as partialProfitSubject, emitters_performanceEmitter as performanceEmitter, emitters_progressBacktestEmitter as progressBacktestEmitter, emitters_progressWalkerEmitter as progressWalkerEmitter, emitters_riskSubject as riskSubject, emitters_schedulePingSubject as schedulePingSubject, emitters_shutdownEmitter as shutdownEmitter, emitters_signalBacktestEmitter as signalBacktestEmitter, emitters_signalEmitter as signalEmitter, emitters_signalLiveEmitter as signalLiveEmitter, emitters_signalNotifySubject as signalNotifySubject, emitters_strategyCommitSubject as strategyCommitSubject, emitters_syncSubject as syncSubject, emitters_validationSubject as validationSubject, emitters_walkerCompleteSubject as walkerCompleteSubject, emitters_walkerEmitter as walkerEmitter, emitters_walkerStopSubject as walkerStopSubject };
29189
+ export { emitters_activePingSubject as activePingSubject, emitters_afterEndSubject as afterEndSubject, emitters_backtestScheduleOpenSubject as backtestScheduleOpenSubject, emitters_beforeStartSubject as beforeStartSubject, emitters_breakevenSubject as breakevenSubject, emitters_doneBacktestSubject as doneBacktestSubject, emitters_doneLiveSubject as doneLiveSubject, emitters_doneWalkerSubject as doneWalkerSubject, emitters_entrySubject as entrySubject, emitters_errorEmitter as errorEmitter, emitters_exitEmitter as exitEmitter, emitters_highestProfitSubject as highestProfitSubject, emitters_idlePingSubject as idlePingSubject, emitters_maxDrawdownSubject as maxDrawdownSubject, emitters_partialLossSubject as partialLossSubject, emitters_partialProfitSubject as partialProfitSubject, emitters_performanceEmitter as performanceEmitter, emitters_progressBacktestEmitter as progressBacktestEmitter, emitters_progressWalkerEmitter as progressWalkerEmitter, emitters_riskSubject as riskSubject, emitters_schedulePingSubject as schedulePingSubject, emitters_shutdownEmitter as shutdownEmitter, emitters_signalBacktestEmitter as signalBacktestEmitter, emitters_signalEmitter as signalEmitter, emitters_signalLiveEmitter as signalLiveEmitter, emitters_signalNotifySubject as signalNotifySubject, emitters_strategyCommitSubject as strategyCommitSubject, emitters_syncSubject as syncSubject, emitters_validationSubject as validationSubject, emitters_walkerCompleteSubject as walkerCompleteSubject, emitters_walkerEmitter as walkerEmitter, emitters_walkerStopSubject as walkerStopSubject };
29128
29190
  }
29129
29191
 
29130
29192
  /**