@tradejs/node 1.0.9 → 1.0.11

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