@tradejs/node 1.0.9 → 1.0.10

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/dist/backtest.mjs CHANGED
@@ -1,20 +1,22 @@
1
1
  import {
2
2
  BUILTIN_CONNECTOR_NAMES,
3
3
  getConnectorCreatorByName
4
- } from "./chunk-JMDYEKIO.mjs";
4
+ } from "./chunk-V3YMKE4I.mjs";
5
5
  import {
6
6
  buildMlPayload,
7
+ enrichSignalWithBinanceMarketContext,
8
+ enrichSignalWithCoinMarketCapContext,
7
9
  enrichSignalWithDerivativesContext
8
- } from "./chunk-JRRG3YQG.mjs";
10
+ } from "./chunk-37VNDZVX.mjs";
9
11
  import {
10
12
  buildAiPayload
11
- } from "./chunk-2JKX3DM7.mjs";
13
+ } from "./chunk-IUZML4RK.mjs";
12
14
  import {
13
15
  getStrategyCreator
14
- } from "./chunk-WGOYR6AB.mjs";
16
+ } from "./chunk-QVSMINLG.mjs";
15
17
  import {
16
18
  getTradejsProjectCwd
17
- } from "./chunk-JU77QVJ3.mjs";
19
+ } from "./chunk-WS5DYEVZ.mjs";
18
20
  import "./chunk-6DZX6EAA.mjs";
19
21
 
20
22
  // src/backtest.ts
@@ -22,6 +24,11 @@ export * from "@tradejs/core/backtest";
22
24
 
23
25
  // src/testing.ts
24
26
  import { alignSortedCandlesByTimestamp } from "@tradejs/core/indicators";
27
+ import { BACKTEST_EXECUTION_INTERVAL } from "@tradejs/core/constants";
28
+ import {
29
+ releaseStrategyIndicatorsReplayCache,
30
+ releaseStrategyReplayCache
31
+ } from "@tradejs/core/strategies";
25
32
  import { getBacktestPreloadStart } from "@tradejs/core/time";
26
33
  import { appendAiDatasetRow } from "@tradejs/infra/ai";
