backtest-kit 11.6.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/LICENSE +21 -21
- package/README.md +1996 -1996
- package/build/index.cjs +422 -113
- package/build/index.mjs +422 -113
- package/package.json +86 -86
- package/types.d.ts +205 -48
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$
|
|
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$
|
|
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 =
|
|
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$
|
|
19938
|
-
const INTERVAL_MINUTES$
|
|
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$
|
|
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
|
*
|
|
@@ -23978,13 +23987,19 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23978
23987
|
// mark-to-market low); equity then moves to the realized close.
|
|
23979
23988
|
// If equity (at trough or close) goes ≤ 0 (e.g. leveraged loss < -100%) — account
|
|
23980
23989
|
// blown, fix DD at 100% and stop walking the curve.
|
|
23990
|
+
// Walk the equity curve in chronological close order. Storage is
|
|
23991
|
+
// newest-first (unshift on addSignal); reverse-storage iteration normally
|
|
23992
|
+
// gives chronological order, but explicitly sorting by closeTimestamp
|
|
23993
|
+
// removes the dependency on insertion-order matching close-order (which
|
|
23994
|
+
// can break under crash recovery, signal backfill, or disk replays).
|
|
23995
|
+
const orderedSignals = [...validSignals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
|
|
23981
23996
|
let equity = 1;
|
|
23982
23997
|
let peak = 1;
|
|
23983
23998
|
let equityMaxDrawdown = 0;
|
|
23984
23999
|
let blown = false;
|
|
23985
|
-
for (
|
|
24000
|
+
for (const s of orderedSignals) {
|
|
23986
24001
|
// Intra-trade trough — mark-to-market low while the position was open.
|
|
23987
|
-
const fallPct =
|
|
24002
|
+
const fallPct = s.signal.maxDrawdown?.pnlPercentage;
|
|
23988
24003
|
if (typeof fallPct === "number" && fallPct < 0) {
|
|
23989
24004
|
const trough = equity * (1 + fallPct / 100);
|
|
23990
24005
|
if (trough <= 0) {
|
|
@@ -23997,7 +24012,7 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23997
24012
|
equityMaxDrawdown = troughDd;
|
|
23998
24013
|
}
|
|
23999
24014
|
// Realized close — book the final per-trade result.
|
|
24000
|
-
equity *= 1 +
|
|
24015
|
+
equity *= 1 + s.pnl.pnlPercentage / 100;
|
|
24001
24016
|
if (equity <= 0) {
|
|
24002
24017
|
equityMaxDrawdown = 100;
|
|
24003
24018
|
blown = true;
|
|
@@ -25142,14 +25157,20 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
25142
25157
|
// snapshot, ≤ 0) is applied as a trough BEFORE booking the realized close. Without it
|
|
25143
25158
|
// the curve only steps at close, so a trade that dipped to -18% and recovered to +2%
|
|
25144
25159
|
// would register zero drawdown — understating DD and inflating Calmar/Recovery.
|
|
25145
|
-
|
|
25146
|
-
|
|
25147
|
-
|
|
25148
|
-
|
|
25149
|
-
|
|
25150
|
-
|
|
25151
|
-
|
|
25152
|
-
|
|
25160
|
+
// Walk the equity curve in chronological close order. Reverse-storage
|
|
25161
|
+
// iteration (newest-first storage → reverse) normally yields chronological
|
|
25162
|
+
// order for live ingest, but explicitly sorting by event.timestamp removes
|
|
25163
|
+
// the dependency on insertion-order matching close-order. This matters
|
|
25164
|
+
// under crash recovery (events reloaded from disk in arbitrary order) and
|
|
25165
|
+
// when ingest latency reorders closed events relative to wall-clock time.
|
|
25166
|
+
const chronological = validClosed
|
|
25167
|
+
.map((e) => ({
|
|
25168
|
+
r: e.pnl,
|
|
25169
|
+
fall: typeof e.fallPnl === "number" ? e.fallPnl : null,
|
|
25170
|
+
ts: e.timestamp,
|
|
25171
|
+
}))
|
|
25172
|
+
.sort((a, b) => a.ts - b.ts)
|
|
25173
|
+
.map(({ r, fall }) => ({ r, fall }));
|
|
25153
25174
|
let equity = 1;
|
|
25154
25175
|
let peak = 1;
|
|
25155
25176
|
let equityMaxDrawdown = 0;
|
|
@@ -27223,11 +27244,18 @@ class HeatmapStorage {
|
|
|
27223
27244
|
let equityFinal = 1;
|
|
27224
27245
|
let blown = false;
|
|
27225
27246
|
if (signals.length > 0) {
|
|
27247
|
+
// Walk the per-symbol equity curve in chronological close order.
|
|
27248
|
+
// Storage is newest-first (unshift on addSignal), but if signals were
|
|
27249
|
+
// ingested out-of-order (e.g. Live + crash recovery loading from disk in
|
|
27250
|
+
// arbitrary order, or a backfill replay), reverse-storage iteration
|
|
27251
|
+
// would misplace peak/trough and silently distort maxDrawdown. Sorting
|
|
27252
|
+
// by closeTimestamp explicitly removes that dependency.
|
|
27253
|
+
const ordered = [...signals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
|
|
27226
27254
|
let equity = 1;
|
|
27227
27255
|
let peak = 1;
|
|
27228
27256
|
let maxDD = 0;
|
|
27229
|
-
for (
|
|
27230
|
-
const fallPct =
|
|
27257
|
+
for (const s of ordered) {
|
|
27258
|
+
const fallPct = s.signal.maxDrawdown?.pnlPercentage;
|
|
27231
27259
|
if (typeof fallPct === "number" && fallPct < 0) {
|
|
27232
27260
|
const trough = equity * (1 + fallPct / 100);
|
|
27233
27261
|
if (trough <= 0) {
|
|
@@ -27239,7 +27267,7 @@ class HeatmapStorage {
|
|
|
27239
27267
|
if (troughDd > maxDD)
|
|
27240
27268
|
maxDD = troughDd;
|
|
27241
27269
|
}
|
|
27242
|
-
equity *= 1 +
|
|
27270
|
+
equity *= 1 + s.pnl.pnlPercentage / 100;
|
|
27243
27271
|
if (equity <= 0) {
|
|
27244
27272
|
maxDD = 100;
|
|
27245
27273
|
blown = true;
|
|
@@ -27676,23 +27704,28 @@ class HeatmapStorage {
|
|
|
27676
27704
|
let portfolioCertaintyRatio = null;
|
|
27677
27705
|
let portfolioExpectedYearlyReturns = null;
|
|
27678
27706
|
let portfolioTradesPerYear = null;
|
|
27679
|
-
const
|
|
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 = [];
|
|
27707
|
+
const pooledTrades = [];
|
|
27683
27708
|
let poolFirstPendingAt = Infinity;
|
|
27684
27709
|
let poolLastCloseAt = -Infinity;
|
|
27685
27710
|
for (const signals of this.symbolData.values()) {
|
|
27686
27711
|
for (const s of signals) {
|
|
27687
|
-
allReturns.push(s.pnl.pnlPercentage);
|
|
27688
27712
|
const fall = s.signal.maxDrawdown?.pnlPercentage;
|
|
27689
|
-
|
|
27713
|
+
pooledTrades.push({
|
|
27714
|
+
r: s.pnl.pnlPercentage,
|
|
27715
|
+
fall: typeof fall === "number" ? fall : null,
|
|
27716
|
+
closeAt: s.closeTimestamp,
|
|
27717
|
+
});
|
|
27690
27718
|
if (s.signal.pendingAt < poolFirstPendingAt)
|
|
27691
27719
|
poolFirstPendingAt = s.signal.pendingAt;
|
|
27692
27720
|
if (s.closeTimestamp > poolLastCloseAt)
|
|
27693
27721
|
poolLastCloseAt = s.closeTimestamp;
|
|
27694
27722
|
}
|
|
27695
27723
|
}
|
|
27724
|
+
pooledTrades.sort((a, b) => a.closeAt - b.closeAt);
|
|
27725
|
+
const allReturns = pooledTrades.map((t) => t.r);
|
|
27726
|
+
// Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
|
|
27727
|
+
// used for mark-to-market DD in the pooled equity curve below.
|
|
27728
|
+
const allFalls = pooledTrades.map((t) => t.fall);
|
|
27696
27729
|
if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
|
|
27697
27730
|
const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
|
|
27698
27731
|
const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
|
|
@@ -28066,7 +28099,7 @@ class HeatmapStorage {
|
|
|
28066
28099
|
`*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
28100
|
`*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
28101
|
`*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.
|
|
28102
|
+
`*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
28103
|
`*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
|
|
28071
28104
|
`*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
28105
|
`*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.*`,
|
|
@@ -36131,6 +36164,21 @@ class PriceMetaService {
|
|
|
36131
36164
|
* Instances are cached until clear() is called.
|
|
36132
36165
|
*/
|
|
36133
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
|
+
};
|
|
36134
36182
|
/**
|
|
36135
36183
|
* Returns the current market price for the given symbol and context.
|
|
36136
36184
|
*
|
|
@@ -36793,6 +36841,129 @@ class NotificationHelperService {
|
|
|
36793
36841
|
}
|
|
36794
36842
|
}
|
|
36795
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
|
+
|
|
36796
36967
|
{
|
|
36797
36968
|
provide(TYPES.loggerService, () => new LoggerService());
|
|
36798
36969
|
}
|
|
@@ -36829,6 +37000,7 @@ class NotificationHelperService {
|
|
|
36829
37000
|
provide(TYPES.contextMetaService, () => new ContextMetaService());
|
|
36830
37001
|
provide(TYPES.priceMetaService, () => new PriceMetaService());
|
|
36831
37002
|
provide(TYPES.timeMetaService, () => new TimeMetaService());
|
|
37003
|
+
provide(TYPES.runtimeMetaService, () => new RuntimeMetaService());
|
|
36832
37004
|
}
|
|
36833
37005
|
{
|
|
36834
37006
|
provide(TYPES.sizingGlobalService, () => new SizingGlobalService());
|
|
@@ -36932,6 +37104,7 @@ const metaServices = {
|
|
|
36932
37104
|
timeMetaService: inject(TYPES.timeMetaService),
|
|
36933
37105
|
priceMetaService: inject(TYPES.priceMetaService),
|
|
36934
37106
|
contextMetaService: inject(TYPES.contextMetaService),
|
|
37107
|
+
runtimeMetaService: inject(TYPES.runtimeMetaService),
|
|
36935
37108
|
};
|
|
36936
37109
|
const globalServices = {
|
|
36937
37110
|
sizingGlobalService: inject(TYPES.sizingGlobalService),
|
|
@@ -64587,6 +64760,20 @@ const CRON_METHOD_NAME_TICK = "CronUtils._tick";
|
|
|
64587
64760
|
const CRON_METHOD_NAME_ENABLE = "CronUtils.enable";
|
|
64588
64761
|
const CRON_METHOD_NAME_DISABLE = "CronUtils.disable";
|
|
64589
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;
|
|
64590
64777
|
/**
|
|
64591
64778
|
* Local logger instance.
|
|
64592
64779
|
*
|
|
@@ -64595,6 +64782,20 @@ const CRON_METHOD_NAME_DISPOSE = "CronUtils.dispose";
|
|
|
64595
64782
|
* being bootstrapped — `Cron` can be imported and used in isolation.
|
|
64596
64783
|
*/
|
|
64597
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();
|
|
64598
64799
|
/**
|
|
64599
64800
|
* Utility class for registering periodic tasks that fire on candle-interval
|
|
64600
64801
|
* boundaries of the virtual time produced by parallel backtests.
|
|
@@ -64617,8 +64818,8 @@ const LOGGER_SERVICE$1 = new LoggerService();
|
|
|
64617
64818
|
* Cron.register({
|
|
64618
64819
|
* name: "tg-signal-parser",
|
|
64619
64820
|
* interval: "1h",
|
|
64620
|
-
* handler: async (
|
|
64621
|
-
* await parseTelegramSignalsToMongo(when);
|
|
64821
|
+
* handler: async (info) => {
|
|
64822
|
+
* await parseTelegramSignalsToMongo(info.when);
|
|
64622
64823
|
* },
|
|
64623
64824
|
* });
|
|
64624
64825
|
*
|
|
@@ -64660,12 +64861,15 @@ class CronUtils {
|
|
|
64660
64861
|
* - Fire-once global: `${name}:once:g${generation}`.
|
|
64661
64862
|
* - Fire-once fan-out: `${name}:once:${symbol}:g${generation}`.
|
|
64662
64863
|
*
|
|
64663
|
-
* Value is the shared in-flight handler promise.
|
|
64664
|
-
*
|
|
64665
|
-
*
|
|
64666
|
-
*
|
|
64667
|
-
*
|
|
64668
|
-
*
|
|
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.
|
|
64669
64873
|
*/
|
|
64670
64874
|
this._inFlight = new Map();
|
|
64671
64875
|
/**
|
|
@@ -64709,9 +64913,12 @@ class CronUtils {
|
|
|
64709
64913
|
*
|
|
64710
64914
|
* Written synchronously in `_tick` at slot-open time (before the `await`),
|
|
64711
64915
|
* so a still-in-flight handler does not let a later tick re-open the same
|
|
64712
|
-
* (or an already-passed) boundary.
|
|
64713
|
-
*
|
|
64714
|
-
*
|
|
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`.
|
|
64715
64922
|
*/
|
|
64716
64923
|
this._lastBoundary = new Map();
|
|
64717
64924
|
/**
|
|
@@ -64731,7 +64938,7 @@ class CronUtils {
|
|
|
64731
64938
|
* name: "fetch-funding",
|
|
64732
64939
|
* interval: "8h",
|
|
64733
64940
|
* symbols: ["BTCUSDT", "ETHUSDT"],
|
|
64734
|
-
* handler: async (
|
|
64941
|
+
* handler: async (info) => { ... },
|
|
64735
64942
|
* });
|
|
64736
64943
|
* // Later:
|
|
64737
64944
|
* dispose();
|
|
@@ -64851,7 +65058,7 @@ class CronUtils {
|
|
|
64851
65058
|
* 4. **Fire-once** (`entry.interval === undefined`):
|
|
64852
65059
|
* - If the entry's fired-once key is already in `_firedOnce`, skip.
|
|
64853
65060
|
* - Slot key: `${name}:once` (+ scope) (+ gen).
|
|
64854
|
-
* - `
|
|
65061
|
+
* - `alignedMs` = the 1-minute-aligned `when` from step 0 (`ts`).
|
|
64855
65062
|
* 5. **Periodic** (`entry.interval` set):
|
|
64856
65063
|
* - Align `when` to the entry's interval via {@link alignToInterval} to
|
|
64857
65064
|
* get `alignedMs`, the boundary this tick belongs to.
|
|
@@ -64873,32 +65080,44 @@ class CronUtils {
|
|
|
64873
65080
|
* handler is still in flight.
|
|
64874
65081
|
* - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
|
|
64875
65082
|
* 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
|
|
64876
|
-
* already exists, `await` the same promise. Otherwise
|
|
64877
|
-
*
|
|
64878
|
-
*
|
|
64879
|
-
*
|
|
64880
|
-
*
|
|
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.
|
|
64881
65094
|
*
|
|
64882
65095
|
* Errors thrown by `handler` are caught, logged via `console.error`, and
|
|
64883
65096
|
* **not** rethrown — a failing handler must not break the per-symbol
|
|
64884
65097
|
* tick loop or unblock other parallel backtests with an unhandled
|
|
64885
65098
|
* rejection. A failed fire-once handler is **not** marked as fired and
|
|
64886
|
-
* 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.
|
|
64887
65102
|
*
|
|
64888
65103
|
* Requires active method context and execution context.
|
|
64889
65104
|
*
|
|
64890
65105
|
* @param symbol - Trading symbol from the current tick.
|
|
64891
65106
|
* @param when - Virtual time of the current tick.
|
|
64892
65107
|
* @param backtest - `true` for backtest ticks, `false` for live ticks.
|
|
64893
|
-
* Forwarded
|
|
64894
|
-
* from the tick that **opens** a given slot is observed by all
|
|
64895
|
-
* 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.
|
|
64896
65114
|
* @throws Error if method or execution context is missing.
|
|
64897
65115
|
*/
|
|
64898
|
-
this._tick = async (symbol, when, backtest) => {
|
|
65116
|
+
this._tick = async (symbol, when, backtest, context) => {
|
|
64899
65117
|
LOGGER_SERVICE$1.debug(CRON_METHOD_NAME_TICK, {
|
|
64900
65118
|
symbol,
|
|
64901
65119
|
when,
|
|
65120
|
+
context,
|
|
64902
65121
|
});
|
|
64903
65122
|
if (!MethodContextService.hasContext()) {
|
|
64904
65123
|
throw new Error("CronUtils _tick requires method context");
|
|
@@ -64908,6 +65127,10 @@ class CronUtils {
|
|
|
64908
65127
|
}
|
|
64909
65128
|
const ts = alignToInterval(when, "1m").getTime();
|
|
64910
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 = [];
|
|
64911
65134
|
for (const { entry, generation } of this._entries.values()) {
|
|
64912
65135
|
if (entry.symbols?.length && !entry.symbols.includes(symbol)) {
|
|
64913
65136
|
continue;
|
|
@@ -64915,7 +65138,6 @@ class CronUtils {
|
|
|
64915
65138
|
const perSymbol = !!entry.symbols?.length;
|
|
64916
65139
|
const scope = perSymbol ? `:${symbol}` : "";
|
|
64917
65140
|
const genSuffix = `:g${generation}`;
|
|
64918
|
-
let aligned;
|
|
64919
65141
|
let alignedMs;
|
|
64920
65142
|
let slotKey;
|
|
64921
65143
|
let firedKey;
|
|
@@ -64927,15 +65149,13 @@ class CronUtils {
|
|
|
64927
65149
|
if (this._firedOnce.has(onceKey)) {
|
|
64928
65150
|
continue;
|
|
64929
65151
|
}
|
|
64930
|
-
aligned = alignToInterval(when, "1m");
|
|
64931
65152
|
alignedMs = ts;
|
|
64932
65153
|
slotKey = `${entry.name}:once${scope}${genSuffix}`;
|
|
64933
65154
|
firedKey = onceKey;
|
|
64934
65155
|
boundaryKey = null;
|
|
64935
65156
|
}
|
|
64936
65157
|
else {
|
|
64937
|
-
|
|
64938
|
-
alignedMs = aligned.getTime();
|
|
65158
|
+
alignedMs = alignToInterval(when, entry.interval).getTime();
|
|
64939
65159
|
boundaryKey = `${entry.name}${scope}${genSuffix}`;
|
|
64940
65160
|
const lastBoundary = this._lastBoundary.get(boundaryKey);
|
|
64941
65161
|
// Fire when the tick's aligned boundary has advanced past the last one
|
|
@@ -64953,16 +65173,70 @@ class CronUtils {
|
|
|
64953
65173
|
// Advance the watermark synchronously at slot-open time, before the
|
|
64954
65174
|
// await below. Otherwise a later tick on the same (or an already
|
|
64955
65175
|
// crossed) boundary, arriving while this handler is still in flight,
|
|
64956
|
-
// 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.
|
|
64957
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);
|
|
64958
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);
|
|
64959
65194
|
}
|
|
64960
|
-
pending = this._runEntry(entry, symbol, aligned, alignedMs, slotKey, firedKey, backtest);
|
|
64961
|
-
this._inFlight.set(slotKey, pending);
|
|
64962
65195
|
}
|
|
64963
65196
|
taskList.push(pending);
|
|
64964
65197
|
}
|
|
64965
|
-
|
|
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
|
+
}
|
|
64966
65240
|
};
|
|
64967
65241
|
/**
|
|
64968
65242
|
* Subscribe `Cron` to the engine's strategy lifecycle subjects so registered
|
|
@@ -64977,7 +65251,11 @@ class CronUtils {
|
|
|
64977
65251
|
*
|
|
64978
65252
|
* All four subjects are subscribed to a single `singlerun`-wrapped
|
|
64979
65253
|
* handler that builds `_tick(event.symbol, new Date(event.timestamp),
|
|
64980
|
-
* event.backtest
|
|
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
|
|
64981
65259
|
* queue: at most one `_tick` runs at a time, the next waits. This matters
|
|
64982
65260
|
* because the engine can emit `beforeStart` and an immediate `idlePing`
|
|
64983
65261
|
* on the very same minute, and concurrent `_tick`s on the same
|
|
@@ -65013,7 +65291,11 @@ class CronUtils {
|
|
|
65013
65291
|
this.enable = functoolsKit.singleshot(() => {
|
|
65014
65292
|
LOGGER_SERVICE$1.info(CRON_METHOD_NAME_ENABLE);
|
|
65015
65293
|
const handleTick = functoolsKit.singlerun(async (event) => {
|
|
65016
|
-
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
|
+
});
|
|
65017
65299
|
});
|
|
65018
65300
|
const unBeforeStart = beforeStartSubject.subscribe(handleTick);
|
|
65019
65301
|
const unIdlePing = idlePingSubject.subscribe(handleTick);
|
|
@@ -65116,25 +65398,51 @@ class CronUtils {
|
|
|
65116
65398
|
/**
|
|
65117
65399
|
* Build the singleshot promise for a single in-flight slot.
|
|
65118
65400
|
*
|
|
65119
|
-
*
|
|
65120
|
-
*
|
|
65121
|
-
*
|
|
65122
|
-
*
|
|
65123
|
-
*
|
|
65124
|
-
*
|
|
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`.
|
|
65125
65420
|
* @param firedKey - Key to add to `_firedOnce` on success, or `null` for
|
|
65126
65421
|
* periodic entries (which never populate `_firedOnce`).
|
|
65127
|
-
* @param backtest -
|
|
65128
|
-
* "winner" tick's flag is what all parallel awaiters
|
|
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.
|
|
65129
65427
|
*/
|
|
65130
|
-
async _runEntry(entry, symbol,
|
|
65428
|
+
async _runEntry(entry, symbol, alignedMs, slotKey, firedKey, backtest, context) {
|
|
65131
65429
|
let failed = false;
|
|
65132
65430
|
try {
|
|
65133
|
-
await
|
|
65431
|
+
const info = await RUNTIME_META_SERVICE.getRuntimeInfo(symbol, context, backtest);
|
|
65432
|
+
await entry.handler(info);
|
|
65134
65433
|
}
|
|
65135
|
-
catch (
|
|
65434
|
+
catch (error) {
|
|
65136
65435
|
failed = true;
|
|
65137
|
-
|
|
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);
|
|
65138
65446
|
}
|
|
65139
65447
|
finally {
|
|
65140
65448
|
this._inFlight.delete(slotKey);
|
|
@@ -65142,6 +65450,7 @@ class CronUtils {
|
|
|
65142
65450
|
this._firedOnce.add(firedKey);
|
|
65143
65451
|
}
|
|
65144
65452
|
}
|
|
65453
|
+
return failed;
|
|
65145
65454
|
}
|
|
65146
65455
|
}
|
|
65147
65456
|
/**
|
|
@@ -65155,7 +65464,7 @@ class CronUtils {
|
|
|
65155
65464
|
* Cron.register({
|
|
65156
65465
|
* name: "tg-parser",
|
|
65157
65466
|
* interval: "1h",
|
|
65158
|
-
* handler: async (
|
|
65467
|
+
* handler: async (info) => { ... },
|
|
65159
65468
|
* });
|
|
65160
65469
|
* ```
|
|
65161
65470
|
*/
|