backtest-kit 11.7.0 → 11.8.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
@@ -94,6 +94,7 @@ const metaServices$1 = {
94
94
  contextMetaService: Symbol('contextMetaService'),
95
95
  priceMetaService: Symbol('priceMetaService'),
96
96
  timeMetaService: Symbol('timeMetaService'),
97
+ runtimeMetaService: Symbol('runtimeMetaService'),
97
98
  };
98
99
  const globalServices$1 = {
99
100
  sizingGlobalService: Symbol('sizingGlobalService'),
@@ -12376,6 +12377,7 @@ const CREATE_COMMIT_SCHEDULE_PING_FN = (self) => functoolsKit.trycatch(async (sy
12376
12377
  symbol,
12377
12378
  strategyName,
12378
12379
  exchangeName,
12380
+ frameName: data.frameName,
12379
12381
  currentPrice,
12380
12382
  data,
12381
12383
  backtest,
@@ -12443,6 +12445,7 @@ const CREATE_COMMIT_ACTIVE_PING_FN = (self) => functoolsKit.trycatch(async (symb
12443
12445
  symbol,
12444
12446
  strategyName,
12445
12447
  exchangeName,
12448
+ frameName: data.frameName,
12446
12449
  currentPrice,
12447
12450
  data,
12448
12451
  backtest,
@@ -14015,11 +14018,51 @@ class StrategyConnectionService {
14015
14018
  }
14016
14019
  }
14017
14020
 
14021
+ const MS_PER_MINUTE$6 = 60000;
14022
+ const INTERVAL_MINUTES$7 = {
14023
+ "1m": 1,
14024
+ "3m": 3,
14025
+ "5m": 5,
14026
+ "15m": 15,
14027
+ "30m": 30,
14028
+ "1h": 60,
14029
+ "2h": 120,
14030
+ "4h": 240,
14031
+ "6h": 360,
14032
+ "8h": 480,
14033
+ "1d": 1440,
14034
+ };
14035
+ /**
14036
+ * Aligns timestamp down to the nearest interval boundary.
14037
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
14038
+ *
14039
+ * Candle timestamp convention:
14040
+ * - Candle timestamp = openTime (when candle opens)
14041
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
14042
+ *
14043
+ * Adapter contract:
14044
+ * - Adapter must return candles with timestamp = openTime
14045
+ * - First returned candle.timestamp must equal aligned since
14046
+ * - Adapter must return exactly `limit` candles
14047
+ *
14048
+ * @param date - Date to align
14049
+ * @param interval - Candle interval (e.g., "1m", "15m", "1h")
14050
+ * @returns New Date aligned down to interval boundary
14051
+ */
14052
+ const alignToInterval = (date, interval) => {
14053
+ const minutes = INTERVAL_MINUTES$7[interval];
14054
+ if (minutes === undefined) {
14055
+ throw new Error(`alignToInterval: unknown interval=${interval}`);
14056
+ }
14057
+ const intervalMs = minutes * MS_PER_MINUTE$6;
14058
+ return new Date(Math.floor(date.getTime() / intervalMs) * intervalMs);
14059
+ };
14060
+
14018
14061
  /**
14019
14062
  * Maps FrameInterval to minutes for timestamp calculation.
14020
14063
  * Used to generate timeframe arrays with proper spacing.
14021
14064
  */
14022
- const INTERVAL_MINUTES$7 = {
14065
+ const INTERVAL_MINUTES$6 = {
14023
14066
  "1m": 1,
14024
14067
  "3m": 3,
14025
14068
  "5m": 5,
@@ -14073,7 +14116,7 @@ const GET_TIMEFRAME_FN = async (symbol, self) => {
14073
14116
  symbol,
14074
14117
  });
14075
14118
  const { interval, startDate, endDate } = self.params;
14076
- const intervalMinutes = INTERVAL_MINUTES$7[interval];
14119
+ const intervalMinutes = INTERVAL_MINUTES$6[interval];
14077
14120
  if (!intervalMinutes) {
14078
14121
  throw new Error(`ClientFrame unknown interval: ${interval}`);
14079
14122
  }
@@ -14082,8 +14125,14 @@ const GET_TIMEFRAME_FN = async (symbol, self) => {
14082
14125
  today.setUTCHours(0, 0, 0, 0);
14083
14126
  // Ensure endDate doesn't go beyond today
14084
14127
  const effectiveEndDate = endDate > today ? today : endDate;
14128
+ // Align the iteration start down to the 1-minute boundary so every generated
14129
+ // timestamp lands on a clean minute, matching live mode
14130
+ // (LiveLogicPrivateService aligns `when` via alignToInterval(new Date(), "1m")).
14131
+ // Without this, a startDate carrying sub-minute (or any non-aligned) offset
14132
+ // would propagate that offset to every tick `when` — and therefore to
14133
+ // IRuntimeInfo.when handed to Cron handlers — diverging from live behaviour.
14085
14134
  const timeframes = [];
14086
- let currentDate = new Date(startDate);
14135
+ let currentDate = alignToInterval(startDate, "1m");
14087
14136
  while (currentDate <= effectiveEndDate) {
14088
14137
  timeframes.push(new Date(currentDate));
14089
14138
  currentDate = new Date(currentDate.getTime() + intervalMinutes * 60 * 1000);
@@ -19934,8 +19983,8 @@ class BacktestLogicPrivateService {
19934
19983
  }
19935
19984
 
19936
19985
  const EMITTER_CHECK_INTERVAL = 5000;
19937
- const MS_PER_MINUTE$6 = 60000;
19938
- const INTERVAL_MINUTES$6 = {
19986
+ const MS_PER_MINUTE$5 = 60000;
19987
+ const INTERVAL_MINUTES$5 = {
19939
19988
  "1m": 1,
19940
19989
  "3m": 3,
19941
19990
  "5m": 5,
@@ -19950,7 +19999,7 @@ const INTERVAL_MINUTES$6 = {
19950
19999
  };
19951
20000
  const createEmitter = functoolsKit.memoize(([interval]) => `${interval}`, (interval) => {
19952
20001
  const tickSubject = new functoolsKit.Subject();
19953
- const intervalMs = INTERVAL_MINUTES$6[interval] * MS_PER_MINUTE$6;
20002
+ const intervalMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$5;
19954
20003
  {
19955
20004
  let lastAligned = Math.floor(Date.now() / intervalMs) * intervalMs;
19956
20005
  functoolsKit.Source.fromInterval(EMITTER_CHECK_INTERVAL)
@@ -19977,46 +20026,6 @@ const waitForCandle = async (interval) => {
19977
20026
  return emitter.toPromise();
19978
20027
  };
19979
20028
 
19980
- const MS_PER_MINUTE$5 = 60000;
19981
- const INTERVAL_MINUTES$5 = {
19982
- "1m": 1,
19983
- "3m": 3,
19984
- "5m": 5,
19985
- "15m": 15,
19986
- "30m": 30,
19987
- "1h": 60,
19988
- "2h": 120,
19989
- "4h": 240,
19990
- "6h": 360,
19991
- "8h": 480,
19992
- "1d": 1440,
19993
- };
19994
- /**
19995
- * Aligns timestamp down to the nearest interval boundary.
19996
- * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
19997
- *
19998
- * Candle timestamp convention:
19999
- * - Candle timestamp = openTime (when candle opens)
20000
- * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
20001
- *
20002
- * Adapter contract:
20003
- * - Adapter must return candles with timestamp = openTime
20004
- * - First returned candle.timestamp must equal aligned since
20005
- * - Adapter must return exactly `limit` candles
20006
- *
20007
- * @param date - Date to align
20008
- * @param interval - Candle interval (e.g., "1m", "15m", "1h")
20009
- * @returns New Date aligned down to interval boundary
20010
- */
20011
- const alignToInterval = (date, interval) => {
20012
- const minutes = INTERVAL_MINUTES$5[interval];
20013
- if (minutes === undefined) {
20014
- throw new Error(`alignToInterval: unknown interval=${interval}`);
20015
- }
20016
- const intervalMs = minutes * MS_PER_MINUTE$5;
20017
- return new Date(Math.floor(date.getTime() / intervalMs) * intervalMs);
20018
- };
20019
-
20020
20029
  /**
20021
20030
  * Private service for live trading orchestration using async generators.
20022
20031
  *
@@ -36155,6 +36164,21 @@ class PriceMetaService {
36155
36164
  * Instances are cached until clear() is called.
36156
36165
  */
36157
36166
  this.getSource = functoolsKit.memoize(([symbol, strategyName, exchangeName, frameName, backtest]) => CREATE_KEY_FN$b(symbol, strategyName, exchangeName, frameName, backtest), () => new functoolsKit.BehaviorSubject());
36167
+ /**
36168
+ * Checks if a price exists for the given key and has emitted at least one value.
36169
+ *
36170
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
36171
+ * @param context - Strategy, exchange, and frame identifiers
36172
+ * @param backtest - True if backtest mode, false if live mode
36173
+ * @returns True if a price exists and has emitted a value, false otherwise
36174
+ */
36175
+ this.hasPrice = (symbol, context, backtest) => {
36176
+ const key = CREATE_KEY_FN$b(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
36177
+ if (!this.getSource.has(key)) {
36178
+ return false;
36179
+ }
36180
+ return !!this.getSource.get(key)?.data;
36181
+ };
36158
36182
  /**
36159
36183
  * Returns the current market price for the given symbol and context.
36160
36184
  *
@@ -36817,6 +36841,129 @@ class NotificationHelperService {
36817
36841
  }
36818
36842
  }
36819
36843
 
36844
+ const GET_RANGE_FN = functoolsKit.trycatch((self, context, backtest) => {
36845
+ if (!backtest) {
36846
+ return null;
36847
+ }
36848
+ const { startDate, endDate } = self.frameSchemaService.get(context.frameName);
36849
+ return {
36850
+ from: startDate,
36851
+ to: endDate,
36852
+ };
36853
+ }, {
36854
+ fallback: (error, self) => {
36855
+ const message = "RuntimeMetaService GET_RANGE_FN thrown";
36856
+ const payload = {
36857
+ error: functoolsKit.errorData(error),
36858
+ message: functoolsKit.getErrorMessage(error),
36859
+ };
36860
+ self.loggerService.warn(message, payload);
36861
+ console.error(message, payload);
36862
+ errorEmitter.next(error);
36863
+ },
36864
+ defaultValue: null,
36865
+ });
36866
+ const GET_INFO_FN = functoolsKit.trycatch((self, context) => {
36867
+ const { info } = self.strategySchemaService.get(context.strategyName);
36868
+ return info || null;
36869
+ }, {
36870
+ fallback: (error, self) => {
36871
+ const message = "RuntimeMetaService GET_INFO_FN thrown";
36872
+ const payload = {
36873
+ error: functoolsKit.errorData(error),
36874
+ message: functoolsKit.getErrorMessage(error),
36875
+ };
36876
+ self.loggerService.warn(message, payload);
36877
+ console.error(message, payload);
36878
+ errorEmitter.next(error);
36879
+ },
36880
+ defaultValue: null,
36881
+ });
36882
+ const GET_PRICE_FN = functoolsKit.trycatch(async (self, symbol, context, backtest) => {
36883
+ return await self.priceMetaService.getCurrentPrice(symbol, context, backtest);
36884
+ }, {
36885
+ fallback: (error, self) => {
36886
+ const message = "RuntimeMetaService GET_PRICE_FN thrown";
36887
+ const payload = {
36888
+ error: functoolsKit.errorData(error),
36889
+ message: functoolsKit.getErrorMessage(error),
36890
+ };
36891
+ self.loggerService.warn(message, payload);
36892
+ console.error(message, payload);
36893
+ errorEmitter.next(error);
36894
+ },
36895
+ defaultValue: null,
36896
+ });
36897
+ const RuntimeMetaService = diSingleton.singleton(class {
36898
+ constructor() {
36899
+ this.loggerService = inject(TYPES.loggerService);
36900
+ this.timeMetaService = inject(TYPES.timeMetaService);
36901
+ this.priceMetaService = inject(TYPES.priceMetaService);
36902
+ this.frameSchemaService = inject(TYPES.frameSchemaService);
36903
+ this.strategySchemaService = inject(TYPES.strategySchemaService);
36904
+ /**
36905
+ * Fetches the time range for the current strategy execution context.
36906
+ *
36907
+ * For backtest mode, it retrieves the start and end dates from the frame schema.
36908
+ * For live mode, it returns null since there is no predefined time range.
36909
+ *
36910
+ * This method is memoized to optimize performance, as the time range for a given context will not change during execution.
36911
+ *
36912
+ * @param context - Strategy, exchange, and frame identifiers
36913
+ * @param backtest - True if backtest mode, false if live mode
36914
+ * @returns An object containing 'from' and 'to' Date objects for backtest mode, or null for live mode
36915
+ */
36916
+ this._getRange = functoolsKit.memoize(([context, backtest]) => `${context.frameName}:${backtest ? "backtest" : "live"}`, (context, backtest) => {
36917
+ return GET_RANGE_FN(this, context, backtest);
36918
+ });
36919
+ /**
36920
+ * Fetches strategy-defined runtime information for the current execution context.
36921
+ *
36922
+ * This method retrieves the 'info' object defined in the strategy schema, which can contain any custom data the strategy wants to track at runtime.
36923
+ * The content of this object is not defined by the system and can be used freely by strategy implementations for monitoring, reporting, or external logic.
36924
+ *
36925
+ * This method is memoized to optimize performance, as the strategy info for a given context will not change during execution.
36926
+ *
36927
+ * @param context - Strategy, exchange, and frame identifiers
36928
+ * @returns The 'info' object defined in the strategy schema for the given strategy, or null if not defined
36929
+ */
36930
+ this._getInfo = functoolsKit.memoize(([context]) => context.strategyName, (context) => {
36931
+ return GET_INFO_FN(this, context);
36932
+ });
36933
+ /**
36934
+ * Fetches comprehensive runtime information for a given symbol and strategy context, including current price, timestamp, and strategy-specific info.
36935
+ *
36936
+ * This method aggregates data from multiple sources (time, price, frame schema, strategy schema) to provide a complete picture of the current runtime state for a strategy tick.
36937
+ *
36938
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
36939
+ * @param context - Strategy, exchange, and frame identifiers
36940
+ * @param backtest - True if backtest mode, false if live mode
36941
+ * @returns An object containing symbol, time range, strategy-defined info, context, timestamp, current price, and backtest flag
36942
+ */
36943
+ this.getRuntimeInfo = async (symbol, context, backtest) => {
36944
+ this.loggerService.log("runtimeMetaService getRuntimeInfo", {
36945
+ symbol,
36946
+ context,
36947
+ backtest,
36948
+ });
36949
+ const timestamp = await this.timeMetaService.getTimestamp(symbol, context, backtest);
36950
+ const when = new Date(timestamp);
36951
+ const currentPrice = await GET_PRICE_FN(this, symbol, context, backtest);
36952
+ const range = this._getRange(context, backtest);
36953
+ const info = this._getInfo(context);
36954
+ return {
36955
+ symbol,
36956
+ range,
36957
+ info,
36958
+ context,
36959
+ backtest,
36960
+ when,
36961
+ currentPrice,
36962
+ };
36963
+ };
36964
+ }
36965
+ });
36966
+
36820
36967
  {
36821
36968
  provide(TYPES.loggerService, () => new LoggerService());
36822
36969
  }
@@ -36853,6 +37000,7 @@ class NotificationHelperService {
36853
37000
  provide(TYPES.contextMetaService, () => new ContextMetaService());
36854
37001
  provide(TYPES.priceMetaService, () => new PriceMetaService());
36855
37002
  provide(TYPES.timeMetaService, () => new TimeMetaService());
37003
+ provide(TYPES.runtimeMetaService, () => new RuntimeMetaService());
36856
37004
  }
36857
37005
  {
36858
37006
  provide(TYPES.sizingGlobalService, () => new SizingGlobalService());
@@ -36956,6 +37104,7 @@ const metaServices = {
36956
37104
  timeMetaService: inject(TYPES.timeMetaService),
36957
37105
  priceMetaService: inject(TYPES.priceMetaService),
36958
37106
  contextMetaService: inject(TYPES.contextMetaService),
37107
+ runtimeMetaService: inject(TYPES.runtimeMetaService),
36959
37108
  };
36960
37109
  const globalServices = {
36961
37110
  sizingGlobalService: inject(TYPES.sizingGlobalService),
@@ -64611,6 +64760,20 @@ const CRON_METHOD_NAME_TICK = "CronUtils._tick";
64611
64760
  const CRON_METHOD_NAME_ENABLE = "CronUtils.enable";
64612
64761
  const CRON_METHOD_NAME_DISABLE = "CronUtils.disable";
64613
64762
  const CRON_METHOD_NAME_DISPOSE = "CronUtils.dispose";
64763
+ /**
64764
+ * Watchdog timeout (ms) for a single cron handler invocation.
64765
+ *
64766
+ * A handler that does not settle within this window is treated as failed:
64767
+ * `_runEntry` races `entry.handler(info)` against this `sleep` and, when the
64768
+ * timeout wins, throws into the same `catch` as any other handler error —
64769
+ * surfacing `failed = true`, logging a warning, and (for periodic entries)
64770
+ * rolling back the watermark so the boundary is retried on the next tick.
64771
+ *
64772
+ * This guards the `singlerun`-serialised tick pipeline against a handler that
64773
+ * never resolves (a lost `resolve`, a hung promise with no timeout of its
64774
+ * own): without it such a handler would stall every subsequent tick forever.
64775
+ */
64776
+ const CRON_HANDLER_TIMEOUT = 120000;
64614
64777
  /**
64615
64778
  * Local logger instance.
64616
64779
  *
@@ -64619,6 +64782,20 @@ const CRON_METHOD_NAME_DISPOSE = "CronUtils.dispose";
64619
64782
  * being bootstrapped — `Cron` can be imported and used in isolation.
64620
64783
  */
64621
64784
  const LOGGER_SERVICE$1 = new LoggerService();
64785
+ /**
64786
+ * Local runtime-meta-service instance.
64787
+ *
64788
+ * Like {@link LOGGER_SERVICE}, instantiated directly via `new` rather than
64789
+ * resolved from the DI container so `CronUtils` carries no compile-time
64790
+ * dependency on a bootstrapped framework. `RuntimeMetaService` is built with
64791
+ * the `singleton` HOF from `di-singleton`, so `new RuntimeMetaService()`
64792
+ * returns the one shared singleton proxy — the same instance the rest of the
64793
+ * framework injects — and resolves its own dependencies lazily on first use.
64794
+ *
64795
+ * Used by {@link CronUtils._runEntry} to assemble the {@link IRuntimeInfo}
64796
+ * snapshot handed to each cron handler.
64797
+ */
64798
+ const RUNTIME_META_SERVICE = new RuntimeMetaService();
64622
64799
  /**
64623
64800
  * Utility class for registering periodic tasks that fire on candle-interval
64624
64801
  * boundaries of the virtual time produced by parallel backtests.
@@ -64641,8 +64818,8 @@ const LOGGER_SERVICE$1 = new LoggerService();
64641
64818
  * Cron.register({
64642
64819
  * name: "tg-signal-parser",
64643
64820
  * interval: "1h",
64644
- * handler: async (symbol, when, backtest) => {
64645
- * await parseTelegramSignalsToMongo(when);
64821
+ * handler: async (info) => {
64822
+ * await parseTelegramSignalsToMongo(info.when);
64646
64823
  * },
64647
64824
  * });
64648
64825
  *
@@ -64684,12 +64861,15 @@ class CronUtils {
64684
64861
  * - Fire-once global: `${name}:once:g${generation}`.
64685
64862
  * - Fire-once fan-out: `${name}:once:${symbol}:g${generation}`.
64686
64863
  *
64687
- * Value is the shared in-flight handler promise. Every parallel `tick` for
64688
- * the same slot key awaits this exact promise (mutex semantics) and is
64689
- * released together when it settles. `_inFlight` is owned exclusively by
64690
- * `_runEntry` `clear()` does **not** touch it, so the singleshot promise
64691
- * survives concurrent `clear` calls and continues to coordinate parallel
64692
- * ticks until it settles.
64864
+ * Value is the shared in-flight handler promise. It resolves to a `boolean`
64865
+ * "failed" flag (`true` when the handler or the runtime-info assembly
64866
+ * threw), which `_tick` uses to roll back the periodic watermark of the slot
64867
+ * it opened so a failed boundary is retried. Every parallel `tick` for the
64868
+ * same slot key awaits this exact promise (mutex semantics) and is released
64869
+ * together when it settles. `_inFlight` is owned exclusively by `_runEntry` —
64870
+ * `clear()` does **not** touch it, so the singleshot promise survives
64871
+ * concurrent `clear` calls and continues to coordinate parallel ticks until
64872
+ * it settles.
64693
64873
  */
64694
64874
  this._inFlight = new Map();
64695
64875
  /**
@@ -64733,9 +64913,12 @@ class CronUtils {
64733
64913
  *
64734
64914
  * Written synchronously in `_tick` at slot-open time (before the `await`),
64735
64915
  * so a still-in-flight handler does not let a later tick re-open the same
64736
- * (or an already-passed) boundary. Fire-once entries never touch this map —
64737
- * they use `_firedOnce`. Pruned by `_clearBoundaryFor` on
64738
- * `register`/`unregister` and wiped by `dispose`.
64916
+ * (or an already-passed) boundary. If that handler then **fails**, the
64917
+ * advance is rolled back after the slot settles — the prior value is restored
64918
+ * (or the key deleted if there was none) — so the failed boundary is retried
64919
+ * on the next tick, mirroring catch-up of a skipped boundary. Fire-once
64920
+ * entries never touch this map — they use `_firedOnce`. Pruned by
64921
+ * `_clearBoundaryFor` on `register`/`unregister` and wiped by `dispose`.
64739
64922
  */
64740
64923
  this._lastBoundary = new Map();
64741
64924
  /**
@@ -64755,7 +64938,7 @@ class CronUtils {
64755
64938
  * name: "fetch-funding",
64756
64939
  * interval: "8h",
64757
64940
  * symbols: ["BTCUSDT", "ETHUSDT"],
64758
- * handler: async (symbol, when, backtest) => { ... },
64941
+ * handler: async (info) => { ... },
64759
64942
  * });
64760
64943
  * // Later:
64761
64944
  * dispose();
@@ -64875,7 +65058,7 @@ class CronUtils {
64875
65058
  * 4. **Fire-once** (`entry.interval === undefined`):
64876
65059
  * - If the entry's fired-once key is already in `_firedOnce`, skip.
64877
65060
  * - Slot key: `${name}:once` (+ scope) (+ gen).
64878
- * - `aligned` = the 1-minute-aligned `when` from step 0.
65061
+ * - `alignedMs` = the 1-minute-aligned `when` from step 0 (`ts`).
64879
65062
  * 5. **Periodic** (`entry.interval` set):
64880
65063
  * - Align `when` to the entry's interval via {@link alignToInterval} to
64881
65064
  * get `alignedMs`, the boundary this tick belongs to.
@@ -64897,32 +65080,44 @@ class CronUtils {
64897
65080
  * handler is still in flight.
64898
65081
  * - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
64899
65082
  * 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
64900
- * already exists, `await` the same promise. Otherwise invoke
64901
- * `entry.handler`, store the promise, and `await` it. The slot is
64902
- * removed in `.finally()` so the next boundary creates a fresh promise;
64903
- * for fire-once entries the fired-once key is also added to
64904
- * `_firedOnce` on success so subsequent ticks skip it.
65083
+ * already exists, `await` the same promise. Otherwise open the slot via
65084
+ * {@link _runEntry} which assembles the {@link IRuntimeInfo} snapshot
65085
+ * (from `symbol`, `context`, `backtest`) and invokes `entry.handler(info)`
65086
+ * store the promise, and `await` it. The slot is removed in `.finally()`
65087
+ * so the next boundary creates a fresh promise; for fire-once entries the
65088
+ * fired-once key is also added to `_firedOnce` on success so subsequent
65089
+ * ticks skip it.
65090
+ * 7. After `await Promise.all`, roll back the watermark for every **periodic**
65091
+ * slot this tick *opened* (not the ones whose in-flight promise it reused)
65092
+ * whose handler reported failure, so the next tick re-opens and re-runs
65093
+ * that boundary.
64905
65094
  *
64906
65095
  * Errors thrown by `handler` are caught, logged via `console.error`, and
64907
65096
  * **not** rethrown — a failing handler must not break the per-symbol
64908
65097
  * tick loop or unblock other parallel backtests with an unhandled
64909
65098
  * rejection. A failed fire-once handler is **not** marked as fired and
64910
- * will retry on the next tick.
65099
+ * will retry on the next tick. A failed **periodic** handler likewise
65100
+ * retries: the boundary watermark advanced at slot-open time is rolled back
65101
+ * after the slot settles (step 7), so the next tick re-opens that boundary.
64911
65102
  *
64912
65103
  * Requires active method context and execution context.
64913
65104
  *
64914
65105
  * @param symbol - Trading symbol from the current tick.
64915
65106
  * @param when - Virtual time of the current tick.
64916
65107
  * @param backtest - `true` for backtest ticks, `false` for live ticks.
64917
- * Forwarded as the third argument to `entry.handler`. Only the value
64918
- * from the tick that **opens** a given slot is observed by all parallel
64919
- * awaiters of that slot.
65108
+ * Forwarded to {@link _runEntry} and surfaced as `info.backtest`. Only the
65109
+ * value from the tick that **opens** a given slot is observed by all
65110
+ * parallel awaiters of that slot.
65111
+ * @param context - Strategy/exchange/frame identifiers from the originating
65112
+ * lifecycle event, forwarded to `RuntimeMetaService.getRuntimeInfo` to
65113
+ * build the {@link IRuntimeInfo} snapshot passed to the handler.
64920
65114
  * @throws Error if method or execution context is missing.
64921
65115
  */
64922
- this._tick = async (symbol, when, backtest) => {
65116
+ this._tick = async (symbol, when, backtest, context) => {
64923
65117
  LOGGER_SERVICE$1.debug(CRON_METHOD_NAME_TICK, {
64924
65118
  symbol,
64925
65119
  when,
65120
+ context,
64926
65121
  });
64927
65122
  if (!MethodContextService.hasContext()) {
64928
65123
  throw new Error("CronUtils _tick requires method context");
@@ -64932,6 +65127,10 @@ class CronUtils {
64932
65127
  }
64933
65128
  const ts = alignToInterval(when, "1m").getTime();
64934
65129
  const taskList = [];
65130
+ // Periodic slots THIS tick actually opened (the `!pending` branch), tracked
65131
+ // for watermark rollback on failure. See {@link IOpenedSlot} for what is and
65132
+ // is not recorded here and why.
65133
+ const openedList = [];
64935
65134
  for (const { entry, generation } of this._entries.values()) {
64936
65135
  if (entry.symbols?.length && !entry.symbols.includes(symbol)) {
64937
65136
  continue;
@@ -64939,7 +65138,6 @@ class CronUtils {
64939
65138
  const perSymbol = !!entry.symbols?.length;
64940
65139
  const scope = perSymbol ? `:${symbol}` : "";
64941
65140
  const genSuffix = `:g${generation}`;
64942
- let aligned;
64943
65141
  let alignedMs;
64944
65142
  let slotKey;
64945
65143
  let firedKey;
@@ -64951,15 +65149,13 @@ class CronUtils {
64951
65149
  if (this._firedOnce.has(onceKey)) {
64952
65150
  continue;
64953
65151
  }
64954
- aligned = alignToInterval(when, "1m");
64955
65152
  alignedMs = ts;
64956
65153
  slotKey = `${entry.name}:once${scope}${genSuffix}`;
64957
65154
  firedKey = onceKey;
64958
65155
  boundaryKey = null;
64959
65156
  }
64960
65157
  else {
64961
- aligned = alignToInterval(when, entry.interval);
64962
- alignedMs = aligned.getTime();
65158
+ alignedMs = alignToInterval(when, entry.interval).getTime();
64963
65159
  boundaryKey = `${entry.name}${scope}${genSuffix}`;
64964
65160
  const lastBoundary = this._lastBoundary.get(boundaryKey);
64965
65161
  // Fire when the tick's aligned boundary has advanced past the last one
@@ -64977,16 +65173,70 @@ class CronUtils {
64977
65173
  // Advance the watermark synchronously at slot-open time, before the
64978
65174
  // await below. Otherwise a later tick on the same (or an already
64979
65175
  // crossed) boundary, arriving while this handler is still in flight,
64980
- // would see the stale watermark and open a duplicate slot.
65176
+ // would see the stale watermark and open a duplicate slot. The advance
65177
+ // is rolled back after the slot settles if the handler failed (see the
65178
+ // post-await loop below), so a failed boundary is retried next tick.
64981
65179
  if (boundaryKey !== null) {
65180
+ // Capture the pre-advance value so it can be restored verbatim on
65181
+ // failure (undefined => the boundary had never opened => delete the
65182
+ // key on rollback). Read fresh here rather than reusing `lastBoundary`
65183
+ // above to keep the value↔slot binding local and obvious; there is no
65184
+ // `await` between the two reads, so they are identical.
65185
+ const prevBoundary = this._lastBoundary.get(boundaryKey);
64982
65186
  this._lastBoundary.set(boundaryKey, alignedMs);
65187
+ pending = this._runEntry(entry, symbol, alignedMs, slotKey, firedKey, backtest, context);
65188
+ this._inFlight.set(slotKey, pending);
65189
+ openedList.push({ boundaryKey, prevBoundary, pending });
65190
+ }
65191
+ else {
65192
+ pending = this._runEntry(entry, symbol, alignedMs, slotKey, firedKey, backtest, context);
65193
+ this._inFlight.set(slotKey, pending);
64983
65194
  }
64984
- pending = this._runEntry(entry, symbol, aligned, alignedMs, slotKey, firedKey, backtest);
64985
- this._inFlight.set(slotKey, pending);
64986
65195
  }
64987
65196
  taskList.push(pending);
64988
65197
  }
64989
- await Promise.all(taskList);
65198
+ {
65199
+ // Watchdog: warn (do not interrupt) if the slots this tick is awaiting
65200
+ // have not settled within CRON_HANDLER_TIMEOUT. We deliberately keep
65201
+ // awaiting Promise.all so the singlerun pipeline stays serialised and no
65202
+ // duplicate/zombie slots are spawned — the timer only surfaces the stall.
65203
+ // Use a real setTimeout/clearTimeout (not sleep) so the alarm is cancelled
65204
+ // the instant Promise.all resolves, rather than lingering for the full
65205
+ // timeout on every fast tick.
65206
+ const timer = setTimeout(() => {
65207
+ const message = `${CRON_METHOD_NAME_TICK} timed out after ${CRON_HANDLER_TIMEOUT}ms`;
65208
+ const payload = { symbol, when, context };
65209
+ LOGGER_SERVICE$1.warn(message, payload);
65210
+ console.error(message, payload);
65211
+ errorEmitter.next(new Error(message));
65212
+ }, CRON_HANDLER_TIMEOUT);
65213
+ try {
65214
+ await Promise.all(taskList);
65215
+ }
65216
+ finally {
65217
+ clearTimeout(timer);
65218
+ }
65219
+ }
65220
+ // Roll back the watermark for any periodic slot THIS tick opened whose
65221
+ // handler failed, so the next tick re-opens the same boundary and retries
65222
+ // it — mirroring how a skipped boundary is later caught up. Restoring
65223
+ // `prevBoundary` (or deleting the key when it was `undefined`) re-arms the
65224
+ // strict-`>` gate without disturbing any earlier already-fired boundary.
65225
+ // `await pending` is cheap — every promise already settled in `Promise.all`
65226
+ // above; we re-await via `openedList` because its entries (opened slots
65227
+ // only) do not line up with `taskList` indices.
65228
+ for (const { boundaryKey, prevBoundary, pending } of openedList) {
65229
+ const failed = await pending;
65230
+ if (!failed) {
65231
+ continue;
65232
+ }
65233
+ if (prevBoundary === undefined) {
65234
+ this._lastBoundary.delete(boundaryKey);
65235
+ }
65236
+ else {
65237
+ this._lastBoundary.set(boundaryKey, prevBoundary);
65238
+ }
65239
+ }
64990
65240
  };
64991
65241
  /**
64992
65242
  * Subscribe `Cron` to the engine's strategy lifecycle subjects so registered
@@ -65001,7 +65251,11 @@ class CronUtils {
65001
65251
  *
65002
65252
  * All four subjects are subscribed to a single `singlerun`-wrapped
65003
65253
  * handler that builds `_tick(event.symbol, new Date(event.timestamp),
65004
- * event.backtest)`. `singlerun` merges the four streams into one serial
65254
+ * event.backtest, { strategyName, exchangeName, frameName })`. The context
65255
+ * object is read uniformly from the event — every contract carries
65256
+ * `strategyName`, `exchangeName` and `frameName` at the top level (Active /
65257
+ * Schedule contracts gained `frameName` for exactly this reason), so no
65258
+ * per-event branching is needed. `singlerun` merges the four streams into one serial
65005
65259
  * queue: at most one `_tick` runs at a time, the next waits. This matters
65006
65260
  * because the engine can emit `beforeStart` and an immediate `idlePing`
65007
65261
  * on the very same minute, and concurrent `_tick`s on the same
@@ -65037,7 +65291,11 @@ class CronUtils {
65037
65291
  this.enable = functoolsKit.singleshot(() => {
65038
65292
  LOGGER_SERVICE$1.info(CRON_METHOD_NAME_ENABLE);
65039
65293
  const handleTick = functoolsKit.singlerun(async (event) => {
65040
- return await this._tick(event.symbol, new Date(event.timestamp), event.backtest);
65294
+ return await this._tick(event.symbol, new Date(event.timestamp), event.backtest, {
65295
+ strategyName: event.strategyName,
65296
+ exchangeName: event.exchangeName,
65297
+ frameName: event.frameName,
65298
+ });
65041
65299
  });
65042
65300
  const unBeforeStart = beforeStartSubject.subscribe(handleTick);
65043
65301
  const unIdlePing = idlePingSubject.subscribe(handleTick);
@@ -65140,25 +65398,51 @@ class CronUtils {
65140
65398
  /**
65141
65399
  * Build the singleshot promise for a single in-flight slot.
65142
65400
  *
65143
- * Invokes `entry.handler(symbol, aligned, backtest)`, swallows and logs
65144
- * any error via `console.error`, and clears the `_inFlight` slot
65145
- * in `.finally()` so the next boundary produces a fresh promise. For
65146
- * fire-once entries `firedKey` is added to `_firedOnce` on success so
65147
- * subsequent ticks skip it.
65148
- *
65401
+ * Assembles the {@link IRuntimeInfo} snapshot via
65402
+ * `RuntimeMetaService.getRuntimeInfo(symbol, context, backtest)` and invokes
65403
+ * `entry.handler(info)`. Logs any error via `console.error` and **returns** a
65404
+ * `failed` boolean (`true` when the handler or the runtime-info assembly —
65405
+ * threw) so the caller (`_tick`) can roll back the periodic watermark of the
65406
+ * slot it opened and retry that boundary. The error is **not** rethrown, so a
65407
+ * failing handler never produces an unhandled rejection. Clears the
65408
+ * `_inFlight` slot in `.finally()` so the next boundary produces a fresh
65409
+ * promise. For fire-once entries `firedKey` is added to `_firedOnce` on
65410
+ * success so subsequent ticks skip it.
65411
+ *
65412
+ * `getRuntimeInfo` is the user-facing aggregator: its sub-fetches (range,
65413
+ * info, price) are individually wrapped in `trycatch` with `null` fallbacks,
65414
+ * so it almost never throws for missing data. Whatever does throw — the
65415
+ * handler, or in rare cases `getRuntimeInfo` — is caught here and reported via
65416
+ * the returned `failed` flag; the watermark rollback treats both identically.
65417
+ *
65418
+ * @param context - Strategy/exchange/frame identifiers from the originating
65419
+ * lifecycle event, forwarded to `getRuntimeInfo` to resolve `range`/`info`.
65149
65420
  * @param firedKey - Key to add to `_firedOnce` on success, or `null` for
65150
65421
  * periodic entries (which never populate `_firedOnce`).
65151
- * @param backtest - Value forwarded as the third handler argument; the
65152
- * "winner" tick's flag is what all parallel awaiters of this slot see.
65422
+ * @param backtest - Forwarded to `getRuntimeInfo` and surfaced as
65423
+ * `info.backtest`; the "winner" tick's flag is what all parallel awaiters
65424
+ * of this slot see.
65425
+ * @returns `true` if the handler (or `getRuntimeInfo`) threw, `false` on
65426
+ * success. `_tick` uses this to decide whether to roll back the watermark.
65153
65427
  */
65154
- async _runEntry(entry, symbol, aligned, alignedMs, slotKey, firedKey, backtest) {
65428
+ async _runEntry(entry, symbol, alignedMs, slotKey, firedKey, backtest, context) {
65155
65429
  let failed = false;
65156
65430
  try {
65157
- await entry.handler(symbol, aligned, backtest);
65431
+ const info = await RUNTIME_META_SERVICE.getRuntimeInfo(symbol, context, backtest);
65432
+ await entry.handler(info);
65158
65433
  }
65159
- catch (err) {
65434
+ catch (error) {
65160
65435
  failed = true;
65161
- console.error(`${CRON_METHOD_NAME_TICK} entry "${entry.name}" failed`, { symbol, alignedMs, err });
65436
+ const message = `${CRON_METHOD_NAME_TICK} entry "${entry.name}" failed`;
65437
+ const payload = {
65438
+ symbol,
65439
+ alignedMs,
65440
+ error: functoolsKit.errorData(error),
65441
+ message: functoolsKit.getErrorMessage(error),
65442
+ };
65443
+ LOGGER_SERVICE$1.warn(message, payload);
65444
+ console.error(message, payload);
65445
+ errorEmitter.next(error);
65162
65446
  }
65163
65447
  finally {
65164
65448
  this._inFlight.delete(slotKey);
@@ -65166,6 +65450,7 @@ class CronUtils {
65166
65450
  this._firedOnce.add(firedKey);
65167
65451
  }
65168
65452
  }
65453
+ return failed;
65169
65454
  }
65170
65455
  }
65171
65456
  /**
@@ -65179,7 +65464,7 @@ class CronUtils {
65179
65464
  * Cron.register({
65180
65465
  * name: "tg-parser",
65181
65466
  * interval: "1h",
65182
- * handler: async (symbol, when, backtest) => { ... },
65467
+ * handler: async (info) => { ... },
65183
65468
  * });
65184
65469
  * ```
65185
65470
  */