27
34
  import {
@@ -33,19 +40,37 @@ import { logger } from "@tradejs/infra/logger";
33
40
 
34
41
  // src/testConnector.ts
35
42
  import { randomUUID } from "crypto";
43
+ import { FEE_PERCENT, INITIAL_BACKTEST_AMOUNT } from "@tradejs/core/constants";
44
+ import { calculateStatsFull } from "@tradejs/core/backtest";
45
+ import {
46
+ applyExecutionSlippage as applyModeledExecutionSlippage,
47
+ calculateExecutionSlippageBreakdown,
48
+ extractExecutionMarketImpactBps,
49
+ extractExecutionSpreadBps,
50
+ extractExecutionDelayRiskBps
51
+ } from "@tradejs/core/trade";
36
52
  import { round } from "@tradejs/core/math";
37
- var FEE = 5e-3;
38
- var INITIAL_AMOUNT = 100;
53
+ var PRICE_PRECISION = 8;
39
54
  var createTestConnector = (connector, context) => {
40
55
  let state = {};
41
56
  const orderLog = [];
42
57
  const positionLog = [];
58
+ const fastMode = Boolean(context?.fastMode);
59
+ const executionCostModel = context?.executionCostModel;
60
+ const makerFeeRate = executionCostModel?.fees.makerRate ?? FEE_PERCENT;
61
+ const takerFeeRate = executionCostModel?.fees.takerRate ?? FEE_PERCENT;
62
+ const fundingRates = [...context?.fundingRates ?? []].sort(
63
+ (left, right) => left.timestamp - right.timestamp
64
+ );
65
+ const processedFundingTimestamps = /* @__PURE__ */ new Set();
43
66
  let currentPosition = null;
44
- let amount = INITIAL_AMOUNT;
67
+ let amount = INITIAL_BACKTEST_AMOUNT;
45
68
  let originalQty = 0;
46
69
  let currentPositionProfit = 0;
70
+ let currentSignalId = null;
47
71
  let takeProfits = [];
48
72
  let stopLossPrice = null;
73
+ let currentTradeResult = null;
49
74
  const closedSignalResults = [];
50
75
  const logOrder = (data) => {
51
76
  const nextEntry = {
@@ -56,10 +81,157 @@ var createTestConnector = (connector, context) => {
56
81
  index: orderLog.length
57
82
  };
58
83
  if (nextEntry.signal) {
59
- const { indicators: _indicators, ...signalWithoutIndicators } = nextEntry.signal;
60
- nextEntry.signal = signalWithoutIndicators;
84
+ const {
85
+ additionalIndicators: _additionalIndicators,
86
+ indicators: _indicators,
87
+ ...signalWithoutHeavyContext
88
+ } = nextEntry.signal;
89
+ nextEntry.signal = signalWithoutHeavyContext;
90
+ }
91
+ if (!fastMode) {
92
+ orderLog.push(nextEntry);
93
+ }
94
+ };
95
+ const roundNullable = (value) => value == null ? null : round(value);
96
+ const roundPrice = (value) => round(value, PRICE_PRECISION);
97
+ const roundNullablePrice = (value) => value == null ? null : roundPrice(value);
98
+ const getSlippageCost = ({
99
+ requestedPrice,
100
+ executionPrice,
101
+ direction,
102
+ stage,
103
+ qty
104
+ }) => {
105
+ if (direction === "LONG") {
106
+ return stage === "entry" ? Math.max(0, executionPrice - requestedPrice) * qty : Math.max(0, requestedPrice - executionPrice) * qty;
107
+ }
108
+ return stage === "entry" ? Math.max(0, requestedPrice - executionPrice) * qty : Math.max(0, executionPrice - requestedPrice) * qty;
109
+ };
110
+ const getSlippageBps = (requestedPrice, executionPrice) => requestedPrice ? (executionPrice - requestedPrice) / requestedPrice * 1e4 : 0;
111
+ const getWeightedAverage = (previousValue, previousQty, nextValue, nextQty) => {
112
+ if (previousValue == null || previousQty <= 0) {
113
+ return nextValue;
61
114
  }
62
- orderLog.push(nextEntry);
115
+ return (previousValue * previousQty + nextValue * nextQty) / (previousQty + nextQty);
116
+ };
117
+ const finalizeTradeResult = (tradeResult, timestamp) => {
118
+ if (!tradeResult.exitReason) {
119
+ return void 0;
120
+ }
121
+ return {
122
+ ...tradeResult,
123
+ exitTimestamp: tradeResult.exitTimestamp ?? timestamp,
124
+ exitReason: tradeResult.exitReason,
125
+ requestedEntryPrice: roundPrice(tradeResult.requestedEntryPrice),
126
+ entryPrice: roundPrice(tradeResult.entryPrice),
127
+ requestedExitPrice: roundNullablePrice(tradeResult.requestedExitPrice),
128
+ exitPrice: roundNullablePrice(tradeResult.exitPrice),
129
+ grossProfit: round(tradeResult.grossProfit),
130
+ netProfit: round(tradeResult.netProfit),
131
+ openFee: round(tradeResult.openFee),
132
+ closeFee: round(tradeResult.closeFee),
133
+ fundingFee: roundNullable(tradeResult.fundingFee),
134
+ totalFee: round(tradeResult.totalFee),
135
+ entrySlippagePrice: round(tradeResult.entrySlippagePrice),
136
+ entrySlippageBps: round(tradeResult.entrySlippageBps),
137
+ entryBaseSlippageBps: round(tradeResult.entryBaseSlippageBps),
138
+ entrySpreadBps: round(tradeResult.entrySpreadBps),
139
+ entrySpreadSlippageBps: round(tradeResult.entrySpreadSlippageBps),
140
+ entryMarketImpactBps: round(tradeResult.entryMarketImpactBps),
141
+ entryDelayRiskBps: roundNullable(tradeResult.entryDelayRiskBps),
142
+ entrySlippageCost: round(tradeResult.entrySlippageCost),
143
+ exitSlippagePrice: roundNullable(tradeResult.exitSlippagePrice),
144
+ exitSlippageBps: roundNullable(tradeResult.exitSlippageBps),
145
+ exitBaseSlippageBps: roundNullable(tradeResult.exitBaseSlippageBps),
146
+ exitSpreadBps: roundNullable(tradeResult.exitSpreadBps),
147
+ exitSpreadSlippageBps: roundNullable(tradeResult.exitSpreadSlippageBps),
148
+ exitMarketImpactBps: roundNullable(tradeResult.exitMarketImpactBps),
149
+ exitDelayRiskBps: roundNullable(tradeResult.exitDelayRiskBps),
150
+ exitSlippageCost: round(tradeResult.exitSlippageCost),
151
+ totalSlippageCost: round(tradeResult.totalSlippageCost),
152
+ qty: round(tradeResult.qty),
153
+ closedQty: round(tradeResult.closedQty)
154
+ };
155
+ };
156
+ const recordExitResult = ({
157
+ timestamp,
158
+ reason,
159
+ requestedPrice,
160
+ executionPrice,
161
+ qty,
162
+ grossProfit,
163
+ fee,
164
+ slippageBreakdown
165
+ }) => {
166
+ if (!currentTradeResult || !currentPosition) {
167
+ return;
168
+ }
169
+ const previousClosedQty = currentTradeResult.closedQty;
170
+ const requestedExitPrice = getWeightedAverage(
171
+ currentTradeResult.requestedExitPrice,
172
+ previousClosedQty,
173
+ requestedPrice,
174
+ qty
175
+ );
176
+ const exitPrice = getWeightedAverage(
177
+ currentTradeResult.exitPrice,
178
+ previousClosedQty,
179
+ executionPrice,
180
+ qty
181
+ );
182
+ const exitBaseSlippageBps = getWeightedAverage(
183
+ currentTradeResult.exitBaseSlippageBps,
184
+ previousClosedQty,
185
+ slippageBreakdown.baseSlippageBps,
186
+ qty
187
+ );
188
+ const exitSpreadBps = getWeightedAverage(
189
+ currentTradeResult.exitSpreadBps,
190
+ previousClosedQty,
191
+ slippageBreakdown.spreadBps,
192
+ qty
193
+ );
194
+ const exitSpreadSlippageBps = getWeightedAverage(
195
+ currentTradeResult.exitSpreadSlippageBps,
196
+ previousClosedQty,
197
+ slippageBreakdown.spreadSlippageBps,
198
+ qty
199
+ );
200
+ const exitMarketImpactBps = getWeightedAverage(
201
+ currentTradeResult.exitMarketImpactBps,
202
+ previousClosedQty,
203
+ slippageBreakdown.marketImpactBps,
204
+ qty
205
+ );
206
+ const exitDelayRiskBps = null;
207
+ const exitSlippageCost = currentTradeResult.exitSlippageCost + getSlippageCost({
208
+ requestedPrice,
209
+ executionPrice,
210
+ direction: currentPosition.direction,
211
+ stage: "exit",
212
+ qty
213
+ });
214
+ currentTradeResult = {
215
+ ...currentTradeResult,
216
+ closedQty: previousClosedQty + qty,
217
+ exitTimestamp: timestamp,
218
+ exitReason: reason,
219
+ requestedExitPrice,
220
+ exitPrice,
221
+ grossProfit: currentTradeResult.grossProfit + grossProfit,
222
+ netProfit: currentTradeResult.netProfit + grossProfit - fee,
223
+ closeFee: currentTradeResult.closeFee + fee,
224
+ totalFee: currentTradeResult.openFee + currentTradeResult.closeFee + fee + (currentTradeResult.fundingFee ?? 0),
225
+ exitSlippagePrice: exitPrice - requestedExitPrice,
226
+ exitSlippageBps: getSlippageBps(requestedExitPrice, exitPrice),
227
+ exitBaseSlippageBps,
228
+ exitSpreadBps,
229
+ exitSpreadSlippageBps,
230
+ exitMarketImpactBps,
231
+ exitDelayRiskBps,
232
+ exitSlippageCost,
233
+ totalSlippageCost: currentTradeResult.entrySlippageCost + exitSlippageCost
234
+ };
63
235
  };
64
236
  const clearPosition = (timestamp) => {
65
237
  takeProfits = [];
@@ -69,11 +241,12 @@ var createTestConnector = (connector, context) => {
69
241
  return;
70
242
  }
71
243
  if (context?.mlEnabled || context?.aiEnabled) {
72
- const signalId = currentPosition.signal?.signalId;
73
- if (signalId) {
244
+ if (currentSignalId) {
245
+ const tradeResult = currentTradeResult ? finalizeTradeResult(currentTradeResult, timestamp) : void 0;
74
246
  closedSignalResults.push({
75
- signalId,
76
- profit: currentPositionProfit
247
+ signalId: currentSignalId,
248
+ profit: round(currentPositionProfit),
249
+ ...tradeResult ? { tradeResult } : {}
77
250
  });
78
251
  }
79
252
  }
@@ -89,10 +262,91 @@ var createTestConnector = (connector, context) => {
89
262
  }
90
263
  });
91
264
  currentPosition = null;
265
+ currentSignalId = null;
266
+ currentTradeResult = null;
92
267
  currentPositionProfit = 0;
93
268
  };
269
+ const getNetProfit = ({
270
+ grossProfit,
271
+ price,
272
+ qty,
273
+ feeRate = takerFeeRate
274
+ }) => {
275
+ const fee = price * qty * feeRate;
276
+ return {
277
+ fee,
278
+ profit: grossProfit - fee
279
+ };
280
+ };
281
+ const applyFunding = (candle) => {
282
+ if (!executionCostModel?.funding.enabled || !currentPosition) {
283
+ return;
284
+ }
285
+ for (const point of fundingRates) {
286
+ if (processedFundingTimestamps.has(point.timestamp) || point.symbol.toUpperCase() !== currentPosition.symbol.toUpperCase() || point.timestamp <= currentPosition.timestamp || point.timestamp > candle.timestamp) {
287
+ continue;
288
+ }
289
+ processedFundingTimestamps.add(point.timestamp);
290
+ const notional = candle.close * currentPosition.qty;
291
+ const fundingCost = notional * point.rate * (currentPosition.direction === "LONG" ? 1 : -1);
292
+ amount -= fundingCost;
293
+ currentPositionProfit -= fundingCost;
294
+ if (currentTradeResult) {
295
+ currentTradeResult.fundingFee = (currentTradeResult.fundingFee ?? 0) + fundingCost;
296
+ currentTradeResult.netProfit -= fundingCost;
297
+ currentTradeResult.totalFee += fundingCost;
298
+ }
299
+ }
300
+ };
301
+ const getExitTimestamp = (candle) => currentPosition ? Math.max(candle.timestamp, currentPosition.timestamp) : candle.timestamp;
302
+ const applyExecutionSlippage = ({
303
+ price,
304
+ direction,
305
+ stage,
306
+ signal
307
+ }) => {
308
+ const modelParams = {
309
+ baseSlippageBps: executionCostModel?.slippage.baseBps,
310
+ spreadBps: extractExecutionSpreadBps(signal),
311
+ spreadMultiplier: executionCostModel?.slippage.spreadMultiplier,
312
+ marketImpactBps: extractExecutionMarketImpactBps(signal) ?? executionCostModel?.slippage.marketImpactBps,
313
+ delayRiskBps: stage === "entry" ? (extractExecutionDelayRiskBps(signal) ?? 0) * (executionCostModel?.slippage.delayRiskMultiplier ?? 1) : null
314
+ };
315
+ return applyModeledExecutionSlippage({
316
+ price,
317
+ direction,
318
+ stage,
319
+ ...modelParams
320
+ });
321
+ };
322
+ const getExecutionSlippageBreakdown = ({
323
+ stage,
324
+ signal
325
+ }) => calculateExecutionSlippageBreakdown({
326
+ baseSlippageBps: executionCostModel?.slippage.baseBps,
327
+ spreadBps: extractExecutionSpreadBps(signal),
328
+ spreadMultiplier: executionCostModel?.slippage.spreadMultiplier,
329
+ marketImpactBps: extractExecutionMarketImpactBps(signal) ?? executionCostModel?.slippage.marketImpactBps,
330
+ delayRiskBps: stage === "entry" ? (extractExecutionDelayRiskBps(signal) ?? 0) * (executionCostModel?.slippage.delayRiskMultiplier ?? 1) : null
331
+ });
332
+ const getExecutionSlippageLogData = (slippageBreakdown, stage) => ({
333
+ executionSlippageStage: stage,
334
+ executionSlippageBps: round(slippageBreakdown.effectiveSlippageBps),
335
+ executionBaseSlippageBps: round(slippageBreakdown.baseSlippageBps),
336
+ executionSpreadBps: round(slippageBreakdown.spreadBps),
337
+ executionSpreadSlippageBps: round(slippageBreakdown.spreadSlippageBps),
338
+ executionMarketImpactBps: round(slippageBreakdown.marketImpactBps),
339
+ executionDelayRiskBps: stage === "entry" ? round(slippageBreakdown.delayRiskBps) : null
340
+ });
94
341
  return {
95
342
  __tradejsTestConnector: true,
343
+ capabilities: connector.capabilities,
344
+ universe: connector.universe,
345
+ accountId: connector.accountId,
346
+ deploymentId: connector.deploymentId,
347
+ listInstruments: (query) => connector.listInstruments(query),
348
+ getFundingRateHistory: connector.getFundingRateHistory ? (request) => connector.getFundingRateHistory(request) : void 0,
349
+ getTradingFeeRate: connector.getTradingFeeRate ? (symbol) => connector.getTradingFeeRate(symbol) : void 0,
96
350
  getState: async () => state,
97
351
  setState: async (newState) => {
98
352
  state = {
@@ -103,15 +357,22 @@ var createTestConnector = (connector, context) => {
103
357
  kline: async (options) => connector.kline(options),
104
358
  getResult: async () => {
105
359
  const orderLogId = randomUUID().slice(-12);
360
+ const fullStat = fastMode ? calculateStatsFull(positionLog) : null;
106
361
  return {
107
- stat: {
108
- amount,
109
- profit: amount - INITIAL_AMOUNT,
362
+ stat: fullStat ? {
363
+ ...fullStat,
364
+ profit: fullStat.netProfit
365
+ } : {
366
+ amount: round(amount),
367
+ profit: round(amount - INITIAL_BACKTEST_AMOUNT),
110
368
  orders: positionLog.length
111
369
  },
112
370
  orderLogId,
113
- inlineOrderLog: [...orderLog],
114
- inlinePositionLog: [...positionLog]
371
+ ...executionCostModel ? { executionCostModel } : {},
372
+ ...fastMode ? {} : {
373
+ inlineOrderLog: orderLog,
374
+ inlinePositionLog: positionLog
375
+ }
115
376
  };
116
377
  },
117
378
  getPosition: async () => currentPosition || null,
@@ -137,6 +398,7 @@ var createTestConnector = (connector, context) => {
137
398
  if (!candle || !currentPosition || !currentPosition.qty) {
138
399
  return;
139
400
  }
401
+ applyFunding(candle);
140
402
  const isLong = currentPosition.direction === "LONG";
141
403
  const entryPrice = currentPosition.price;
142
404
  const high = candle.high;
@@ -146,47 +408,217 @@ var createTestConnector = (connector, context) => {
146
408
  const targetPrice = tp.price;
147
409
  const reached = isLong ? high >= targetPrice : low <= targetPrice;
148
410
  if (reached) {
411
+ const exitTimestamp = getExitTimestamp(candle);
149
412
  const qty = originalQty * tp.rate;
150
- const profit = isLong ? (targetPrice - entryPrice) * qty : (entryPrice - targetPrice) * qty;
413
+ const slippageBreakdown = getExecutionSlippageBreakdown({
414
+ stage: "exit",
415
+ signal: currentPosition.signal
416
+ });
417
+ const executionPrice = applyExecutionSlippage({
418
+ price: targetPrice,
419
+ direction: currentPosition.direction,
420
+ stage: "exit",
421
+ signal: currentPosition.signal
422
+ });
423
+ const grossProfit = isLong ? (executionPrice - entryPrice) * qty : (entryPrice - executionPrice) * qty;
424
+ const { fee, profit } = getNetProfit({
425
+ grossProfit,
426
+ price: executionPrice,
427
+ qty
428
+ });
429
+ recordExitResult({
430
+ timestamp: exitTimestamp,
431
+ reason: "take_profit",
432
+ requestedPrice: targetPrice,
433
+ executionPrice,
434
+ qty,
435
+ grossProfit,
436
+ fee,
437
+ slippageBreakdown
438
+ });
151
439
  amount += profit;
152
440
  currentPositionProfit += profit;
153
441
  currentPosition.qty = parseFloat(
154
442
  (currentPosition.qty - qty).toFixed(8)
155
443
  );
156
444
  logOrder({
157
- timestamp: candle.timestamp,
445
+ timestamp: exitTimestamp,
158
446
  qty,
159
- price: targetPrice,
447
+ price: executionPrice,
160
448
  profit,
161
- type: isLong ? "TAKE_PROFIT_LONG" : "TAKE_PROFIT_SHORT"
449
+ fee,
450
+ type: isLong ? "TAKE_PROFIT_LONG" : "TAKE_PROFIT_SHORT",
451
+ ...getExecutionSlippageLogData(slippageBreakdown, "exit")
162
452
  });
163
453
  tp.done = true;
164
454
  }
165
455
  }
166
456
  takeProfits = takeProfits.filter(({ done }) => !done);
167
457
  if (currentPosition && currentPosition.qty <= 0) {
168
- clearPosition(candle.timestamp);
458
+ clearPosition(getExitTimestamp(candle));
169
459
  }
170
460
  },
171
461
  checkSl: async (candle) => {
172
462
  if (!stopLossPrice || !currentPosition || !candle) {
173
463
  return;
174
464
  }
465
+ applyFunding(candle);
175
466
  const isLong = currentPosition.direction === "LONG";
176
467
  const hitStop = isLong ? candle.low <= stopLossPrice : candle.high >= stopLossPrice;
177
468
  if (hitStop) {
469
+ const exitTimestamp = getExitTimestamp(candle);
178
470
  const qty = currentPosition.qty;
179
- const profit = isLong ? (stopLossPrice - currentPosition.price) * qty : (currentPosition.price - stopLossPrice) * qty;
471
+ const slippageBreakdown = getExecutionSlippageBreakdown({
472
+ stage: "exit",
473
+ signal: currentPosition.signal
474
+ });
475
+ const executionPrice = applyExecutionSlippage({
476
+ price: stopLossPrice,
477
+ direction: currentPosition.direction,
478
+ stage: "exit",
479
+ signal: currentPosition.signal
480
+ });
481
+ const grossProfit = isLong ? (executionPrice - currentPosition.price) * qty : (currentPosition.price - executionPrice) * qty;
482
+ const { fee, profit } = getNetProfit({
483
+ grossProfit,
484
+ price: executionPrice,
485
+ qty
486
+ });
487
+ recordExitResult({
488
+ timestamp: exitTimestamp,
489
+ reason: "stop_loss",
490
+ requestedPrice: stopLossPrice,
491
+ executionPrice,
492
+ qty,
493
+ grossProfit,
494
+ fee,
495
+ slippageBreakdown
496
+ });
180
497
  amount += profit;
181
498
  currentPositionProfit += profit;
182
499
  logOrder({
183
- timestamp: candle.timestamp,
500
+ timestamp: exitTimestamp,
184
501
  qty,
185
502
  profit,
186
- price: stopLossPrice,
187
- type: isLong ? "STOP_LOSS_LONG" : "STOP_LOSS_SHORT"
503
+ price: executionPrice,
504
+ fee,
505
+ type: isLong ? "STOP_LOSS_LONG" : "STOP_LOSS_SHORT",
506
+ ...getExecutionSlippageLogData(slippageBreakdown, "exit")
188
507
  });
189
- clearPosition(candle.timestamp);
508
+ clearPosition(exitTimestamp);
509
+ }
510
+ },
511
+ checkExits: async (candle) => {
512
+ if (!candle || !currentPosition) {
513
+ return;
514
+ }
515
+ applyFunding(candle);
516
+ if (stopLossPrice) {
517
+ const isLong2 = currentPosition.direction === "LONG";
518
+ const hitStop = isLong2 ? candle.low <= stopLossPrice : candle.high >= stopLossPrice;
519
+ if (hitStop) {
520
+ const exitTimestamp = getExitTimestamp(candle);
521
+ const qty = currentPosition.qty;
522
+ const slippageBreakdown = getExecutionSlippageBreakdown({
523
+ stage: "exit",
524
+ signal: currentPosition.signal
525
+ });
526
+ const executionPrice = applyExecutionSlippage({
527
+ price: stopLossPrice,
528
+ direction: currentPosition.direction,
529
+ stage: "exit",
530
+ signal: currentPosition.signal
531
+ });
532
+ const grossProfit = isLong2 ? (executionPrice - currentPosition.price) * qty : (currentPosition.price - executionPrice) * qty;
533
+ const { fee, profit } = getNetProfit({
534
+ grossProfit,
535
+ price: executionPrice,
536
+ qty
537
+ });
538
+ recordExitResult({
539
+ timestamp: exitTimestamp,
540
+ reason: "stop_loss",
541
+ requestedPrice: stopLossPrice,
542
+ executionPrice,
543
+ qty,
544
+ grossProfit,
545
+ fee,
546
+ slippageBreakdown
547
+ });
548
+ amount += profit;
549
+ currentPositionProfit += profit;
550
+ logOrder({
551
+ timestamp: exitTimestamp,
552
+ qty,
553
+ profit,
554
+ price: executionPrice,
555
+ fee,
556
+ type: isLong2 ? "STOP_LOSS_LONG" : "STOP_LOSS_SHORT",
557
+ ...getExecutionSlippageLogData(slippageBreakdown, "exit")
558
+ });
559
+ clearPosition(exitTimestamp);
560
+ }
561
+ }
562
+ if (!currentPosition || !currentPosition.qty) {
563
+ return;
564
+ }
565
+ const isLong = currentPosition.direction === "LONG";
566
+ const entryPrice = currentPosition.price;
567
+ const high = candle.high;
568
+ const low = candle.low;
569
+ for (const tp of takeProfits) {
570
+ if (!currentPosition || currentPosition.qty <= 0) break;
571
+ const targetPrice = tp.price;
572
+ const reached = isLong ? high >= targetPrice : low <= targetPrice;
573
+ if (reached) {
574
+ const exitTimestamp = getExitTimestamp(candle);
575
+ const qty = originalQty * tp.rate;
576
+ const slippageBreakdown = getExecutionSlippageBreakdown({
577
+ stage: "exit",
578
+ signal: currentPosition.signal
579
+ });
580
+ const executionPrice = applyExecutionSlippage({
581
+ price: targetPrice,
582
+ direction: currentPosition.direction,
583
+ stage: "exit",
584
+ signal: currentPosition.signal
585
+ });
586
+ const grossProfit = isLong ? (executionPrice - entryPrice) * qty : (entryPrice - executionPrice) * qty;
587
+ const { fee, profit } = getNetProfit({
588
+ grossProfit,
589
+ price: executionPrice,
590
+ qty
591
+ });
592
+ recordExitResult({
593
+ timestamp: exitTimestamp,
594
+ reason: "take_profit",
595
+ requestedPrice: targetPrice,
596
+ executionPrice,
597
+ qty,
598
+ grossProfit,
599
+ fee,
600
+ slippageBreakdown
601
+ });
602
+ amount += profit;
603
+ currentPositionProfit += profit;
604
+ currentPosition.qty = parseFloat(
605
+ (currentPosition.qty - qty).toFixed(8)
606
+ );
607
+ logOrder({
608
+ timestamp: exitTimestamp,
609
+ qty,
610
+ price: executionPrice,
611
+ profit,
612
+ fee,
613
+ type: isLong ? "TAKE_PROFIT_LONG" : "TAKE_PROFIT_SHORT",
614
+ ...getExecutionSlippageLogData(slippageBreakdown, "exit")
615
+ });
616
+ tp.done = true;
617
+ }
618
+ }
619
+ takeProfits = takeProfits.filter(({ done }) => !done);
620
+ if (currentPosition && currentPosition.qty <= 0) {
621
+ clearPosition(getExitTimestamp(candle));
190
622
  }
191
623
  },
192
624
  placeOrder: async (order) => {
@@ -194,17 +626,77 @@ var createTestConnector = (connector, context) => {
194
626
  return false;
195
627
  }
196
628
  const isLong = order.direction === "LONG";
197
- currentPosition = { ...order, amount };
629
+ const entrySlippageBreakdown = getExecutionSlippageBreakdown({
630
+ stage: "entry",
631
+ signal: order.signal
632
+ });
633
+ const entryPrice = applyExecutionSlippage({
634
+ price: order.price,
635
+ direction: order.direction,
636
+ stage: "entry",
637
+ signal: order.signal
638
+ });
639
+ currentPosition = { ...order, price: entryPrice, amount };
640
+ currentSignalId = typeof order.signal?.signalId === "string" && order.signal.signalId ? order.signal.signalId : null;
198
641
  originalQty = order.qty;
199
- const fee = order.price * order.qty * FEE;
200
- const profit = fee * -1;
642
+ const { fee, profit } = getNetProfit({
643
+ grossProfit: 0,
644
+ price: entryPrice,
645
+ qty: order.qty,
646
+ feeRate: order.isLimit ? makerFeeRate : takerFeeRate
647
+ });
648
+ const entrySlippageCost = getSlippageCost({
649
+ requestedPrice: order.price,
650
+ executionPrice: entryPrice,
651
+ direction: order.direction,
652
+ stage: "entry",
653
+ qty: order.qty
654
+ });
201
655
  amount += profit;
202
656
  currentPositionProfit = profit;
657
+ currentTradeResult = currentSignalId ? {
658
+ signalId: currentSignalId,
659
+ direction: order.direction,
660
+ qty: order.qty,
661
+ closedQty: 0,
662
+ entryTimestamp: order.timestamp,
663
+ exitTimestamp: null,
664
+ exitReason: null,
665
+ requestedEntryPrice: order.price,
666
+ entryPrice,
667
+ requestedExitPrice: null,
668
+ exitPrice: null,
669
+ grossProfit: 0,
670
+ netProfit: profit,
671
+ openFee: fee,
672
+ closeFee: 0,
673
+ fundingFee: executionCostModel?.funding.enabled ? 0 : null,
674
+ totalFee: fee,
675
+ entrySlippagePrice: entryPrice - order.price,
676
+ entrySlippageBps: getSlippageBps(order.price, entryPrice),
677
+ entryBaseSlippageBps: entrySlippageBreakdown.baseSlippageBps,
678
+ entrySpreadBps: entrySlippageBreakdown.spreadBps,
679
+ entrySpreadSlippageBps: entrySlippageBreakdown.spreadSlippageBps,
680
+ entryMarketImpactBps: entrySlippageBreakdown.marketImpactBps,
681
+ entryDelayRiskBps: entrySlippageBreakdown.delayRiskBps,
682
+ entrySlippageCost,
683
+ exitSlippagePrice: null,
684
+ exitSlippageBps: null,
685
+ exitBaseSlippageBps: null,
686
+ exitSpreadBps: null,
687
+ exitSpreadSlippageBps: null,
688
+ exitMarketImpactBps: null,
689
+ exitDelayRiskBps: null,
690
+ exitSlippageCost: 0,
691
+ totalSlippageCost: entrySlippageCost
692
+ } : null;
203
693
  logOrder({
204
694
  ...order,
695
+ price: entryPrice,
205
696
  profit,
206
697
  fee,
207
- type: isLong ? "OPEN_LONG" : "OPEN_SHORT"
698
+ type: isLong ? "OPEN_LONG" : "OPEN_SHORT",
699
+ ...getExecutionSlippageLogData(entrySlippageBreakdown, "entry")
208
700
  });
209
701
  return true;
210
702
  },
@@ -231,14 +723,42 @@ var createTestConnector = (connector, context) => {
231
723
  return false;
232
724
  }
233
725
  const isLong = currentPosition.direction === "LONG";
234
- const profit = isLong ? (order.price - currentPosition.price) * currentPosition.qty : (currentPosition.price - order.price) * currentPosition.qty;
726
+ const slippageBreakdown = getExecutionSlippageBreakdown({
727
+ stage: "exit",
728
+ signal: currentPosition.signal
729
+ });
730
+ const executionPrice = applyExecutionSlippage({
731
+ price: order.price,
732
+ direction: currentPosition.direction,
733
+ stage: "exit",
734
+ signal: currentPosition.signal
735
+ });
736
+ const grossProfit = isLong ? (executionPrice - currentPosition.price) * currentPosition.qty : (currentPosition.price - executionPrice) * currentPosition.qty;
737
+ const { fee, profit } = getNetProfit({
738
+ grossProfit,
739
+ price: executionPrice,
740
+ qty: currentPosition.qty
741
+ });
742
+ recordExitResult({
743
+ timestamp: order.timestamp,
744
+ reason: "exit",
745
+ requestedPrice: order.price,
746
+ executionPrice,
747
+ qty: currentPosition.qty,
748
+ grossProfit,
749
+ fee,
750
+ slippageBreakdown
751
+ });
235
752
  amount += profit;
236
753
  currentPositionProfit += profit;
237
754
  logOrder({
238
755
  ...order,
756
+ price: executionPrice,
239
757
  qty: currentPosition.qty,
240
758
  profit,
241
- type: isLong ? "CLOSE_LONG" : "CLOSE_SHORT"
759
+ fee,
760
+ type: isLong ? "CLOSE_LONG" : "CLOSE_SHORT",
761
+ ...getExecutionSlippageLogData(slippageBreakdown, "exit")
242
762
  });
243
763
  clearPosition(order.timestamp);
244
764
  return true;
@@ -249,10 +769,200 @@ var createTestConnector = (connector, context) => {
249
769
  };
250
770
  };
251
771
 
772
+ // src/executionCosts.ts
773
+ import {
774
+ BACKTEST_BASE_SLIPPAGE_BPS,
775
+ BACKTEST_DELAY_RISK_MULTIPLIER,
776
+ BACKTEST_MARKET_IMPACT_BPS,
777
+ BACKTEST_SPREAD_SLIPPAGE_MULTIPLIER,
778
+ FEE_PERCENT as FEE_PERCENT2
779
+ } from "@tradejs/core/constants";
780
+ var finiteOr = (value, fallback) => {
781
+ const parsed = Number(value);
782
+ return Number.isFinite(parsed) ? parsed : fallback;
783
+ };
784
+ var feeCache = /* @__PURE__ */ new WeakMap();
785
+ var fundingCache = /* @__PURE__ */ new WeakMap();
786
+ var loadTradingFee = async (connector, symbol) => {
787
+ if (!connector.getTradingFeeRate) return null;
788
+ let cache = feeCache.get(connector);
789
+ if (!cache) {
790
+ cache = /* @__PURE__ */ new Map();
791
+ feeCache.set(connector, cache);
792
+ }
793
+ const key = symbol.toUpperCase();
794
+ const cached = cache.get(key);
795
+ if (cached) return cached;
796
+ const rate = await connector.getTradingFeeRate(symbol);
797
+ cache.set(key, rate);
798
+ return rate;
799
+ };
800
+ var loadFundingRates = (connector, symbol, startTime, endTime) => {
801
+ if (!connector.getFundingRateHistory) return Promise.resolve([]);
802
+ let cache = fundingCache.get(connector);
803
+ if (!cache) {
804
+ cache = /* @__PURE__ */ new Map();
805
+ fundingCache.set(connector, cache);
806
+ }
807
+ const key = `${symbol.toUpperCase()}:${startTime}:${endTime}`;
808
+ const cached = cache.get(key);
809
+ if (cached) return cached;
810
+ const pending = connector.getFundingRateHistory({ symbol, startTime, endTime }).catch(() => []);
811
+ cache.set(key, pending);
812
+ return pending;
813
+ };
814
+ var resolveExecutionCosts = async (params) => {
815
+ const { connector, symbol, config, startTime, endTime, instrument } = params;
816
+ const cacheOnly = config.EXECUTION_COSTS_CACHE_ONLY === true;
817
+ const hasConfiguredFees = Number.isFinite(Number(config.MAKER_FEE_RATE)) && Number.isFinite(Number(config.TAKER_FEE_RATE));
818
+ const exchangeFees = !cacheOnly && !hasConfiguredFees && connector.getTradingFeeRate ? await loadTradingFee(connector, symbol).catch(() => null) : null;
819
+ const makerRate = hasConfiguredFees ? Number(config.MAKER_FEE_RATE) : exchangeFees?.makerRate ?? FEE_PERCENT2;
820
+ const takerRate = hasConfiguredFees ? Number(config.TAKER_FEE_RATE) : exchangeFees?.takerRate ?? FEE_PERCENT2;
821
+ const fundingEnabled = config.FUNDING_ENABLED !== false && !cacheOnly && typeof connector.getFundingRateHistory === "function";
822
+ const fundingRates = fundingEnabled ? await loadFundingRates(connector, symbol, startTime, endTime) : [];
823
+ const requestedLeverage = Math.max(1, finiteOr(config.LEVERAGE, 10));
824
+ const venueMaxLeverage = Number(instrument?.venueMetadata?.maxLeverage);
825
+ const maxAllowed = Number.isFinite(venueMaxLeverage) ? venueMaxLeverage : null;
826
+ const effectiveLeverage = maxAllowed == null ? requestedLeverage : Math.min(requestedLeverage, maxAllowed);
827
+ const feeSource = hasConfiguredFees ? "config" : exchangeFees?.source ?? "fallback";
828
+ const fundingSource = !fundingEnabled ? cacheOnly ? "fallback" : "disabled" : fundingRates.length ? "historical" : "unavailable";
829
+ const usesFallback = feeSource === "fallback" || fundingEnabled && fundingSource === "unavailable" || config.SLIPPAGE_BASE_BPS == null && config.SLIPPAGE_SPREAD_MULTIPLIER == null && config.SLIPPAGE_MARKET_IMPACT_BPS == null;
830
+ return {
831
+ model: {
832
+ fees: { makerRate, takerRate, source: feeSource },
833
+ funding: {
834
+ enabled: fundingEnabled,
835
+ source: fundingSource,
836
+ points: fundingRates.length,
837
+ fromTimestamp: fundingRates[0]?.timestamp ?? null,
838
+ toTimestamp: fundingRates.at(-1)?.timestamp ?? null
839
+ },
840
+ slippage: {
841
+ baseBps: finiteOr(config.SLIPPAGE_BASE_BPS, BACKTEST_BASE_SLIPPAGE_BPS),
842
+ spreadMultiplier: finiteOr(
843
+ config.SLIPPAGE_SPREAD_MULTIPLIER,
844
+ BACKTEST_SPREAD_SLIPPAGE_MULTIPLIER
845
+ ),
846
+ marketImpactBps: finiteOr(
847
+ config.SLIPPAGE_MARKET_IMPACT_BPS,
848
+ BACKTEST_MARKET_IMPACT_BPS
849
+ ),
850
+ delayRiskMultiplier: finiteOr(
851
+ config.SLIPPAGE_DELAY_RISK_MULTIPLIER,
852
+ BACKTEST_DELAY_RISK_MULTIPLIER
853
+ ),
854
+ source: config.SLIPPAGE_BASE_BPS != null || config.SLIPPAGE_SPREAD_MULTIPLIER != null || config.SLIPPAGE_MARKET_IMPACT_BPS != null ? "config" : "fallback"
855
+ },
856
+ leverage: {
857
+ requested: requestedLeverage,
858
+ effective: effectiveLeverage,
859
+ maxAllowed
860
+ },
861
+ quality: usesFallback ? "fallback" : fundingEnabled && fundingRates.length ? "full" : "partial",
862
+ capturedAt: Date.now()
863
+ },
864
+ fundingRates
865
+ };
866
+ };
867
+
252
868
  // src/testing.ts
869
+ var isBacktestEntryDelayControlCode = (value) => typeof value === "string" && value.startsWith("BACKTEST_ENTRY_DELAY_");
870
+ var CLOSED_RESULT_FLUSH_INTERVAL = 500;
871
+ var DEFAULT_STRATEGY_CANDLE_TIMEOUT_MS = 6e4;
872
+ var resolvePositiveInt = (value, fallback) => {
873
+ const parsed = parseInt(String(value ?? ""), 10);
874
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
875
+ };
876
+ var getStrategyCandleTimeoutMs = () => resolvePositiveInt(
877
+ process.env.BACKTEST_STRATEGY_CANDLE_TIMEOUT_MS,
878
+ DEFAULT_STRATEGY_CANDLE_TIMEOUT_MS
879
+ );
880
+ var getEffectiveTimeoutMs = (baseTimeoutMs, stageTimeoutMs) => {
881
+ const base = baseTimeoutMs && baseTimeoutMs > 0 ? Math.trunc(baseTimeoutMs) : null;
882
+ const stage = stageTimeoutMs && stageTimeoutMs > 0 ? Math.trunc(stageTimeoutMs) : null;
883
+ if (base == null) return stage;
884
+ if (stage == null) return base;
885
+ return Math.min(base, stage);
886
+ };
887
+ var buildCandleByTimestamp = (candles) => new Map(
888
+ (candles ?? []).filter((candle) => typeof candle?.timestamp === "number").map((candle) => [candle.timestamp, candle])
889
+ );
890
+ var buildBacktestDatasetMetadata = ({
891
+ backtestRunId,
892
+ backtestTestKey,
893
+ chunkId
894
+ }) => {
895
+ if (!backtestRunId || !backtestTestKey || !chunkId) {
896
+ return {};
897
+ }
898
+ return {
899
+ backtestRunId,
900
+ backtestTestKey,
901
+ backtestChunkId: chunkId
902
+ };
903
+ };
904
+ var cloneAiPayloadSignal = (signal) => {
905
+ const cloneValue = (value) => {
906
+ if (value == null) {
907
+ return value;
908
+ }
909
+ if (typeof structuredClone === "function") {
910
+ return structuredClone(value);
911
+ }
912
+ return JSON.parse(JSON.stringify(value));
913
+ };
914
+ return {
915
+ ...signal,
916
+ figures: cloneValue(signal.figures),
917
+ indicators: cloneValue(signal.indicators),
918
+ additionalIndicators: cloneValue(signal.additionalIndicators)
919
+ };
920
+ };
921
+ var buildReplaySignalEvaluationRecord = ({
922
+ signal,
923
+ testId,
924
+ userName,
925
+ strategyName,
926
+ symbol,
927
+ interval,
928
+ candle
929
+ }) => {
930
+ if (!signal || typeof signal === "string") {
931
+ return {
932
+ evaluationId: `${testId}:${strategyName}:${symbol}:${candle.timestamp}`,
933
+ userName,
934
+ strategy: strategyName,
935
+ symbol,
936
+ interval,
937
+ timestamp: candle.timestamp,
938
+ evaluatedAt: candle.timestamp,
939
+ status: "skip",
940
+ reason: typeof signal === "string" && signal.trim() ? signal : "NO_SIGNAL"
941
+ };
942
+ }
943
+ const signalTimestamp = typeof signal.timestamp === "number" && Number.isFinite(signal.timestamp) ? signal.timestamp : candle.timestamp;
944
+ return {
945
+ evaluationId: `${signal.signalId || testId}:${strategyName}:${symbol}:${signalTimestamp}`,
946
+ userName,
947
+ strategy: signal.strategy || strategyName,
948
+ symbol: signal.symbol || symbol,
949
+ interval: signal.interval || interval,
950
+ timestamp: signalTimestamp,
951
+ evaluatedAt: candle.timestamp,
952
+ status: "signal",
953
+ reason: signal.orderSkipReason || signal.orderStatus,
954
+ signalId: signal.signalId,
955
+ direction: signal.direction,
956
+ orderStatus: signal.orderStatus,
957
+ orderSkipReason: signal.orderSkipReason,
958
+ aiAnalysis: signal.aiAnalysis ?? null,
959
+ ml: signal.ml
960
+ };
961
+ };
253
962
  var createTestingKlineCacheState = () => ({
254
963
  coinKlineCache: /* @__PURE__ */ new Map(),
255
964
  btcKlineCache: /* @__PURE__ */ new Map(),
965
+ ethKlineCache: /* @__PURE__ */ new Map(),
256
966
  btcBinanceKlineCache: /* @__PURE__ */ new Map(),
257
967
  btcCoinbaseKlineCache: /* @__PURE__ */ new Map(),
258
968
  preparedDataCache: /* @__PURE__ */ new Map(),
@@ -279,11 +989,15 @@ var getKlineCacheKey = (params) => {
279
989
  preloadStart,
280
990
  end,
281
991
  interval,
282
- cacheOnly
992
+ cacheOnly,
993
+ universe,
994
+ accountId
283
995
  } = params;
284
996
  return [
285
997
  userName,
286
998
  connectorName,
999
+ universe ?? "crypto",
1000
+ accountId ?? "default",
287
1001
  symbol,
288
1002
  preloadStart,
289
1003
  end,
@@ -301,21 +1015,47 @@ var getPreparedDataCacheKey = (params) => {
301
1015
  end,
302
1016
  interval,
303
1017
  btcBinanceConnectorName,
304
- btcCoinbaseConnectorName
1018
+ btcCoinbaseConnectorName,
1019
+ backtestExecutionInterval,
1020
+ universe,
1021
+ accountId
305
1022
  } = params;
306
1023
  return [
307
1024
  userName,
308
1025
  connectorName,
1026
+ universe ?? "crypto",
1027
+ accountId ?? "default",
309
1028
  symbol,
310
1029
  preloadStart,
311
1030
  start,
312
1031
  end,
313
1032
  interval,
1033
+ backtestExecutionInterval,
314
1034
  btcBinanceConnectorName,
315
1035
  btcCoinbaseConnectorName
316
1036
  ].join(":");
317
1037
  };
318
- var getConnectorCacheKey = (params) => [params.userName, params.connectorName].join(":");
1038
+ var getConnectorCacheKey = (params) => [
1039
+ params.userName,
1040
+ params.connectorName,
1041
+ params.universe ?? "crypto",
1042
+ params.accountId ?? "default"
1043
+ ].join(":");
1044
+ var BACKTEST_INTERVAL = "15";
1045
+ var resolveBacktestExecutionInterval = (interval) => {
1046
+ const normalized = String(interval);
1047
+ if (normalized === "15") {
1048
+ return BACKTEST_EXECUTION_INTERVAL;
1049
+ }
1050
+ if (normalized === "60") {
1051
+ return "15";
1052
+ }
1053
+ return null;
1054
+ };
1055
+ var resolveIntervalMs = (interval) => {
1056
+ const intervalMinutes = Number(interval);
1057
+ return Number.isFinite(intervalMinutes) && intervalMinutes > 0 ? intervalMinutes * 6e4 : Number(BACKTEST_INTERVAL) * 6e4;
1058
+ };
319
1059
  var splitCandlesForTesting = (candles, start, preloadStart) => {
320
1060
  const prevData = [];
321
1061
  const testData = [];
@@ -329,9 +1069,53 @@ var splitCandlesForTesting = (candles, start, preloadStart) => {
329
1069
  }
330
1070
  return { prevData, testData };
331
1071
  };
1072
+ var getCurrentOpenTimestamp = (interval) => {
1073
+ const intervalMs = resolveIntervalMs(interval);
1074
+ return Math.floor(Date.now() / intervalMs) * intervalMs;
1075
+ };
1076
+ var filterClosedAlignedCandles = (data, btcData, interval) => {
1077
+ const currentOpenTimestamp = getCurrentOpenTimestamp(interval);
1078
+ const closedData = [];
1079
+ const closedBtcData = [];
1080
+ for (let index = 0; index < data.length; index += 1) {
1081
+ const candle = data[index];
1082
+ const btcCandle = btcData[index];
1083
+ if (!candle || !btcCandle || candle.timestamp >= currentOpenTimestamp) {
1084
+ continue;
1085
+ }
1086
+ closedData.push(candle);
1087
+ closedBtcData.push(btcCandle);
1088
+ }
1089
+ return {
1090
+ data: closedData,
1091
+ btcData: closedBtcData
1092
+ };
1093
+ };
1094
+ var shouldLoadBacktestExecutionCandles = (interval) => {
1095
+ const executionInterval = resolveBacktestExecutionInterval(interval);
1096
+ if (!executionInterval) {
1097
+ return false;
1098
+ }
1099
+ const primaryMs = resolveIntervalMs(interval);
1100
+ const executionMs = resolveIntervalMs(executionInterval);
1101
+ return executionMs > 0 && executionMs < primaryMs;
1102
+ };
332
1103
  var getCachedConnector = async (params) => {
333
- const { state, projectRoot, userName, connectorName } = params;
334
- const cacheKey = getConnectorCacheKey({ userName, connectorName });
1104
+ const {
1105
+ state,
1106
+ projectRoot,
1107
+ userName,
1108
+ connectorName,
1109
+ universe,
1110
+ accountId,
1111
+ deploymentId
1112
+ } = params;
1113
+ const cacheKey = getConnectorCacheKey({
1114
+ userName,
1115
+ connectorName,
1116
+ universe,
1117
+ accountId
1118
+ });
335
1119
  const cachedConnector = state.connectorCache.get(cacheKey);
336
1120
  if (cachedConnector) {
337
1121
  return cachedConnector;
@@ -344,7 +1128,10 @@ var getCachedConnector = async (params) => {
344
1128
  return void 0;
345
1129
  }
346
1130
  const connector = await connectorCreator({
347
- userName
1131
+ userName,
1132
+ universe,
1133
+ accountId,
1134
+ deploymentId
348
1135
  });
349
1136
  state.connectorCache.set(cacheKey, connector);
350
1137
  return connector;
@@ -359,107 +1146,64 @@ var resetTestingKlineCache = (cwd) => {
359
1146
  getTradejsProjectCwd(normalizedCwd)
360
1147
  );
361
1148
  };
362
- var testing = async ({
363
- userName,
364
- symbol,
365
- options: { start, end },
366
- name,
367
- testId,
368
- testSuiteId,
369
- strategyName,
370
- strategyConfig,
371
- connectorName,
372
- ml = false,
373
- ai = false,
374
- chunkId = "single",
375
- timeoutMs
376
- }) => {
377
- if (!start) {
378
- throw new Error("no start");
379
- }
380
- const preloadStart = getBacktestPreloadStart(start);
381
- const startedAt = Date.now();
382
- const formatTimeoutMessage = (stage) => `Test ${name} (${symbol}) timed out after ${timeoutMs}ms during ${stage}`;
383
- const getRemainingTimeoutMs = (stage) => {
384
- if (!timeoutMs || timeoutMs <= 0) {
385
- return null;
386
- }
387
- const remainingMs = timeoutMs - (Date.now() - startedAt);
388
- if (remainingMs <= 0) {
389
- throw new Error(formatTimeoutMessage(stage));
390
- }
391
- return remainingMs;
392
- };
393
- const throwIfTimedOut = (stage) => {
394
- getRemainingTimeoutMs(stage);
395
- };
396
- const withTimeout = async (stage, promise) => {
397
- const remainingMs = getRemainingTimeoutMs(stage);
398
- if (remainingMs == null) {
399
- return promise;
1149
+ var releaseTestingSymbolCache = (params) => {
1150
+ const { cwd, userName, connectorName, symbol } = params;
1151
+ const { state } = getTestingKlineCacheState(cwd);
1152
+ const connectorPrefix = [userName, connectorName].join(":") + ":";
1153
+ for (const cache of [state.coinKlineCache, state.preparedDataCache]) {
1154
+ for (const key of cache.keys()) {
1155
+ if (key.startsWith(connectorPrefix) && key.split(":").includes(symbol)) {
1156
+ cache.delete(key);
1157
+ }
400
1158
  }
401
- return await new Promise((resolve, reject) => {
402
- const timer = setTimeout(() => {
403
- reject(new Error(formatTimeoutMessage(stage)));
404
- }, remainingMs);
405
- promise.then(
406
- (value) => {
407
- clearTimeout(timer);
408
- resolve(value);
409
- },
410
- (error) => {
411
- clearTimeout(timer);
412
- reject(error);
413
- }
414
- );
415
- });
416
- };
417
- const { projectRoot, state } = getTestingKlineCacheState();
418
- const connector = await withTimeout(
419
- "connector init",
420
- getCachedConnector({
421
- state,
422
- projectRoot,
423
- userName,
424
- connectorName
425
- })
426
- );
1159
+ }
1160
+ };
1161
+ var prepareTestingData = async (params) => {
1162
+ const {
1163
+ state,
1164
+ projectRoot,
1165
+ userName,
1166
+ connectorName,
1167
+ symbol,
1168
+ preloadStart,
1169
+ start,
1170
+ end,
1171
+ interval,
1172
+ universe = "crypto",
1173
+ accountId,
1174
+ deploymentId
1175
+ } = params;
1176
+ const binanceConnector = universe === "crypto" ? await getCachedConnector({
1177
+ state,
1178
+ projectRoot,
1179
+ userName,
1180
+ connectorName: BUILTIN_CONNECTOR_NAMES.Binance
1181
+ }) : void 0;
1182
+ const coinbaseConnector = universe === "crypto" ? await getCachedConnector({
1183
+ state,
1184
+ projectRoot,
1185
+ userName,
1186
+ connectorName: BUILTIN_CONNECTOR_NAMES.Coinbase
1187
+ }) : void 0;
1188
+ const cacheOnly = true;
1189
+ const connector = await getCachedConnector({
1190
+ state,
1191
+ projectRoot,
1192
+ userName,
1193
+ connectorName,
1194
+ universe,
1195
+ accountId,
1196
+ deploymentId
1197
+ });
427
1198
  if (!connector) {
428
1199
  throw new Error(`Unknown connector: ${connectorName}`);
429
1200
  }
430
- const strategyCreator = await withTimeout(
431
- "strategy lookup",
432
- getStrategyCreator(strategyName, projectRoot)
433
- );
434
- if (!strategyCreator) {
435
- throw new Error(`Unknown strategy: ${strategyName}`);
436
- }
437
- const binanceConnector = await withTimeout(
438
- "binance connector init",
439
- getCachedConnector({
440
- state,
441
- projectRoot,
442
- userName,
443
- connectorName: BUILTIN_CONNECTOR_NAMES.Binance
444
- })
445
- );
446
- const coinbaseConnector = await withTimeout(
447
- "coinbase connector init",
448
- getCachedConnector({
449
- state,
450
- projectRoot,
451
- userName,
452
- connectorName: BUILTIN_CONNECTOR_NAMES.Coinbase
453
- })
454
- );
455
1201
  if (!binanceConnector || !coinbaseConnector) {
456
1202
  logger.warn(
457
1203
  "Binance/Coinbase connectors are unavailable. Reusing %s for BTC references.",
458
1204
  connectorName
459
1205
  );
460
1206
  }
461
- const interval = "15";
462
- const cacheOnly = true;
463
1207
  const coinCacheKey = getKlineCacheKey({
464
1208
  userName,
465
1209
  connectorName,
@@ -467,7 +1211,9 @@ var testing = async ({
467
1211
  preloadStart,
468
1212
  end,
469
1213
  interval,
470
- cacheOnly
1214
+ cacheOnly,
1215
+ universe,
1216
+ accountId
471
1217
  });
472
1218
  const btcCacheKey = getKlineCacheKey({
473
1219
  userName,
@@ -476,12 +1222,23 @@ var testing = async ({
476
1222
  preloadStart,
477
1223
  end,
478
1224
  interval,
479
- cacheOnly
1225
+ cacheOnly,
1226
+ universe,
1227
+ accountId
1228
+ });
1229
+ const ethCacheKey = getKlineCacheKey({
1230
+ userName,
1231
+ connectorName,
1232
+ symbol: "ETHUSDT",
1233
+ preloadStart,
1234
+ end,
1235
+ interval,
1236
+ cacheOnly,
1237
+ universe,
1238
+ accountId
480
1239
  });
481
1240
  const btcBinanceConnectorName = binanceConnector ? BUILTIN_CONNECTOR_NAMES.Binance : connectorName;
482
1241
  const btcCoinbaseConnectorName = coinbaseConnector ? BUILTIN_CONNECTOR_NAMES.Coinbase : connectorName;
483
- const cachedCoinData = state.coinKlineCache.get(coinCacheKey);
484
- const cachedBtcData = state.btcKlineCache.get(btcCacheKey);
485
1242
  const btcBinanceCacheKey = getKlineCacheKey({
486
1243
  userName,
487
1244
  connectorName: btcBinanceConnectorName,
@@ -489,7 +1246,8 @@ var testing = async ({
489
1246
  preloadStart,
490
1247
  end,
491
1248
  interval,
492
- cacheOnly
1249
+ cacheOnly,
1250
+ universe: "crypto"
493
1251
  });
494
1252
  const btcCoinbaseCacheKey = getKlineCacheKey({
495
1253
  userName,
@@ -498,10 +1256,41 @@ var testing = async ({
498
1256
  preloadStart,
499
1257
  end,
500
1258
  interval,
501
- cacheOnly
1259
+ cacheOnly,
1260
+ universe: "crypto"
502
1261
  });
1262
+ const cachedCoinData = state.coinKlineCache.get(coinCacheKey);
1263
+ const cachedBtcData = state.btcKlineCache.get(btcCacheKey);
1264
+ const cachedEthData = state.ethKlineCache.get(ethCacheKey);
503
1265
  const cachedBtcBinanceData = state.btcBinanceKlineCache.get(btcBinanceCacheKey);
504
1266
  const cachedBtcCoinbaseData = state.btcCoinbaseKlineCache.get(btcCoinbaseCacheKey);
1267
+ const backtestExecutionInterval = resolveBacktestExecutionInterval(interval);
1268
+ const backtestExecutionCacheInterval = backtestExecutionInterval ?? interval;
1269
+ const shouldLoadExecutionCandles = backtestExecutionInterval != null && shouldLoadBacktestExecutionCandles(interval);
1270
+ const executionCoinCacheKey = getKlineCacheKey({
1271
+ userName,
1272
+ connectorName,
1273
+ symbol,
1274
+ preloadStart,
1275
+ end,
1276
+ interval: backtestExecutionCacheInterval,
1277
+ cacheOnly,
1278
+ universe,
1279
+ accountId
1280
+ });
1281
+ const executionBtcCacheKey = getKlineCacheKey({
1282
+ userName,
1283
+ connectorName,
1284
+ symbol: "BTCUSDT",
1285
+ preloadStart,
1286
+ end,
1287
+ interval: backtestExecutionCacheInterval,
1288
+ cacheOnly,
1289
+ universe,
1290
+ accountId
1291
+ });
1292
+ const cachedExecutionCoinData = shouldLoadExecutionCandles ? state.coinKlineCache.get(executionCoinCacheKey) : void 0;
1293
+ const cachedExecutionBtcData = shouldLoadExecutionCandles ? state.btcKlineCache.get(executionBtcCacheKey) : void 0;
505
1294
  const preparedDataCacheKey = getPreparedDataCacheKey({
506
1295
  userName,
507
1296
  connectorName,
@@ -510,118 +1299,407 @@ var testing = async ({
510
1299
  start,
511
1300
  end,
512
1301
  interval,
1302
+ backtestExecutionInterval: backtestExecutionCacheInterval,
513
1303
  btcBinanceConnectorName,
514
- btcCoinbaseConnectorName
1304
+ btcCoinbaseConnectorName,
1305
+ universe,
1306
+ accountId
515
1307
  });
516
- let preparedData = state.preparedDataCache.get(preparedDataCacheKey);
517
- if (!preparedData) {
518
- const [data, btcData, btcBinanceData, btcCoinbaseData] = await withTimeout(
519
- "kline preload",
520
- Promise.all([
521
- cachedCoinData ? Promise.resolve(cachedCoinData) : connector.kline({
522
- symbol,
523
- start: preloadStart,
524
- end,
525
- interval,
526
- silent: true,
527
- cacheOnly
528
- }),
529
- cachedBtcData ? Promise.resolve(cachedBtcData) : connector.kline({
530
- symbol: "BTCUSDT",
531
- start: preloadStart,
532
- end,
533
- interval,
534
- silent: true,
535
- cacheOnly
536
- }),
537
- cachedBtcBinanceData ? Promise.resolve(cachedBtcBinanceData) : (binanceConnector ?? connector).kline({
538
- symbol: "BTCUSDT",
539
- start: preloadStart,
540
- end,
541
- interval,
542
- silent: true,
543
- cacheOnly
544
- }),
545
- cachedBtcCoinbaseData ? Promise.resolve(cachedBtcCoinbaseData) : (coinbaseConnector ?? connector).kline({
546
- symbol: "BTCUSDT",
547
- start: preloadStart,
548
- end,
549
- interval,
550
- silent: true,
551
- cacheOnly
552
- })
553
- ])
554
- );
555
- if (!cachedCoinData) {
556
- state.coinKlineCache.set(coinCacheKey, data);
557
- }
558
- if (!cachedBtcData) {
559
- state.btcKlineCache.set(btcCacheKey, btcData);
1308
+ const cachedPreparedData = state.preparedDataCache.get(preparedDataCacheKey);
1309
+ if (cachedPreparedData) {
1310
+ return cachedPreparedData;
1311
+ }
1312
+ const btcDataPromise = universe === "tradfi" ? Promise.resolve([]) : cachedBtcData ? Promise.resolve(cachedBtcData) : connector.kline({
1313
+ symbol: "BTCUSDT",
1314
+ start: preloadStart,
1315
+ end,
1316
+ interval,
1317
+ silent: true,
1318
+ cacheOnly
1319
+ });
1320
+ const ethDataPromise = universe === "tradfi" ? Promise.resolve([]) : cachedEthData ? Promise.resolve(cachedEthData) : connector.kline({
1321
+ symbol: "ETHUSDT",
1322
+ start: preloadStart,
1323
+ end,
1324
+ interval,
1325
+ silent: true,
1326
+ cacheOnly
1327
+ });
1328
+ const btcBinanceDataPromise = universe === "tradfi" ? Promise.resolve([]) : cachedBtcBinanceData ? Promise.resolve(cachedBtcBinanceData) : btcBinanceCacheKey === btcCacheKey ? btcDataPromise : (binanceConnector ?? connector).kline({
1329
+ symbol: "BTCUSDT",
1330
+ start: preloadStart,
1331
+ end,
1332
+ interval,
1333
+ silent: true,
1334
+ cacheOnly
1335
+ });
1336
+ const btcCoinbaseDataPromise = universe === "tradfi" ? Promise.resolve([]) : cachedBtcCoinbaseData ? Promise.resolve(cachedBtcCoinbaseData) : btcCoinbaseCacheKey === btcCacheKey ? btcDataPromise : (coinbaseConnector ?? connector).kline({
1337
+ symbol: "BTCUSDT",
1338
+ start: preloadStart,
1339
+ end,
1340
+ interval,
1341
+ silent: true,
1342
+ cacheOnly
1343
+ });
1344
+ const executionDataPromise = !shouldLoadExecutionCandles ? Promise.resolve([]) : cachedExecutionCoinData ? Promise.resolve(cachedExecutionCoinData) : connector.kline({
1345
+ symbol,
1346
+ start: preloadStart,
1347
+ end,
1348
+ interval: backtestExecutionInterval,
1349
+ silent: true,
1350
+ cacheOnly
1351
+ });
1352
+ const executionBtcDataPromise = universe === "tradfi" || !shouldLoadExecutionCandles ? Promise.resolve([]) : cachedExecutionBtcData ? Promise.resolve(cachedExecutionBtcData) : connector.kline({
1353
+ symbol: "BTCUSDT",
1354
+ start: preloadStart,
1355
+ end,
1356
+ interval: backtestExecutionInterval,
1357
+ silent: true,
1358
+ cacheOnly
1359
+ });
1360
+ const [
1361
+ dataRaw,
1362
+ btcDataRaw,
1363
+ ethDataRaw,
1364
+ btcBinanceDataRaw,
1365
+ btcCoinbaseDataRaw,
1366
+ executionDataRaw,
1367
+ executionBtcDataRaw
1368
+ ] = await Promise.all([
1369
+ cachedCoinData ? Promise.resolve(cachedCoinData) : connector.kline({
1370
+ symbol,
1371
+ start: preloadStart,
1372
+ end,
1373
+ interval,
1374
+ silent: true,
1375
+ cacheOnly
1376
+ }),
1377
+ btcDataPromise,
1378
+ ethDataPromise,
1379
+ btcBinanceDataPromise,
1380
+ btcCoinbaseDataPromise,
1381
+ executionDataPromise,
1382
+ executionBtcDataPromise
1383
+ ]);
1384
+ if (!cachedCoinData) {
1385
+ state.coinKlineCache.set(coinCacheKey, dataRaw);
1386
+ }
1387
+ if (!cachedBtcData) {
1388
+ state.btcKlineCache.set(btcCacheKey, btcDataRaw);
1389
+ }
1390
+ if (!cachedEthData) {
1391
+ state.ethKlineCache.set(ethCacheKey, ethDataRaw);
1392
+ }
1393
+ if (!cachedBtcBinanceData) {
1394
+ state.btcBinanceKlineCache.set(btcBinanceCacheKey, btcBinanceDataRaw);
1395
+ }
1396
+ if (!cachedBtcCoinbaseData) {
1397
+ state.btcCoinbaseKlineCache.set(btcCoinbaseCacheKey, btcCoinbaseDataRaw);
1398
+ }
1399
+ if (shouldLoadExecutionCandles && !cachedExecutionCoinData) {
1400
+ state.coinKlineCache.set(executionCoinCacheKey, executionDataRaw);
1401
+ }
1402
+ if (shouldLoadExecutionCandles && !cachedExecutionBtcData) {
1403
+ state.btcKlineCache.set(executionBtcCacheKey, executionBtcDataRaw);
1404
+ }
1405
+ const aligned = universe === "tradfi" ? { alignedCoinCandles: dataRaw, alignedBtcCandles: dataRaw } : alignSortedCandlesByTimestamp(dataRaw, btcDataRaw);
1406
+ const { data, btcData } = filterClosedAlignedCandles(
1407
+ aligned.alignedCoinCandles,
1408
+ aligned.alignedBtcCandles,
1409
+ interval
1410
+ );
1411
+ const btcBinanceData = universe === "tradfi" ? [] : alignSortedCandlesByTimestamp(data, btcBinanceDataRaw).alignedBtcCandles;
1412
+ const btcCoinbaseData = universe === "tradfi" ? [] : alignSortedCandlesByTimestamp(data, btcCoinbaseDataRaw).alignedBtcCandles;
1413
+ const ethData = universe === "tradfi" ? [] : alignSortedCandlesByTimestamp(data, ethDataRaw).alignedBtcCandles;
1414
+ const alignedExecution = shouldLoadExecutionCandles ? universe === "tradfi" ? {
1415
+ alignedCoinCandles: executionDataRaw,
1416
+ alignedBtcCandles: executionDataRaw
1417
+ } : alignSortedCandlesByTimestamp(executionDataRaw, executionBtcDataRaw) : { alignedCoinCandles: [], alignedBtcCandles: [] };
1418
+ const { data: backtestExecutionData, btcData: backtestExecutionBtcData } = filterClosedAlignedCandles(
1419
+ alignedExecution.alignedCoinCandles,
1420
+ alignedExecution.alignedBtcCandles,
1421
+ backtestExecutionCacheInterval
1422
+ );
1423
+ const backtestExecutionDataByTimestamp = buildCandleByTimestamp(
1424
+ backtestExecutionData
1425
+ );
1426
+ const backtestExecutionBtcDataByTimestamp = buildCandleByTimestamp(
1427
+ backtestExecutionBtcData
1428
+ );
1429
+ const { prevData, testData } = splitCandlesForTesting(
1430
+ data,
1431
+ start,
1432
+ preloadStart
1433
+ );
1434
+ const { prevData: btcPrevData, testData: btcTestData } = splitCandlesForTesting(btcData, start, preloadStart);
1435
+ const { prevData: ethPrevData, testData: ethTestData } = splitCandlesForTesting(ethData, start, preloadStart);
1436
+ const preparedData = {
1437
+ data,
1438
+ btcData,
1439
+ ethData,
1440
+ prevData,
1441
+ btcPrevData,
1442
+ ethPrevData,
1443
+ testData,
1444
+ btcTestData,
1445
+ ethTestData,
1446
+ btcBinanceData,
1447
+ btcCoinbaseData,
1448
+ backtestExecutionInterval: backtestExecutionCacheInterval,
1449
+ backtestExecutionData,
1450
+ backtestExecutionBtcData,
1451
+ backtestExecutionDataByTimestamp,
1452
+ backtestExecutionBtcDataByTimestamp
1453
+ };
1454
+ state.preparedDataCache.set(preparedDataCacheKey, preparedData);
1455
+ return preparedData;
1456
+ };
1457
+ var canRunTestsInSharedCandleLoop = (tests) => {
1458
+ if (tests.length <= 1) {
1459
+ return false;
1460
+ }
1461
+ const first = tests[0];
1462
+ const firstStart = first.options?.start;
1463
+ const firstEnd = first.options?.end;
1464
+ return tests.every(
1465
+ (test) => test.userName === first.userName && test.connectorName === first.connectorName && (test.universe ?? "crypto") === (first.universe ?? "crypto") && (test.accountId ?? null) === (first.accountId ?? null) && (test.deploymentId ?? null) === (first.deploymentId ?? null) && test.symbol === first.symbol && test.strategyName === first.strategyName && test.options?.start === firstStart && test.options?.end === firstEnd && Boolean(test.ml) === Boolean(first.ml) && Boolean(test.ai) === Boolean(first.ai) && Boolean(test.fast) === Boolean(first.fast) && Boolean(test.collectReplaySignalEvaluations) === Boolean(first.collectReplaySignalEvaluations) && (test.interval ?? BACKTEST_INTERVAL) === (first.interval ?? BACKTEST_INTERVAL) && (test.timeoutMs ?? null) === (first.timeoutMs ?? null)
1466
+ );
1467
+ };
1468
+ var testing = async ({
1469
+ userName,
1470
+ symbol,
1471
+ options: { start, end },
1472
+ name,
1473
+ testId,
1474
+ testSuiteId,
1475
+ configId,
1476
+ strategyName,
1477
+ strategyConfig,
1478
+ connectorName,
1479
+ universe = "crypto",
1480
+ assetClass,
1481
+ instrument: requestedInstrument,
1482
+ accountId,
1483
+ deploymentId,
1484
+ policyProfileId,
1485
+ interval = BACKTEST_INTERVAL,
1486
+ ml = false,
1487
+ ai = false,
1488
+ fast = false,
1489
+ collectReplaySignalEvaluations = false,
1490
+ chunkId = "single",
1491
+ backtestRunId,
1492
+ backtestTestKey,
1493
+ timeoutMs
1494
+ }) => {
1495
+ if (!start) {
1496
+ throw new Error("no start");
1497
+ }
1498
+ const preloadStart = getBacktestPreloadStart(start);
1499
+ const startedAt = Date.now();
1500
+ let activeStageStartedAt = startedAt;
1501
+ let lastProgressSentAt = 0;
1502
+ let lastProgressSignature = "";
1503
+ let currentCandleIndex = 0;
1504
+ let totalCandles = 0;
1505
+ const strategyCandleTimeoutMs = getStrategyCandleTimeoutMs();
1506
+ const formatTimeoutMessage = (stage, stageTimeoutMs) => `Test ${name} (${symbol}) timed out after ${stageTimeoutMs}ms during ${stage}`;
1507
+ const emitProgress = (stage, options = {}) => {
1508
+ const now = Date.now();
1509
+ const candleIndex = typeof options.candleIndex === "number" ? options.candleIndex : currentCandleIndex;
1510
+ const candleTotal = typeof options.candleTotal === "number" ? options.candleTotal : totalCandles;
1511
+ const signature = [
1512
+ stage,
1513
+ candleIndex,
1514
+ candleTotal,
1515
+ Math.floor((now - activeStageStartedAt) / 5e3)
1516
+ ].join(":");
1517
+ if (!options.force) {
1518
+ if (signature === lastProgressSignature) {
1519
+ return;
1520
+ }
1521
+ if (now - lastProgressSentAt < 4e3) {
1522
+ return;
1523
+ }
560
1524
  }
561
- if (!cachedBtcBinanceData) {
562
- state.btcBinanceKlineCache.set(btcBinanceCacheKey, btcBinanceData);
1525
+ lastProgressSentAt = now;
1526
+ lastProgressSignature = signature;
1527
+ process.send?.({
1528
+ progress: true,
1529
+ testName: name,
1530
+ symbol,
1531
+ strategyName,
1532
+ stage,
1533
+ candleIndex,
1534
+ candleTotal,
1535
+ elapsedMs: now - startedAt,
1536
+ stageElapsedMs: now - activeStageStartedAt
1537
+ });
1538
+ };
1539
+ const getStageTimeoutMs = () => {
1540
+ if (!timeoutMs || timeoutMs <= 0) {
1541
+ return null;
563
1542
  }
564
- if (!cachedBtcCoinbaseData) {
565
- state.btcCoinbaseKlineCache.set(btcCoinbaseCacheKey, btcCoinbaseData);
1543
+ return timeoutMs;
1544
+ };
1545
+ const throwIfTimedOut = (stage) => {
1546
+ if (getStageTimeoutMs() == null) {
1547
+ return;
566
1548
  }
567
- const { prevData: prevDataRaw, testData: testDataRaw } = splitCandlesForTesting(data, start, preloadStart);
568
- const { prevData: btcPrevDataRaw, testData: btcTestDataRaw } = splitCandlesForTesting(btcData, start, preloadStart);
569
- const { prevData: btcBinancePrevDataRaw } = splitCandlesForTesting(
570
- btcBinanceData,
571
- start,
572
- preloadStart
573
- );
574
- const { prevData: btcCoinbasePrevDataRaw } = splitCandlesForTesting(
575
- btcCoinbaseData,
576
- start,
577
- preloadStart
1549
+ emitProgress(stage);
1550
+ };
1551
+ const withTimeout = async (stage, promise, stageTimeoutOverrideMs = null) => {
1552
+ const stageTimeoutMs = getEffectiveTimeoutMs(
1553
+ getStageTimeoutMs() ?? void 0,
1554
+ stageTimeoutOverrideMs
578
1555
  );
579
- const { alignedCoinCandles: prevData2, alignedBtcCandles: btcPrevData2 } = alignSortedCandlesByTimestamp(prevDataRaw, btcPrevDataRaw);
580
- const { alignedCoinCandles: testData2, alignedBtcCandles: btcTestData2 } = alignSortedCandlesByTimestamp(testDataRaw, btcTestDataRaw);
581
- const { alignedBtcCandles: btcBinancePrevData2 } = alignSortedCandlesByTimestamp(prevDataRaw, btcBinancePrevDataRaw);
582
- const { alignedBtcCandles: btcCoinbasePrevData2 } = alignSortedCandlesByTimestamp(prevDataRaw, btcCoinbasePrevDataRaw);
583
- preparedData = {
584
- prevData: prevData2,
585
- btcPrevData: btcPrevData2,
586
- testData: testData2,
587
- btcTestData: btcTestData2,
588
- btcBinancePrevData: btcBinancePrevData2,
589
- btcCoinbasePrevData: btcCoinbasePrevData2
590
- };
591
- state.preparedDataCache.set(preparedDataCacheKey, preparedData);
1556
+ if (stageTimeoutMs == null) {
1557
+ return promise;
1558
+ }
1559
+ activeStageStartedAt = Date.now();
1560
+ emitProgress(stage, { force: true });
1561
+ return await new Promise((resolve, reject) => {
1562
+ const heartbeat = setInterval(() => {
1563
+ emitProgress(stage);
1564
+ }, 5e3);
1565
+ const timer = setTimeout(() => {
1566
+ clearInterval(heartbeat);
1567
+ reject(new Error(formatTimeoutMessage(stage, stageTimeoutMs)));
1568
+ }, stageTimeoutMs);
1569
+ promise.then(
1570
+ (value) => {
1571
+ clearInterval(heartbeat);
1572
+ clearTimeout(timer);
1573
+ resolve(value);
1574
+ },
1575
+ (error) => {
1576
+ clearInterval(heartbeat);
1577
+ clearTimeout(timer);
1578
+ reject(error);
1579
+ }
1580
+ );
1581
+ });
1582
+ };
1583
+ const runStage = (stage, fn) => {
1584
+ if (getStageTimeoutMs() == null) {
1585
+ return fn();
1586
+ }
1587
+ return withTimeout(stage, fn());
1588
+ };
1589
+ const runStrategyCandleStage = (stage, fn) => withTimeout(stage, fn(), strategyCandleTimeoutMs);
1590
+ const { projectRoot, state } = getTestingKlineCacheState();
1591
+ const connector = await withTimeout(
1592
+ "connector init",
1593
+ getCachedConnector({
1594
+ state,
1595
+ projectRoot,
1596
+ userName,
1597
+ connectorName,
1598
+ universe,
1599
+ accountId,
1600
+ deploymentId
1601
+ })
1602
+ );
1603
+ if (!connector) {
1604
+ throw new Error(`Unknown connector: ${connectorName}`);
1605
+ }
1606
+ const strategyCreator = await withTimeout(
1607
+ "strategy lookup",
1608
+ getStrategyCreator(strategyName, projectRoot)
1609
+ );
1610
+ if (!strategyCreator) {
1611
+ throw new Error(`Unknown strategy: ${strategyName}`);
592
1612
  }
1613
+ const preparedData = await withTimeout(
1614
+ "kline preload",
1615
+ prepareTestingData({
1616
+ state,
1617
+ projectRoot,
1618
+ userName,
1619
+ connectorName,
1620
+ symbol,
1621
+ preloadStart,
1622
+ start,
1623
+ end,
1624
+ interval,
1625
+ universe,
1626
+ accountId,
1627
+ deploymentId
1628
+ })
1629
+ );
593
1630
  if (!preparedData) {
594
1631
  throw new Error("Prepared backtest data not available");
595
1632
  }
596
1633
  const {
597
1634
  prevData,
598
1635
  btcPrevData,
1636
+ ethPrevData,
1637
+ ethTestData,
599
1638
  testData,
600
1639
  btcTestData,
601
- btcBinancePrevData,
602
- btcCoinbasePrevData
1640
+ btcBinanceData,
1641
+ btcCoinbaseData,
1642
+ backtestExecutionInterval,
1643
+ backtestExecutionData,
1644
+ backtestExecutionBtcData,
1645
+ backtestExecutionDataByTimestamp,
1646
+ backtestExecutionBtcDataByTimestamp
603
1647
  } = preparedData;
1648
+ const runtimePrevData = prevData.slice();
1649
+ const runtimeBtcPrevData = btcPrevData.slice();
1650
+ const runtimeEthData = [...ethPrevData, ...ethTestData];
1651
+ totalCandles = testData.length;
1652
+ const instrument = requestedInstrument;
1653
+ const { model: executionCostModel, fundingRates } = await resolveExecutionCosts({
1654
+ connector,
1655
+ symbol,
1656
+ config: strategyConfig,
1657
+ startTime: start,
1658
+ endTime: end,
1659
+ instrument
1660
+ });
604
1661
  const testConnector = createTestConnector(connector, {
605
1662
  userName,
606
1663
  mlEnabled: ml,
607
- aiEnabled: ai
1664
+ aiEnabled: ai,
1665
+ fastMode: fast,
1666
+ executionCostModel,
1667
+ fundingRates
608
1668
  });
609
1669
  const strategy = await withTimeout(
610
1670
  "strategy init",
611
1671
  strategyCreator({
612
1672
  userName,
613
- config: strategyConfig,
1673
+ connectorName,
1674
+ universe,
1675
+ assetClass: assetClass ?? instrument?.assetClass,
1676
+ instrument,
1677
+ accountId,
1678
+ deploymentId,
1679
+ policyProfileId,
1680
+ config: {
1681
+ ...strategyConfig,
1682
+ INTERVAL: interval
1683
+ },
614
1684
  symbol,
615
- data: prevData,
616
- btcData: btcPrevData,
617
- btcBinanceData: btcBinancePrevData,
618
- btcCoinbaseData: btcCoinbasePrevData,
1685
+ data: runtimePrevData,
1686
+ btcData: runtimeBtcPrevData,
1687
+ ethData: runtimeEthData,
1688
+ btcBinanceData,
1689
+ btcCoinbaseData,
1690
+ backtestExecutionMarketData: {
1691
+ interval: backtestExecutionInterval,
1692
+ data: backtestExecutionData,
1693
+ btcData: backtestExecutionBtcData,
1694
+ dataByTimestamp: backtestExecutionDataByTimestamp,
1695
+ btcDataByTimestamp: backtestExecutionBtcDataByTimestamp
1696
+ },
619
1697
  connector: testConnector
620
1698
  })
621
1699
  );
622
1700
  const pendingMlPayloadBySignalId = /* @__PURE__ */ new Map();
623
1701
  const pendingAiRowBySignalId = /* @__PURE__ */ new Map();
624
- const replaySignalEvaluations = [];
1702
+ const replaySignalEvaluations = collectReplaySignalEvaluations ? [] : null;
625
1703
  const flushClosedResultsBatch = async () => {
626
1704
  if (!ml && !ai) return;
627
1705
  const batch = await testConnector.drainMlResultsBatch();
@@ -633,7 +1711,14 @@ var testing = async ({
633
1711
  const fullRow = buildMlTrainingRow(payload, {
634
1712
  profit: resultRecord.profit
635
1713
  });
636
- const row = trimMlTrainingRowWindows(fullRow, 5);
1714
+ const row = {
1715
+ ...trimMlTrainingRowWindows(fullRow, 5),
1716
+ ...buildBacktestDatasetMetadata({
1717
+ backtestRunId,
1718
+ backtestTestKey,
1719
+ chunkId
1720
+ })
1721
+ };
637
1722
  await appendMlDatasetRow({
638
1723
  strategyName,
639
1724
  chunkId,
@@ -643,62 +1728,54 @@ var testing = async ({
643
1728
  const aiRowBase = pendingAiRowBySignalId.get(resultRecord.signalId);
644
1729
  if (aiRowBase) {
645
1730
  pendingAiRowBySignalId.delete(resultRecord.signalId);
1731
+ const { signal: aiSignal, ...rowBase } = aiRowBase;
646
1732
  await appendAiDatasetRow({
647
1733
  strategyName,
648
1734
  chunkId,
649
1735
  row: {
650
- ...aiRowBase,
651
- profit: resultRecord.profit
1736
+ ...rowBase,
1737
+ payload: buildAiPayload(aiSignal),
1738
+ profit: resultRecord.profit,
1739
+ tradeResult: resultRecord.tradeResult
652
1740
  }
653
1741
  });
654
1742
  }
655
1743
  }
656
1744
  };
657
- for (let candleIndex = 0; candleIndex < testData.length; candleIndex++) {
658
- if (candleIndex % 25 === 0) {
659
- throwIfTimedOut("candle loop");
1745
+ const processSignal = async (signal, candle) => {
1746
+ if (isBacktestEntryDelayControlCode(signal)) {
1747
+ return;
660
1748
  }
661
- const candle = testData[candleIndex];
662
- const btcCandle = btcTestData[candleIndex];
663
- await withTimeout("stop-loss check", testConnector.checkSl(candle));
664
- await withTimeout("take-profit check", testConnector.checkTp(candle));
665
- const signal = await withTimeout(
666
- "strategy signal",
667
- strategy(candle, btcCandle)
668
- );
669
- if (!signal || typeof signal === "string") {
670
- replaySignalEvaluations.push({
671
- evaluationId: `${testId}:${strategyName}:${symbol}:${candle.timestamp}`,
672
- userName,
673
- strategy: strategyName,
674
- symbol,
675
- interval,
676
- timestamp: candle.timestamp,
677
- evaluatedAt: candle.timestamp,
678
- status: "skip",
679
- reason: typeof signal === "string" && signal.trim() ? signal : "NO_SIGNAL"
680
- });
681
- } else {
682
- replaySignalEvaluations.push({
683
- evaluationId: `${signal.signalId || testId}:${strategyName}:${symbol}:${signal.timestamp || candle.timestamp}`,
684
- userName,
685
- strategy: signal.strategy || strategyName,
686
- symbol: signal.symbol || symbol,
687
- interval: signal.interval || interval,
688
- timestamp: typeof signal.timestamp === "number" && Number.isFinite(signal.timestamp) ? signal.timestamp : candle.timestamp,
689
- evaluatedAt: candle.timestamp,
690
- status: "signal",
691
- reason: signal.orderSkipReason || signal.orderStatus,
692
- signalId: signal.signalId,
693
- direction: signal.direction,
694
- orderStatus: signal.orderStatus,
695
- orderSkipReason: signal.orderSkipReason,
696
- aiAnalysis: signal.aiAnalysis ?? null,
697
- ml: signal.ml
698
- });
1749
+ if (replaySignalEvaluations) {
1750
+ replaySignalEvaluations.push(
1751
+ buildReplaySignalEvaluationRecord({
1752
+ signal,
1753
+ testId,
1754
+ userName,
1755
+ strategyName,
1756
+ symbol,
1757
+ interval,
1758
+ candle
1759
+ })
1760
+ );
699
1761
  }
700
1762
  const shouldCapturePayload = signal && typeof signal !== "string" && signal.signalId && (ml || ai);
701
1763
  if (shouldCapturePayload) {
1764
+ await withTimeout(
1765
+ "binance market context",
1766
+ enrichSignalWithBinanceMarketContext({
1767
+ signal,
1768
+ env: "BACKTEST"
1769
+ })
1770
+ );
1771
+ await withTimeout(
1772
+ "coinmarketcap context",
1773
+ enrichSignalWithCoinMarketCapContext({
1774
+ signal,
1775
+ env: "BACKTEST",
1776
+ enabled: Boolean(ml || ai)
1777
+ })
1778
+ );
702
1779
  await withTimeout(
703
1780
  "derivatives context",
704
1781
  enrichSignalWithDerivativesContext({
@@ -715,6 +1792,7 @@ var testing = async ({
715
1792
  testId,
716
1793
  testSuiteId,
717
1794
  testName: name,
1795
+ configId,
718
1796
  symbol,
719
1797
  strategyName,
720
1798
  strategyConfig,
@@ -730,23 +1808,515 @@ var testing = async ({
730
1808
  symbol: signal.symbol || symbol,
731
1809
  direction: signal.direction,
732
1810
  timestamp: signal.timestamp,
733
- payload: buildAiPayload(signal),
1811
+ signal: cloneAiPayloadSignal(signal),
734
1812
  testId,
735
1813
  testSuiteId,
736
1814
  testName: name,
737
- connectorName
1815
+ configId,
1816
+ connectorName,
1817
+ ...buildBacktestDatasetMetadata({
1818
+ backtestRunId,
1819
+ backtestTestKey,
1820
+ chunkId
1821
+ })
738
1822
  });
739
1823
  }
1824
+ };
1825
+ for (let candleIndex = 0; candleIndex < testData.length; candleIndex++) {
1826
+ if (candleIndex % 25 === 0) {
1827
+ throwIfTimedOut("candle loop");
1828
+ }
1829
+ currentCandleIndex = candleIndex + 1;
1830
+ emitProgress("candle loop", {
1831
+ force: candleIndex === 0 || currentCandleIndex === totalCandles
1832
+ });
1833
+ const candle = testData[candleIndex];
1834
+ const btcCandle = btcTestData[candleIndex];
1835
+ const delayedSignal = await runStrategyCandleStage(
1836
+ "delayed entry",
1837
+ async () => strategy.__tradejsFlushBacktestDelayedEntry?.(candle, btcCandle)
1838
+ );
1839
+ if (delayedSignal && typeof delayedSignal !== "string") {
1840
+ await processSignal(delayedSignal, candle);
1841
+ }
1842
+ await runStage("exit checks", () => testConnector.checkExits(candle));
1843
+ const signal = await runStrategyCandleStage(
1844
+ "strategy signal",
1845
+ () => strategy(candle, btcCandle)
1846
+ );
1847
+ await processSignal(signal, candle);
1848
+ if ((candleIndex + 1) % CLOSED_RESULT_FLUSH_INTERVAL === 0) {
1849
+ await withTimeout("flush closed results", flushClosedResultsBatch());
1850
+ }
740
1851
  }
741
1852
  await withTimeout("flush closed results", flushClosedResultsBatch());
742
1853
  const result = await withTimeout("collect result", testConnector.getResult());
743
- return {
1854
+ return replaySignalEvaluations ? {
744
1855
  ...result,
745
1856
  inlineReplaySignalEvaluations: replaySignalEvaluations
1857
+ } : result;
1858
+ };
1859
+ var testingGroupInSharedCandleLoop = async (tests) => {
1860
+ if (!canRunTestsInSharedCandleLoop(tests)) {
1861
+ const results = [];
1862
+ for (const test of tests) {
1863
+ const result = await testing(test);
1864
+ if (result) {
1865
+ results.push({ test, result });
1866
+ }
1867
+ }
1868
+ return results;
1869
+ }
1870
+ const first = tests[0];
1871
+ const {
1872
+ userName,
1873
+ symbol,
1874
+ options: { start, end },
1875
+ strategyName,
1876
+ connectorName,
1877
+ universe = "crypto",
1878
+ accountId,
1879
+ deploymentId,
1880
+ interval = BACKTEST_INTERVAL,
1881
+ ml = false,
1882
+ ai = false,
1883
+ fast = false,
1884
+ collectReplaySignalEvaluations = false,
1885
+ chunkId = "single",
1886
+ timeoutMs
1887
+ } = first;
1888
+ if (!start) {
1889
+ throw new Error("no start");
1890
+ }
1891
+ const preloadStart = getBacktestPreloadStart(start);
1892
+ const startedAt = Date.now();
1893
+ let activeStageStartedAt = startedAt;
1894
+ let lastProgressSentAt = 0;
1895
+ let lastProgressSignature = "";
1896
+ let currentCandleIndex = 0;
1897
+ let totalCandles = 0;
1898
+ const strategyCandleTimeoutMs = getStrategyCandleTimeoutMs();
1899
+ const formatTimeoutMessage = (stage, stageTimeoutMs) => `Test group ${strategyName}/${symbol} timed out after ${stageTimeoutMs}ms during ${stage}`;
1900
+ const emitProgress = (stage, options = {}) => {
1901
+ const now = Date.now();
1902
+ const candleIndex = typeof options.candleIndex === "number" ? options.candleIndex : currentCandleIndex;
1903
+ const candleTotal = typeof options.candleTotal === "number" ? options.candleTotal : totalCandles;
1904
+ const signature = [
1905
+ stage,
1906
+ candleIndex,
1907
+ candleTotal,
1908
+ Math.floor((now - activeStageStartedAt) / 5e3)
1909
+ ].join(":");
1910
+ if (!options.force) {
1911
+ if (signature === lastProgressSignature) {
1912
+ return;
1913
+ }
1914
+ if (now - lastProgressSentAt < 4e3) {
1915
+ return;
1916
+ }
1917
+ }
1918
+ lastProgressSentAt = now;
1919
+ lastProgressSignature = signature;
1920
+ process.send?.({
1921
+ progress: true,
1922
+ testName: first.name,
1923
+ symbol,
1924
+ strategyName,
1925
+ stage,
1926
+ candleIndex,
1927
+ candleTotal,
1928
+ elapsedMs: now - startedAt,
1929
+ stageElapsedMs: now - activeStageStartedAt
1930
+ });
1931
+ };
1932
+ const getStageTimeoutMs = () => {
1933
+ if (!timeoutMs || timeoutMs <= 0) {
1934
+ return null;
1935
+ }
1936
+ return timeoutMs;
1937
+ };
1938
+ const throwIfTimedOut = (stage) => {
1939
+ if (getStageTimeoutMs() == null) {
1940
+ return;
1941
+ }
1942
+ emitProgress(stage);
1943
+ };
1944
+ const withTimeout = async (stage, promise, stageTimeoutOverrideMs = null) => {
1945
+ const stageTimeoutMs = getEffectiveTimeoutMs(
1946
+ getStageTimeoutMs() ?? void 0,
1947
+ stageTimeoutOverrideMs
1948
+ );
1949
+ if (stageTimeoutMs == null) {
1950
+ return promise;
1951
+ }
1952
+ activeStageStartedAt = Date.now();
1953
+ emitProgress(stage, { force: true });
1954
+ return await new Promise((resolve, reject) => {
1955
+ const heartbeat = setInterval(() => {
1956
+ emitProgress(stage);
1957
+ }, 5e3);
1958
+ const timer = setTimeout(() => {
1959
+ clearInterval(heartbeat);
1960
+ reject(new Error(formatTimeoutMessage(stage, stageTimeoutMs)));
1961
+ }, stageTimeoutMs);
1962
+ promise.then(
1963
+ (value) => {
1964
+ clearInterval(heartbeat);
1965
+ clearTimeout(timer);
1966
+ resolve(value);
1967
+ },
1968
+ (error) => {
1969
+ clearInterval(heartbeat);
1970
+ clearTimeout(timer);
1971
+ reject(error);
1972
+ }
1973
+ );
1974
+ });
1975
+ };
1976
+ const runStage = (stage, fn) => {
1977
+ if (getStageTimeoutMs() == null) {
1978
+ return fn();
1979
+ }
1980
+ return withTimeout(stage, fn());
746
1981
  };
1982
+ const runStrategyCandleStage = (stage, fn) => withTimeout(stage, fn(), strategyCandleTimeoutMs);
1983
+ const { projectRoot, state } = getTestingKlineCacheState();
1984
+ const connector = await withTimeout(
1985
+ "connector init",
1986
+ getCachedConnector({
1987
+ state,
1988
+ projectRoot,
1989
+ userName,
1990
+ connectorName,
1991
+ universe,
1992
+ accountId,
1993
+ deploymentId
1994
+ })
1995
+ );
1996
+ if (!connector) {
1997
+ throw new Error(`Unknown connector: ${connectorName}`);
1998
+ }
1999
+ const strategyCreator = await withTimeout(
2000
+ "strategy lookup",
2001
+ getStrategyCreator(strategyName, projectRoot)
2002
+ );
2003
+ if (!strategyCreator) {
2004
+ throw new Error(`Unknown strategy: ${strategyName}`);
2005
+ }
2006
+ const preparedData = await withTimeout(
2007
+ "kline preload",
2008
+ prepareTestingData({
2009
+ state,
2010
+ projectRoot,
2011
+ userName,
2012
+ connectorName,
2013
+ symbol,
2014
+ preloadStart,
2015
+ start,
2016
+ end,
2017
+ interval,
2018
+ universe,
2019
+ accountId,
2020
+ deploymentId
2021
+ })
2022
+ );
2023
+ if (!preparedData) {
2024
+ throw new Error("Prepared backtest data not available");
2025
+ }
2026
+ const {
2027
+ prevData,
2028
+ btcPrevData,
2029
+ ethPrevData,
2030
+ ethTestData,
2031
+ testData,
2032
+ btcTestData,
2033
+ btcBinanceData,
2034
+ btcCoinbaseData,
2035
+ backtestExecutionInterval,
2036
+ backtestExecutionData,
2037
+ backtestExecutionBtcData,
2038
+ backtestExecutionDataByTimestamp,
2039
+ backtestExecutionBtcDataByTimestamp
2040
+ } = preparedData;
2041
+ totalCandles = testData.length;
2042
+ const sharedIndicatorsReplayKey = [
2043
+ "shared",
2044
+ userName,
2045
+ connectorName,
2046
+ strategyName,
2047
+ symbol,
2048
+ interval,
2049
+ start,
2050
+ end,
2051
+ chunkId
2052
+ ].join(":");
2053
+ const runners = [];
2054
+ try {
2055
+ for (const test of tests) {
2056
+ const instrument = test.instrument;
2057
+ const { model: executionCostModel, fundingRates } = await resolveExecutionCosts({
2058
+ connector,
2059
+ symbol: test.symbol,
2060
+ config: test.strategyConfig,
2061
+ startTime: start,
2062
+ endTime: end,
2063
+ instrument
2064
+ });
2065
+ const testConnector = createTestConnector(connector, {
2066
+ userName: test.userName,
2067
+ mlEnabled: test.ml,
2068
+ aiEnabled: test.ai,
2069
+ fastMode: test.fast,
2070
+ executionCostModel,
2071
+ fundingRates
2072
+ });
2073
+ const strategy = await withTimeout(
2074
+ "strategy init",
2075
+ strategyCreator({
2076
+ userName: test.userName,
2077
+ connectorName: test.connectorName,
2078
+ universe: test.universe ?? universe,
2079
+ assetClass: test.assetClass ?? instrument?.assetClass,
2080
+ instrument,
2081
+ accountId: test.accountId ?? accountId,
2082
+ deploymentId: test.deploymentId ?? deploymentId,
2083
+ policyProfileId: test.policyProfileId,
2084
+ config: {
2085
+ ...test.strategyConfig,
2086
+ INTERVAL: test.interval ?? interval
2087
+ },
2088
+ symbol: test.symbol,
2089
+ data: prevData.slice(),
2090
+ btcData: btcPrevData.slice(),
2091
+ ethData: [...ethPrevData, ...ethTestData],
2092
+ btcBinanceData,
2093
+ btcCoinbaseData,
2094
+ backtestExecutionMarketData: {
2095
+ interval: backtestExecutionInterval,
2096
+ data: backtestExecutionData,
2097
+ btcData: backtestExecutionBtcData,
2098
+ dataByTimestamp: backtestExecutionDataByTimestamp,
2099
+ btcDataByTimestamp: backtestExecutionBtcDataByTimestamp
2100
+ },
2101
+ connector: testConnector,
2102
+ sharedIndicatorsReplayKey
2103
+ })
2104
+ );
2105
+ runners.push({
2106
+ test,
2107
+ strategy,
2108
+ testConnector,
2109
+ pendingMlPayloadBySignalId: /* @__PURE__ */ new Map(),
2110
+ pendingAiRowBySignalId: /* @__PURE__ */ new Map(),
2111
+ replaySignalEvaluations: collectReplaySignalEvaluations ? [] : null
2112
+ });
2113
+ }
2114
+ const flushClosedResultsBatch = async (runner) => {
2115
+ if (!runner.test.ml && !runner.test.ai) return;
2116
+ const batch = await runner.testConnector.drainMlResultsBatch();
2117
+ if (!batch.length) return;
2118
+ for (const resultRecord of batch) {
2119
+ const payload = runner.pendingMlPayloadBySignalId.get(
2120
+ resultRecord.signalId
2121
+ );
2122
+ if (payload) {
2123
+ runner.pendingMlPayloadBySignalId.delete(resultRecord.signalId);
2124
+ const fullRow = buildMlTrainingRow(payload, {
2125
+ profit: resultRecord.profit
2126
+ });
2127
+ const resolvedChunkId = runner.test.chunkId ?? "single";
2128
+ const row = {
2129
+ ...trimMlTrainingRowWindows(fullRow, 5),
2130
+ ...buildBacktestDatasetMetadata({
2131
+ backtestRunId: runner.test.backtestRunId,
2132
+ backtestTestKey: runner.test.backtestTestKey,
2133
+ chunkId: resolvedChunkId
2134
+ })
2135
+ };
2136
+ await appendMlDatasetRow({
2137
+ strategyName: runner.test.strategyName,
2138
+ chunkId: resolvedChunkId,
2139
+ row
2140
+ });
2141
+ }
2142
+ const aiRowBase = runner.pendingAiRowBySignalId.get(
2143
+ resultRecord.signalId
2144
+ );
2145
+ if (aiRowBase) {
2146
+ runner.pendingAiRowBySignalId.delete(resultRecord.signalId);
2147
+ const { signal: aiSignal, ...rowBase } = aiRowBase;
2148
+ const resolvedChunkId = runner.test.chunkId ?? "single";
2149
+ await appendAiDatasetRow({
2150
+ strategyName: runner.test.strategyName,
2151
+ chunkId: resolvedChunkId,
2152
+ row: {
2153
+ ...rowBase,
2154
+ payload: buildAiPayload(aiSignal),
2155
+ profit: resultRecord.profit,
2156
+ tradeResult: resultRecord.tradeResult
2157
+ }
2158
+ });
2159
+ }
2160
+ }
2161
+ };
2162
+ const processRunnerSignal = async (runner, signal, candle) => {
2163
+ if (isBacktestEntryDelayControlCode(signal)) {
2164
+ return;
2165
+ }
2166
+ const { test } = runner;
2167
+ if (runner.replaySignalEvaluations) {
2168
+ runner.replaySignalEvaluations.push(
2169
+ buildReplaySignalEvaluationRecord({
2170
+ signal,
2171
+ testId: test.testId,
2172
+ userName: test.userName,
2173
+ strategyName: test.strategyName,
2174
+ symbol: test.symbol,
2175
+ interval: test.interval ?? interval,
2176
+ candle
2177
+ })
2178
+ );
2179
+ }
2180
+ const shouldCapturePayload = signal && typeof signal !== "string" && signal.signalId && (test.ml || test.ai);
2181
+ if (shouldCapturePayload) {
2182
+ await withTimeout(
2183
+ "binance market context",
2184
+ enrichSignalWithBinanceMarketContext({
2185
+ signal,
2186
+ env: "BACKTEST"
2187
+ })
2188
+ );
2189
+ await withTimeout(
2190
+ "coinmarketcap context",
2191
+ enrichSignalWithCoinMarketCapContext({
2192
+ signal,
2193
+ env: "BACKTEST",
2194
+ enabled: Boolean(test.ml || test.ai)
2195
+ })
2196
+ );
2197
+ await withTimeout(
2198
+ "derivatives context",
2199
+ enrichSignalWithDerivativesContext({
2200
+ signal,
2201
+ env: "BACKTEST"
2202
+ })
2203
+ );
2204
+ }
2205
+ if (test.ml && signal && typeof signal !== "string" && signal.signalId) {
2206
+ const payload = buildMlPayload({
2207
+ signal,
2208
+ context: {
2209
+ userName: test.userName,
2210
+ testId: test.testId,
2211
+ testSuiteId: test.testSuiteId,
2212
+ testName: test.name,
2213
+ configId: test.configId,
2214
+ symbol: test.symbol,
2215
+ strategyName: test.strategyName,
2216
+ strategyConfig: test.strategyConfig,
2217
+ connectorName: test.connectorName
2218
+ }
2219
+ });
2220
+ runner.pendingMlPayloadBySignalId.set(signal.signalId, payload);
2221
+ }
2222
+ if (test.ai && signal && typeof signal !== "string" && signal.signalId) {
2223
+ runner.pendingAiRowBySignalId.set(signal.signalId, {
2224
+ signalId: signal.signalId,
2225
+ strategyName: signal.strategy || test.strategyName,
2226
+ symbol: signal.symbol || test.symbol,
2227
+ direction: signal.direction,
2228
+ timestamp: signal.timestamp,
2229
+ signal: cloneAiPayloadSignal(signal),
2230
+ testId: test.testId,
2231
+ testSuiteId: test.testSuiteId,
2232
+ testName: test.name,
2233
+ configId: test.configId,
2234
+ connectorName: test.connectorName,
2235
+ ...buildBacktestDatasetMetadata({
2236
+ backtestRunId: test.backtestRunId,
2237
+ backtestTestKey: test.backtestTestKey,
2238
+ chunkId: test.chunkId ?? "single"
2239
+ })
2240
+ });
2241
+ }
2242
+ };
2243
+ for (let candleIndex = 0; candleIndex < testData.length; candleIndex++) {
2244
+ if (candleIndex % 25 === 0) {
2245
+ throwIfTimedOut("candle loop");
2246
+ }
2247
+ currentCandleIndex = candleIndex + 1;
2248
+ emitProgress("candle loop", {
2249
+ force: candleIndex === 0 || currentCandleIndex === totalCandles
2250
+ });
2251
+ const candle = testData[candleIndex];
2252
+ const btcCandle = btcTestData[candleIndex];
2253
+ const detectorNoSignalByKey = /* @__PURE__ */ new Map();
2254
+ for (const runner of runners) {
2255
+ const { test, testConnector, strategy } = runner;
2256
+ const delayedSignal = await runStrategyCandleStage(
2257
+ "delayed entry",
2258
+ async () => strategy.__tradejsFlushBacktestDelayedEntry?.(candle, btcCandle)
2259
+ );
2260
+ if (delayedSignal && typeof delayedSignal !== "string") {
2261
+ await processRunnerSignal(runner, delayedSignal, candle);
2262
+ }
2263
+ await runStage("exit checks", () => testConnector.checkExits(candle));
2264
+ const detectorFanoutKey = strategy.detectorFanoutKey;
2265
+ const detectorSkipCode = detectorFanoutKey ? detectorNoSignalByKey.get(detectorFanoutKey) : void 0;
2266
+ const signal = await runStrategyCandleStage(
2267
+ detectorSkipCode ? "strategy detector skip" : "strategy signal",
2268
+ () => detectorSkipCode && strategy.canFastAdvanceDetectorNoSignal && strategy.advanceDetectorNoSignal ? strategy.advanceDetectorNoSignal(
2269
+ candle,
2270
+ btcCandle,
2271
+ detectorSkipCode
2272
+ ) : detectorSkipCode && strategy.skipDetectorNoSignal ? strategy.skipDetectorNoSignal(
2273
+ candle,
2274
+ btcCandle,
2275
+ detectorSkipCode
2276
+ ) : strategy(candle, btcCandle)
2277
+ );
2278
+ if (detectorFanoutKey && strategy.detectorNoSignalSkipReason && typeof signal === "string" && signal === strategy.detectorNoSignalSkipReason) {
2279
+ detectorNoSignalByKey.set(detectorFanoutKey, signal);
2280
+ }
2281
+ await processRunnerSignal(runner, signal, candle);
2282
+ }
2283
+ if ((candleIndex + 1) % CLOSED_RESULT_FLUSH_INTERVAL === 0) {
2284
+ await withTimeout(
2285
+ "flush closed results",
2286
+ Promise.all(runners.map((runner) => flushClosedResultsBatch(runner)))
2287
+ );
2288
+ }
2289
+ }
2290
+ const results = [];
2291
+ for (const runner of runners) {
2292
+ await withTimeout(
2293
+ "flush closed results",
2294
+ flushClosedResultsBatch(runner)
2295
+ );
2296
+ const result = await withTimeout(
2297
+ "collect result",
2298
+ runner.testConnector.getResult()
2299
+ );
2300
+ results.push({
2301
+ test: runner.test,
2302
+ result: runner.replaySignalEvaluations ? {
2303
+ ...result,
2304
+ inlineReplaySignalEvaluations: runner.replaySignalEvaluations
2305
+ } : result
2306
+ });
2307
+ }
2308
+ return results;
2309
+ } finally {
2310
+ releaseStrategyIndicatorsReplayCache(sharedIndicatorsReplayKey);
2311
+ releaseStrategyReplayCache(sharedIndicatorsReplayKey);
2312
+ }
747
2313
  };
748
2314
  export {
2315
+ canRunTestsInSharedCandleLoop,
749
2316
  createTestConnector,
2317
+ releaseTestingSymbolCache,
750
2318
  resetTestingKlineCache,
751
- testing
2319
+ resolveExecutionCosts,
2320
+ testing,
2321
+ testingGroupInSharedCandleLoop
752
2322
  };