tradelab 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +75 -12
- package/bin/tradelab-mcp.js +7 -0
- package/bin/tradelab.js +29 -0
- package/dist/cjs/data.cjs +149 -26
- package/dist/cjs/index.cjs +1893 -1003
- package/dist/cjs/live.cjs +134 -25
- package/dist/cjs/ta.cjs +339 -0
- package/docs/api-reference.md +46 -0
- package/docs/backtest-engine.md +112 -0
- package/docs/live-trading.md +51 -0
- package/docs/mcp.md +64 -0
- package/docs/research.md +103 -0
- package/docs/superpowers/plans/2026-00-overview.md +101 -0
- package/docs/superpowers/plans/2026-01-metrics-correctness.md +873 -0
- package/docs/superpowers/plans/2026-02-indicator-library.md +677 -0
- package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +882 -0
- package/docs/superpowers/plans/2026-04-async-signals-seeding.md +981 -0
- package/docs/superpowers/plans/2026-05-mcp-server.md +758 -0
- package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +508 -0
- package/docs/superpowers/plans/2026-07-funding-carry-costs.md +535 -0
- package/docs/superpowers/plans/2026-08-live-dashboard.md +547 -0
- package/docs/superpowers/plans/HANDOFF.md +88 -0
- package/examples/liveDashboard.js +33 -0
- package/examples/llmSignal.js +33 -0
- package/examples/optimize.js +25 -0
- package/package.json +16 -2
- package/src/engine/asyncSignal.js +28 -0
- package/src/engine/backtest.js +13 -1
- package/src/engine/backtestAsync.js +27 -0
- package/src/engine/backtestTicks.js +13 -2
- package/src/engine/barSystemRunner.js +96 -41
- package/src/engine/execution.js +39 -0
- package/src/engine/grid.js +15 -0
- package/src/engine/llmSignal.js +84 -0
- package/src/engine/optimize.js +86 -0
- package/src/engine/optimizeWorker.js +67 -0
- package/src/engine/walkForward.js +1 -0
- package/src/index.js +9 -0
- package/src/live/dashboard/server.js +120 -0
- package/src/live/engine/liveEngine.js +2 -2
- package/src/live/index.js +1 -0
- package/src/mcp/schemas.js +48 -0
- package/src/mcp/server.js +31 -0
- package/src/mcp/tools.js +142 -0
- package/src/metrics/annualize.js +32 -0
- package/src/metrics/benchmark.js +55 -0
- package/src/metrics/buildMetrics.js +34 -13
- package/src/metrics/finite.js +17 -0
- package/src/research/combinations.js +18 -0
- package/src/research/cpcv.js +47 -0
- package/src/research/deflatedSharpe.js +35 -0
- package/src/research/index.js +6 -0
- package/src/research/monteCarlo.js +88 -0
- package/src/research/pbo.js +69 -0
- package/src/research/stats.js +78 -0
- package/src/strategies/builtins.js +96 -0
- package/src/strategies/index.js +30 -0
- package/src/ta/channels.js +67 -0
- package/src/ta/index.js +16 -0
- package/src/ta/oscillators.js +70 -0
- package/src/ta/trend.js +78 -0
- package/src/utils/random.js +33 -0
- package/templates/dashboard.html +174 -0
- package/types/index.d.ts +154 -0
- package/types/live.d.ts +15 -0
- package/types/ta.d.ts +45 -0
package/dist/cjs/index.cjs
CHANGED
|
@@ -30,16 +30,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.js
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
BIG_NUMBER: () => BIG_NUMBER,
|
|
34
|
+
LlmSignal: () => LlmSignal,
|
|
33
35
|
atr: () => atr,
|
|
34
36
|
backtest: () => backtest,
|
|
37
|
+
backtestAsync: () => backtestAsync,
|
|
35
38
|
backtestHistorical: () => backtestHistorical,
|
|
36
39
|
backtestPortfolio: () => backtestPortfolio,
|
|
37
40
|
backtestTicks: () => backtestTicks,
|
|
41
|
+
benchmarkStats: () => benchmarkStats,
|
|
38
42
|
bpsOf: () => bpsOf,
|
|
39
43
|
buildMetrics: () => buildMetrics,
|
|
40
44
|
cachedCandlesPath: () => cachedCandlesPath,
|
|
41
45
|
calculatePositionSize: () => calculatePositionSize,
|
|
42
46
|
candleStats: () => candleStats,
|
|
47
|
+
clampFinite: () => clampFinite,
|
|
43
48
|
detectFVG: () => detectFVG,
|
|
44
49
|
ema: () => ema,
|
|
45
50
|
exportBacktestArtifacts: () => exportBacktestArtifacts,
|
|
@@ -49,18 +54,25 @@ __export(index_exports, {
|
|
|
49
54
|
fetchHistorical: () => fetchHistorical,
|
|
50
55
|
fetchLatestCandle: () => fetchLatestCandle,
|
|
51
56
|
getHistoricalCandles: () => getHistoricalCandles,
|
|
57
|
+
getStrategy: () => getStrategy,
|
|
58
|
+
grid: () => grid,
|
|
52
59
|
inWindowsET: () => inWindowsET,
|
|
53
60
|
isSession: () => isSession,
|
|
54
61
|
lastSwing: () => lastSwing,
|
|
62
|
+
listStrategies: () => listStrategies,
|
|
55
63
|
loadCandlesFromCSV: () => loadCandlesFromCSV,
|
|
56
64
|
loadCandlesFromCache: () => loadCandlesFromCache,
|
|
57
65
|
mergeCandles: () => mergeCandles,
|
|
58
66
|
minutesET: () => minutesET,
|
|
59
67
|
normalizeCandles: () => normalizeCandles,
|
|
60
68
|
offsetET: () => offsetET,
|
|
69
|
+
optimize: () => optimize,
|
|
61
70
|
parseWindowsCSV: () => parseWindowsCSV,
|
|
62
71
|
pct: () => pct,
|
|
72
|
+
periodsPerYear: () => periodsPerYear,
|
|
73
|
+
registerStrategy: () => registerStrategy,
|
|
63
74
|
renderHtmlReport: () => renderHtmlReport,
|
|
75
|
+
research: () => research_exports,
|
|
64
76
|
saveCandlesToCache: () => saveCandlesToCache,
|
|
65
77
|
structureState: () => structureState,
|
|
66
78
|
swingHigh: () => swingHigh,
|
|
@@ -221,22 +233,93 @@ function calculatePositionSize({
|
|
|
221
233
|
return quantity >= minQty ? quantity : 0;
|
|
222
234
|
}
|
|
223
235
|
|
|
236
|
+
// src/metrics/finite.js
|
|
237
|
+
var BIG_NUMBER = 1e9;
|
|
238
|
+
function clampFinite(value, fallback = 0) {
|
|
239
|
+
if (value === Infinity) return BIG_NUMBER;
|
|
240
|
+
if (value === -Infinity) return -BIG_NUMBER;
|
|
241
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
242
|
+
return fallback;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/metrics/annualize.js
|
|
246
|
+
var TRADING_DAYS = 252;
|
|
247
|
+
var RTH_HOURS = 6.5;
|
|
248
|
+
var MS_PER_YEAR = 365 * 24 * 60 * 60 * 1e3;
|
|
249
|
+
var INTERVAL_PERIODS = {
|
|
250
|
+
"1m": TRADING_DAYS * RTH_HOURS * 60,
|
|
251
|
+
"2m": TRADING_DAYS * RTH_HOURS * 30,
|
|
252
|
+
"5m": TRADING_DAYS * RTH_HOURS * 12,
|
|
253
|
+
"15m": TRADING_DAYS * RTH_HOURS * 4,
|
|
254
|
+
"30m": TRADING_DAYS * RTH_HOURS * 2,
|
|
255
|
+
"1h": TRADING_DAYS * RTH_HOURS,
|
|
256
|
+
"60m": TRADING_DAYS * RTH_HOURS,
|
|
257
|
+
"1d": TRADING_DAYS,
|
|
258
|
+
"1wk": 52,
|
|
259
|
+
"1mo": 12
|
|
260
|
+
};
|
|
261
|
+
function periodsPerYear(interval, estBarMs) {
|
|
262
|
+
if (interval && INTERVAL_PERIODS[interval]) return INTERVAL_PERIODS[interval];
|
|
263
|
+
if (Number.isFinite(estBarMs) && estBarMs > 0) {
|
|
264
|
+
return Math.round(MS_PER_YEAR / estBarMs);
|
|
265
|
+
}
|
|
266
|
+
return TRADING_DAYS;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/metrics/benchmark.js
|
|
270
|
+
function mean(xs) {
|
|
271
|
+
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
|
|
272
|
+
}
|
|
273
|
+
function benchmarkStats(strategyReturns, benchmarkReturns) {
|
|
274
|
+
const nullStats = {
|
|
275
|
+
alpha: null,
|
|
276
|
+
beta: null,
|
|
277
|
+
correlation: null,
|
|
278
|
+
informationRatio: null,
|
|
279
|
+
trackingError: null
|
|
280
|
+
};
|
|
281
|
+
if (!Array.isArray(strategyReturns) || !Array.isArray(benchmarkReturns) || strategyReturns.length === 0 || strategyReturns.length !== benchmarkReturns.length) {
|
|
282
|
+
return nullStats;
|
|
283
|
+
}
|
|
284
|
+
const meanStrat = mean(strategyReturns);
|
|
285
|
+
const meanBench = mean(benchmarkReturns);
|
|
286
|
+
let covar = 0;
|
|
287
|
+
let varBench = 0;
|
|
288
|
+
let varStrat = 0;
|
|
289
|
+
for (let i = 0; i < strategyReturns.length; i += 1) {
|
|
290
|
+
const ds = strategyReturns[i] - meanStrat;
|
|
291
|
+
const db = benchmarkReturns[i] - meanBench;
|
|
292
|
+
covar += ds * db;
|
|
293
|
+
varBench += db * db;
|
|
294
|
+
varStrat += ds * ds;
|
|
295
|
+
}
|
|
296
|
+
const beta = varBench === 0 ? 0 : covar / varBench;
|
|
297
|
+
const alpha = meanStrat - beta * meanBench;
|
|
298
|
+
const denom = Math.sqrt(varStrat * varBench);
|
|
299
|
+
const correlation = denom === 0 ? 0 : covar / denom;
|
|
300
|
+
const active = strategyReturns.map((r, i) => r - benchmarkReturns[i]);
|
|
301
|
+
const meanActive = mean(active);
|
|
302
|
+
const trackingError = Math.sqrt(mean(active.map((a) => (a - meanActive) ** 2)));
|
|
303
|
+
const informationRatio = trackingError === 0 ? 0 : meanActive / trackingError;
|
|
304
|
+
return { alpha, beta, correlation, informationRatio, trackingError };
|
|
305
|
+
}
|
|
306
|
+
|
|
224
307
|
// src/metrics/buildMetrics.js
|
|
225
308
|
function sum(values) {
|
|
226
309
|
return values.reduce((total, value) => total + value, 0);
|
|
227
310
|
}
|
|
228
|
-
function
|
|
311
|
+
function mean2(values) {
|
|
229
312
|
return values.length ? sum(values) / values.length : 0;
|
|
230
313
|
}
|
|
231
314
|
function stddev(values) {
|
|
232
315
|
if (values.length <= 1) return 0;
|
|
233
|
-
const avg =
|
|
234
|
-
return Math.sqrt(
|
|
316
|
+
const avg = mean2(values);
|
|
317
|
+
return Math.sqrt(mean2(values.map((value) => (value - avg) ** 2)));
|
|
235
318
|
}
|
|
236
319
|
function sortino(values) {
|
|
237
320
|
const losses = values.filter((value) => value < 0);
|
|
238
321
|
const downsideDeviation = stddev(losses.length ? losses : [0]);
|
|
239
|
-
const avg =
|
|
322
|
+
const avg = mean2(values);
|
|
240
323
|
return downsideDeviation === 0 ? avg > 0 ? Infinity : 0 : avg / downsideDeviation;
|
|
241
324
|
}
|
|
242
325
|
function dayKeyUTC(timeMs) {
|
|
@@ -322,14 +405,22 @@ function percentile(values, percentileRank) {
|
|
|
322
405
|
const index = Math.floor((sorted.length - 1) * percentileRank);
|
|
323
406
|
return sorted[index];
|
|
324
407
|
}
|
|
325
|
-
var PROFIT_FACTOR_CAP = 1e6;
|
|
326
408
|
function finiteProfitFactor(grossProfit, grossLoss) {
|
|
327
409
|
if (grossLoss === 0) {
|
|
328
|
-
return grossProfit > 0 ?
|
|
410
|
+
return grossProfit > 0 ? BIG_NUMBER : 0;
|
|
329
411
|
}
|
|
330
412
|
return grossProfit / grossLoss;
|
|
331
413
|
}
|
|
332
|
-
function buildMetrics({
|
|
414
|
+
function buildMetrics({
|
|
415
|
+
closed,
|
|
416
|
+
equityStart,
|
|
417
|
+
equityFinal,
|
|
418
|
+
candles,
|
|
419
|
+
estBarMs,
|
|
420
|
+
eqSeries,
|
|
421
|
+
interval,
|
|
422
|
+
benchmarkReturns
|
|
423
|
+
}) {
|
|
333
424
|
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
334
425
|
const completedTrades = [];
|
|
335
426
|
const tradeRs = [];
|
|
@@ -399,11 +490,11 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
|
|
|
399
490
|
if (pnl > 0) shortTradeWins += 1;
|
|
400
491
|
}
|
|
401
492
|
}
|
|
402
|
-
const avgR =
|
|
493
|
+
const avgR = mean2(tradeRs);
|
|
403
494
|
const { maxWin, maxLoss } = streaks(labels);
|
|
404
|
-
const expectancy =
|
|
495
|
+
const expectancy = mean2(tradePnls);
|
|
405
496
|
const tradeReturnStd = stddev(tradeReturns);
|
|
406
|
-
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 :
|
|
497
|
+
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean2(tradeReturns) / tradeReturnStd;
|
|
407
498
|
const sortinoPerTrade = sortino(tradeReturns);
|
|
408
499
|
const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
|
|
409
500
|
const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
|
|
@@ -411,11 +502,11 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
|
|
|
411
502
|
const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
|
|
412
503
|
const totalBars = Math.max(1, candles.length);
|
|
413
504
|
const exposurePct = openBars / totalBars;
|
|
414
|
-
const avgHoldMin =
|
|
505
|
+
const avgHoldMin = mean2(holdDurationsMinutes);
|
|
415
506
|
const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
|
|
416
507
|
const dailyReturnsSeries = dailyReturns(equitySeries);
|
|
417
508
|
const dailyStd = stddev(dailyReturnsSeries);
|
|
418
|
-
const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 :
|
|
509
|
+
const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean2(dailyReturnsSeries) / dailyStd;
|
|
419
510
|
const sortinoDaily = sortino(dailyReturnsSeries);
|
|
420
511
|
const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
|
|
421
512
|
const rDistribution = {
|
|
@@ -437,28 +528,36 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
|
|
|
437
528
|
trades: longTradesCount,
|
|
438
529
|
winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
|
|
439
530
|
avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
|
|
440
|
-
avgR:
|
|
531
|
+
avgR: mean2(longRs)
|
|
441
532
|
},
|
|
442
533
|
short: {
|
|
443
534
|
trades: shortTradesCount,
|
|
444
535
|
winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
|
|
445
536
|
avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
|
|
446
|
-
avgR:
|
|
537
|
+
avgR: mean2(shortRs)
|
|
447
538
|
}
|
|
448
539
|
};
|
|
540
|
+
const periods = periodsPerYear(interval, estBarMs);
|
|
541
|
+
const sqrtPeriods = Math.sqrt(periods);
|
|
542
|
+
const sharpeAnnualized = clampFinite(clampFinite(sharpeDaily) * sqrtPeriods);
|
|
543
|
+
const sortinoAnnualized = clampFinite(clampFinite(sortinoDaily) * sqrtPeriods);
|
|
544
|
+
const benchmark = benchmarkStats(dailyReturnsSeries, benchmarkReturns ?? []);
|
|
449
545
|
return {
|
|
450
546
|
trades: completedTrades.length,
|
|
451
547
|
winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
452
|
-
profitFactor: profitFactorPositions,
|
|
548
|
+
profitFactor: clampFinite(profitFactorPositions),
|
|
453
549
|
expectancy,
|
|
454
550
|
totalR,
|
|
455
551
|
avgR,
|
|
456
|
-
sharpe: sharpeDaily,
|
|
457
|
-
|
|
458
|
-
|
|
552
|
+
sharpe: clampFinite(sharpeDaily),
|
|
553
|
+
sharpeAnnualized,
|
|
554
|
+
sortinoAnnualized,
|
|
555
|
+
sharpePerTrade: clampFinite(sharpePerTrade),
|
|
556
|
+
sortinoPerTrade: clampFinite(sortinoPerTrade),
|
|
557
|
+
annualizationPeriods: periods,
|
|
459
558
|
maxDrawdown,
|
|
460
559
|
maxDrawdownPct: maxDrawdown,
|
|
461
|
-
calmar,
|
|
560
|
+
calmar: clampFinite(calmar),
|
|
462
561
|
maxConsecWins: maxWin,
|
|
463
562
|
maxConsecLosses: maxLoss,
|
|
464
563
|
avgHold: avgHoldMin,
|
|
@@ -468,12 +567,13 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
|
|
|
468
567
|
returnPct,
|
|
469
568
|
finalEquity: equityFinal,
|
|
470
569
|
startEquity: equityStart,
|
|
471
|
-
profitFactor_pos: profitFactorPositions,
|
|
472
|
-
profitFactor_leg: profitFactorLegs,
|
|
570
|
+
profitFactor_pos: clampFinite(profitFactorPositions),
|
|
571
|
+
profitFactor_leg: clampFinite(profitFactorLegs),
|
|
473
572
|
winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
474
573
|
winRate_leg: legs.length ? winningLegCount / legs.length : 0,
|
|
475
|
-
sharpeDaily,
|
|
476
|
-
sortinoDaily,
|
|
574
|
+
sharpeDaily: clampFinite(sharpeDaily),
|
|
575
|
+
sortinoDaily: clampFinite(sortinoDaily),
|
|
576
|
+
benchmark,
|
|
477
577
|
sideBreakdown,
|
|
478
578
|
long: sideBreakdown.long,
|
|
479
579
|
short: sideBreakdown.short,
|
|
@@ -482,7 +582,7 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
|
|
|
482
582
|
daily: {
|
|
483
583
|
count: dailyReturnsSeries.length,
|
|
484
584
|
winRate: dailyWinRate,
|
|
485
|
-
avgReturn:
|
|
585
|
+
avgReturn: mean2(dailyReturnsSeries)
|
|
486
586
|
}
|
|
487
587
|
};
|
|
488
588
|
}
|
|
@@ -939,6 +1039,30 @@ function dayKeyET(timeMs) {
|
|
|
939
1039
|
const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1e3 + minutesETDay * 60 * 1e3;
|
|
940
1040
|
return dayKeyUTC2(pseudoEtTime);
|
|
941
1041
|
}
|
|
1042
|
+
var MS_PER_YEAR2 = 365 * 24 * 60 * 60 * 1e3;
|
|
1043
|
+
function fundingEvents(fromMs, toMs, intervalMs, anchorMs = 0) {
|
|
1044
|
+
if (!(intervalMs > 0) || toMs <= fromMs) return 0;
|
|
1045
|
+
const firstK = Math.floor((fromMs - anchorMs) / intervalMs) + 1;
|
|
1046
|
+
const lastK = Math.floor((toMs - anchorMs) / intervalMs);
|
|
1047
|
+
return Math.max(0, lastK - firstK + 1);
|
|
1048
|
+
}
|
|
1049
|
+
function financingCost({ side, notional, fromMs, toMs, costs }) {
|
|
1050
|
+
const model = costs || {};
|
|
1051
|
+
const absNotional = Math.abs(notional);
|
|
1052
|
+
let cost = 0;
|
|
1053
|
+
if (model.carry) {
|
|
1054
|
+
const annualBps = side === "long" ? model.carry.longAnnualBps ?? 0 : model.carry.shortAnnualBps ?? 0;
|
|
1055
|
+
const years = Math.max(0, toMs - fromMs) / MS_PER_YEAR2;
|
|
1056
|
+
cost += absNotional * (annualBps / 1e4) * years;
|
|
1057
|
+
}
|
|
1058
|
+
const funding = model.funding;
|
|
1059
|
+
if (funding && funding.intervalMs > 0 && Number.isFinite(funding.rateBps)) {
|
|
1060
|
+
const count = fundingEvents(fromMs, toMs, funding.intervalMs, funding.anchorMs ?? 0);
|
|
1061
|
+
const perEvent = absNotional * (funding.rateBps / 1e4);
|
|
1062
|
+
cost += (side === "long" ? 1 : -1) * perEvent * count;
|
|
1063
|
+
}
|
|
1064
|
+
return cost;
|
|
1065
|
+
}
|
|
942
1066
|
|
|
943
1067
|
// src/engine/backtest.js
|
|
944
1068
|
function asNumber(value) {
|
|
@@ -1070,6 +1194,7 @@ function mergeOptions(options) {
|
|
|
1070
1194
|
maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
|
|
1071
1195
|
collectEqSeries: options.collectEqSeries ?? true,
|
|
1072
1196
|
collectReplay: options.collectReplay ?? true,
|
|
1197
|
+
benchmarkReturns: Array.isArray(options.benchmarkReturns) ? options.benchmarkReturns : null,
|
|
1073
1198
|
strict: options.strict ?? false
|
|
1074
1199
|
};
|
|
1075
1200
|
}
|
|
@@ -1192,7 +1317,14 @@ function backtest(rawOptions) {
|
|
|
1192
1317
|
const entryFill = openPos.entryFill;
|
|
1193
1318
|
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
1194
1319
|
const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
1195
|
-
const
|
|
1320
|
+
const financing = financingCost({
|
|
1321
|
+
side: openPos.side,
|
|
1322
|
+
notional: entryFill * qty,
|
|
1323
|
+
fromMs: openPos.openTime,
|
|
1324
|
+
toMs: time,
|
|
1325
|
+
costs
|
|
1326
|
+
});
|
|
1327
|
+
const pnl = grossPnl - entryFeePortion - exitFeeTotal - financing;
|
|
1196
1328
|
currentEquity += pnl;
|
|
1197
1329
|
dayPnl += pnl;
|
|
1198
1330
|
if (wantEqSeries) {
|
|
@@ -1220,6 +1352,7 @@ function backtest(rawOptions) {
|
|
|
1220
1352
|
time,
|
|
1221
1353
|
reason,
|
|
1222
1354
|
pnl,
|
|
1355
|
+
financing,
|
|
1223
1356
|
exitATR: openPos._lastATR ?? void 0
|
|
1224
1357
|
},
|
|
1225
1358
|
mfeR: openPos._mfeR ?? 0,
|
|
@@ -1622,7 +1755,9 @@ function backtest(rawOptions) {
|
|
|
1622
1755
|
equityFinal: currentEquity,
|
|
1623
1756
|
candles,
|
|
1624
1757
|
estBarMs: estimatedBarMs,
|
|
1625
|
-
eqSeries
|
|
1758
|
+
eqSeries,
|
|
1759
|
+
interval: options.interval,
|
|
1760
|
+
benchmarkReturns: options.benchmarkReturns
|
|
1626
1761
|
});
|
|
1627
1762
|
const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
1628
1763
|
const lastPrice = asNumber(candles[candles.length - 1]?.close);
|
|
@@ -1643,11 +1778,31 @@ function backtest(rawOptions) {
|
|
|
1643
1778
|
};
|
|
1644
1779
|
}
|
|
1645
1780
|
|
|
1646
|
-
// src/engine/
|
|
1781
|
+
// src/engine/barSystemRunner.js
|
|
1647
1782
|
function asNumber2(value) {
|
|
1648
1783
|
const numeric = Number(value);
|
|
1649
1784
|
return Number.isFinite(numeric) ? numeric : null;
|
|
1650
1785
|
}
|
|
1786
|
+
function equityPoint2(time, equity, extra = {}) {
|
|
1787
|
+
return { time, timestamp: time, equity, ...extra };
|
|
1788
|
+
}
|
|
1789
|
+
function isArrayIndexKey2(property) {
|
|
1790
|
+
if (typeof property !== "string") return false;
|
|
1791
|
+
const numeric = Number(property);
|
|
1792
|
+
return Number.isInteger(numeric) && numeric >= 0;
|
|
1793
|
+
}
|
|
1794
|
+
function strictHistoryView2(candles, currentIndex) {
|
|
1795
|
+
return new Proxy(candles, {
|
|
1796
|
+
get(target, property, receiver) {
|
|
1797
|
+
if (isArrayIndexKey2(property) && Number(property) >= target.length) {
|
|
1798
|
+
throw new Error(
|
|
1799
|
+
`strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
return Reflect.get(target, property, receiver);
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1651
1806
|
function describeValue2(value) {
|
|
1652
1807
|
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
1653
1808
|
if (value === null) return "null";
|
|
@@ -1666,38 +1821,45 @@ function callSignalWithContext2({ signal, context, index, bar, symbol }) {
|
|
|
1666
1821
|
);
|
|
1667
1822
|
}
|
|
1668
1823
|
}
|
|
1824
|
+
async function callSignalWithContextAsync({ signal, context, index, bar, symbol }) {
|
|
1825
|
+
try {
|
|
1826
|
+
return await signal(context);
|
|
1827
|
+
} catch (error) {
|
|
1828
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
1829
|
+
throw new Error(
|
|
1830
|
+
`signal() threw at index=${index}, time=${formatIsoTime2(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
function snapshotOpenPosition2(open, markPrice) {
|
|
1835
|
+
if (!open) return null;
|
|
1836
|
+
const entryPrice = open.entryFill ?? open.entry;
|
|
1837
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
1838
|
+
const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
|
|
1839
|
+
return {
|
|
1840
|
+
id: open.id,
|
|
1841
|
+
symbol: open.symbol,
|
|
1842
|
+
side: open.side,
|
|
1843
|
+
size: open.size,
|
|
1844
|
+
entry: open.entry,
|
|
1845
|
+
entryFill: open.entryFill,
|
|
1846
|
+
stop: open.stop,
|
|
1847
|
+
takeProfit: open.takeProfit,
|
|
1848
|
+
openTime: open.openTime,
|
|
1849
|
+
markPrice,
|
|
1850
|
+
unrealizedPnl,
|
|
1851
|
+
_initRisk: open._initRisk
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1669
1854
|
function normalizeSide2(value) {
|
|
1670
1855
|
if (value === "long" || value === "buy") return "long";
|
|
1671
1856
|
if (value === "short" || value === "sell") return "short";
|
|
1672
1857
|
return null;
|
|
1673
1858
|
}
|
|
1674
|
-
function normalizeTick(tick) {
|
|
1675
|
-
const time = Number(tick?.time);
|
|
1676
|
-
const bid = asNumber2(tick?.bid);
|
|
1677
|
-
const ask = asNumber2(tick?.ask);
|
|
1678
|
-
const last = asNumber2(tick?.price ?? tick?.last ?? tick?.close);
|
|
1679
|
-
const mid = bid !== null && ask !== null ? (bid + ask) / 2 : last ?? bid ?? ask;
|
|
1680
|
-
if (!Number.isFinite(time) || !Number.isFinite(mid)) return null;
|
|
1681
|
-
const prices = [asNumber2(tick?.low), asNumber2(tick?.high), bid, ask, last, mid].filter(
|
|
1682
|
-
Number.isFinite
|
|
1683
|
-
);
|
|
1684
|
-
const low = prices.length ? Math.min(...prices) : mid;
|
|
1685
|
-
const high = prices.length ? Math.max(...prices) : mid;
|
|
1686
|
-
return {
|
|
1687
|
-
...tick,
|
|
1688
|
-
time,
|
|
1689
|
-
open: mid,
|
|
1690
|
-
high,
|
|
1691
|
-
low,
|
|
1692
|
-
close: mid,
|
|
1693
|
-
volume: asNumber2(tick?.size ?? tick?.volume) ?? void 0
|
|
1694
|
-
};
|
|
1695
|
-
}
|
|
1696
1859
|
function normalizeSignal2(signal, bar, fallbackR) {
|
|
1697
1860
|
if (!signal) return null;
|
|
1698
1861
|
const side = normalizeSide2(signal.side ?? signal.direction ?? signal.action);
|
|
1699
1862
|
if (!side) return null;
|
|
1700
|
-
const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
|
|
1701
1863
|
const entry = asNumber2(signal.entry ?? signal.limit ?? signal.price) ?? asNumber2(bar?.close);
|
|
1702
1864
|
const stop = asNumber2(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
1703
1865
|
if (entry === null || stop === null) return null;
|
|
@@ -1719,804 +1881,376 @@ function normalizeSignal2(signal, bar, fallbackR) {
|
|
|
1719
1881
|
qty: asNumber2(signal.qty ?? signal.size),
|
|
1720
1882
|
riskPct: asNumber2(signal.riskPct),
|
|
1721
1883
|
riskFraction: asNumber2(signal.riskFraction),
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
}
|
|
1725
|
-
function equityPoint2(time, equity) {
|
|
1726
|
-
return { time, timestamp: time, equity };
|
|
1727
|
-
}
|
|
1728
|
-
function xmur3(seed) {
|
|
1729
|
-
let hash = 1779033703 ^ seed.length;
|
|
1730
|
-
for (let index = 0; index < seed.length; index += 1) {
|
|
1731
|
-
hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353);
|
|
1732
|
-
hash = hash << 13 | hash >>> 19;
|
|
1733
|
-
}
|
|
1734
|
-
return () => {
|
|
1735
|
-
hash = Math.imul(hash ^ hash >>> 16, 2246822507);
|
|
1736
|
-
hash = Math.imul(hash ^ hash >>> 13, 3266489909);
|
|
1737
|
-
return (hash ^= hash >>> 16) >>> 0;
|
|
1884
|
+
_rr: rrHint ?? signal._rr,
|
|
1885
|
+
_initRisk: asNumber2(signal._initRisk) ?? signal._initRisk
|
|
1738
1886
|
};
|
|
1739
1887
|
}
|
|
1740
|
-
function
|
|
1741
|
-
|
|
1742
|
-
return
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1888
|
+
function mergeOptions2(options) {
|
|
1889
|
+
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
1890
|
+
return {
|
|
1891
|
+
candles: normalizeCandles(options.candles ?? []),
|
|
1892
|
+
symbol: options.symbol ?? "UNKNOWN",
|
|
1893
|
+
equity: options.equity ?? 1e4,
|
|
1894
|
+
riskPct: normalizedRiskPct ?? 1,
|
|
1895
|
+
signal: options.signal,
|
|
1896
|
+
interval: options.interval,
|
|
1897
|
+
range: options.range,
|
|
1898
|
+
warmupBars: options.warmupBars ?? 200,
|
|
1899
|
+
slippageBps: options.slippageBps ?? 1,
|
|
1900
|
+
feeBps: options.feeBps ?? 0,
|
|
1901
|
+
costs: options.costs ?? null,
|
|
1902
|
+
scaleOutAtR: options.scaleOutAtR ?? 1,
|
|
1903
|
+
scaleOutFrac: options.scaleOutFrac ?? 0.5,
|
|
1904
|
+
finalTP_R: options.finalTP_R ?? 3,
|
|
1905
|
+
maxDailyLossPct: options.maxDailyLossPct ?? 2,
|
|
1906
|
+
atrTrailMult: options.atrTrailMult ?? 0,
|
|
1907
|
+
atrTrailPeriod: options.atrTrailPeriod ?? 14,
|
|
1908
|
+
oco: {
|
|
1909
|
+
mode: "intrabar",
|
|
1910
|
+
tieBreak: "pessimistic",
|
|
1911
|
+
clampStops: true,
|
|
1912
|
+
clampEpsBps: 0.25,
|
|
1913
|
+
...options.oco || {}
|
|
1914
|
+
},
|
|
1915
|
+
triggerMode: options.triggerMode,
|
|
1916
|
+
flattenAtClose: options.flattenAtClose ?? true,
|
|
1917
|
+
dailyMaxTrades: options.dailyMaxTrades ?? 0,
|
|
1918
|
+
postLossCooldownBars: options.postLossCooldownBars ?? 0,
|
|
1919
|
+
mfeTrail: {
|
|
1920
|
+
enabled: false,
|
|
1921
|
+
armR: 1,
|
|
1922
|
+
givebackR: 0.5,
|
|
1923
|
+
...options.mfeTrail || {}
|
|
1924
|
+
},
|
|
1925
|
+
pyramiding: {
|
|
1926
|
+
enabled: false,
|
|
1927
|
+
addAtR: 1,
|
|
1928
|
+
addFrac: 0.25,
|
|
1929
|
+
maxAdds: 1,
|
|
1930
|
+
onlyAfterBreakEven: true,
|
|
1931
|
+
...options.pyramiding || {}
|
|
1932
|
+
},
|
|
1933
|
+
volScale: {
|
|
1934
|
+
enabled: false,
|
|
1935
|
+
atrPeriod: options.atrTrailPeriod ?? 14,
|
|
1936
|
+
cutIfAtrX: 1.3,
|
|
1937
|
+
cutFrac: 0.33,
|
|
1938
|
+
noCutAboveR: 1.5,
|
|
1939
|
+
...options.volScale || {}
|
|
1940
|
+
},
|
|
1941
|
+
qtyStep: options.qtyStep ?? 1e-3,
|
|
1942
|
+
minQty: options.minQty ?? 1e-3,
|
|
1943
|
+
maxLeverage: options.maxLeverage ?? 2,
|
|
1944
|
+
entryChase: {
|
|
1945
|
+
enabled: true,
|
|
1946
|
+
afterBars: 2,
|
|
1947
|
+
maxSlipR: 0.2,
|
|
1948
|
+
convertOnExpiry: false,
|
|
1949
|
+
...options.entryChase || {}
|
|
1950
|
+
},
|
|
1951
|
+
reanchorStopOnFill: options.reanchorStopOnFill ?? true,
|
|
1952
|
+
maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
|
|
1953
|
+
collectEqSeries: options.collectEqSeries ?? true,
|
|
1954
|
+
collectReplay: options.collectReplay ?? true,
|
|
1955
|
+
strict: options.strict ?? false
|
|
1747
1956
|
};
|
|
1748
1957
|
}
|
|
1749
|
-
function
|
|
1750
|
-
const
|
|
1751
|
-
|
|
1752
|
-
return mulberry32(seedFn())();
|
|
1753
|
-
}
|
|
1754
|
-
function deterministicFill(probability, seedParts) {
|
|
1755
|
-
if (probability >= 1) return true;
|
|
1756
|
-
if (probability <= 0) return false;
|
|
1757
|
-
const normalized = seededUnitInterval(seedParts);
|
|
1758
|
-
return normalized <= probability;
|
|
1958
|
+
function capitalForSize(entryPrice, size, maxLeverage) {
|
|
1959
|
+
const leverage = Math.max(1, Number(maxLeverage) || 1);
|
|
1960
|
+
return Math.abs(entryPrice) * Math.max(0, size) / leverage;
|
|
1759
1961
|
}
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
);
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1962
|
+
var BarSystemRunner = class {
|
|
1963
|
+
constructor(rawOptions = {}) {
|
|
1964
|
+
this.options = mergeOptions2(rawOptions);
|
|
1965
|
+
const { candles, signal } = this.options;
|
|
1966
|
+
if (!Array.isArray(candles) || candles.length === 0) {
|
|
1967
|
+
throw new Error(
|
|
1968
|
+
`backtestPortfolio() requires each system to include non-empty candles, got ${describeValue2(
|
|
1969
|
+
candles
|
|
1970
|
+
)} for ${this.options.symbol}`
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
if (typeof signal !== "function") {
|
|
1974
|
+
throw new Error(
|
|
1975
|
+
`backtestPortfolio() requires each system to include a signal function, got ${describeValue2(
|
|
1976
|
+
signal
|
|
1977
|
+
)} for ${this.options.symbol}`
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
this.symbol = this.options.symbol;
|
|
1981
|
+
this.candles = candles;
|
|
1982
|
+
this.closed = [];
|
|
1983
|
+
this.currentEquity = this.options.equity;
|
|
1984
|
+
this.open = null;
|
|
1985
|
+
this.cooldown = 0;
|
|
1986
|
+
this.pending = null;
|
|
1987
|
+
this.currentDay = null;
|
|
1988
|
+
this.dayPnl = 0;
|
|
1989
|
+
this.dayTrades = 0;
|
|
1990
|
+
this.dayEquityStart = this.options.equity;
|
|
1991
|
+
this.tradeIdCounter = 0;
|
|
1992
|
+
this.estimatedBarMs = estimateBarMs(candles);
|
|
1993
|
+
const atrSourcePeriod = this.options.volScale.enabled ? this.options.volScale.atrPeriod : this.options.atrTrailPeriod;
|
|
1994
|
+
const needAtr = this.options.atrTrailMult > 0 || this.options.volScale.enabled;
|
|
1995
|
+
this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
|
|
1996
|
+
this.wantEqSeries = Boolean(this.options.collectEqSeries);
|
|
1997
|
+
this.wantReplay = Boolean(this.options.collectReplay);
|
|
1998
|
+
this.eqSeries = this.wantEqSeries ? [equityPoint2(candles[0].time, this.currentEquity)] : [];
|
|
1999
|
+
this.replayFrames = this.wantReplay ? [] : [];
|
|
2000
|
+
this.replayEvents = this.wantReplay ? [] : [];
|
|
2001
|
+
this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
|
|
2002
|
+
this.history = candles.slice(0, this.startIndex);
|
|
2003
|
+
this.index = this.startIndex;
|
|
2004
|
+
this.lastBar = this.history.length ? this.history[this.history.length - 1] : null;
|
|
2005
|
+
}
|
|
2006
|
+
hasNext() {
|
|
2007
|
+
return this.index < this.candles.length;
|
|
2008
|
+
}
|
|
2009
|
+
peekTime() {
|
|
2010
|
+
return this.hasNext() ? this.candles[this.index].time : Infinity;
|
|
2011
|
+
}
|
|
2012
|
+
getLockedCapital() {
|
|
2013
|
+
if (!this.open) return 0;
|
|
2014
|
+
return capitalForSize(
|
|
2015
|
+
this.open.entryFill ?? this.open.entry,
|
|
2016
|
+
this.open.size,
|
|
2017
|
+
this.options.maxLeverage
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
getMarkPrice() {
|
|
2021
|
+
return this.lastBar?.close ?? null;
|
|
2022
|
+
}
|
|
2023
|
+
getMarkedEquity() {
|
|
2024
|
+
if (!this.open || !this.lastBar) return this.currentEquity;
|
|
2025
|
+
const direction = this.open.side === "long" ? 1 : -1;
|
|
2026
|
+
const markPnl = (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
|
|
2027
|
+
return this.currentEquity + markPnl;
|
|
2028
|
+
}
|
|
2029
|
+
recordFrame(bar, extraFrame = {}) {
|
|
2030
|
+
if (this.wantEqSeries) {
|
|
2031
|
+
this.eqSeries.push(equityPoint2(bar.time, this.currentEquity));
|
|
2032
|
+
}
|
|
2033
|
+
if (this.wantReplay) {
|
|
2034
|
+
this.replayFrames.push({
|
|
2035
|
+
t: new Date(bar.time).toISOString(),
|
|
2036
|
+
price: bar.close,
|
|
2037
|
+
equity: this.currentEquity,
|
|
2038
|
+
posSide: this.open ? this.open.side : null,
|
|
2039
|
+
posSize: this.open ? this.open.size : 0,
|
|
2040
|
+
...extraFrame
|
|
1831
2041
|
});
|
|
1832
2042
|
}
|
|
1833
2043
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
const
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2044
|
+
closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
|
|
2045
|
+
const direction = openPos.side === "long" ? 1 : -1;
|
|
2046
|
+
const entryFill = openPos.entryFill;
|
|
2047
|
+
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
2048
|
+
const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
2049
|
+
const financing = financingCost({
|
|
2050
|
+
side: openPos.side,
|
|
2051
|
+
notional: entryFill * qty,
|
|
2052
|
+
fromMs: openPos.openTime,
|
|
2053
|
+
toMs: time,
|
|
2054
|
+
costs: this.options.costs
|
|
1843
2055
|
});
|
|
1844
|
-
const
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
replayEvents.push({
|
|
1861
|
-
t: new Date(tick.time).toISOString(),
|
|
1862
|
-
price,
|
|
1863
|
-
type: reason === "TP" ? "tp" : reason === "SL" ? "sl" : "exit",
|
|
1864
|
-
side: open.side,
|
|
1865
|
-
size: open.size,
|
|
1866
|
-
tradeId: open.id,
|
|
2056
|
+
const pnl = grossPnl - entryFeePortion - exitFeeTotal - financing;
|
|
2057
|
+
this.currentEquity += pnl;
|
|
2058
|
+
this.dayPnl += pnl;
|
|
2059
|
+
if (this.wantEqSeries) {
|
|
2060
|
+
this.eqSeries.push(equityPoint2(time, this.currentEquity));
|
|
2061
|
+
}
|
|
2062
|
+
const remaining = openPos.size - qty;
|
|
2063
|
+
const eventType = reason === "SCALE" ? "scale-out" : reason === "TP" ? "tp" : reason === "SL" ? "sl" : reason === "EOD" ? "eod" : remaining <= 0 ? "exit" : "scale-out";
|
|
2064
|
+
if (this.wantReplay) {
|
|
2065
|
+
this.replayEvents.push({
|
|
2066
|
+
t: new Date(time).toISOString(),
|
|
2067
|
+
price: exitPx,
|
|
2068
|
+
type: eventType,
|
|
2069
|
+
side: openPos.side,
|
|
2070
|
+
size: qty,
|
|
2071
|
+
tradeId: openPos.id,
|
|
1867
2072
|
reason,
|
|
1868
|
-
pnl
|
|
2073
|
+
pnl,
|
|
2074
|
+
symbol: this.symbol
|
|
1869
2075
|
});
|
|
1870
2076
|
}
|
|
1871
|
-
|
|
2077
|
+
const record = {
|
|
2078
|
+
...openPos,
|
|
2079
|
+
size: qty,
|
|
2080
|
+
exit: {
|
|
2081
|
+
price: exitPx,
|
|
2082
|
+
time,
|
|
2083
|
+
reason,
|
|
2084
|
+
pnl,
|
|
2085
|
+
financing,
|
|
2086
|
+
exitATR: openPos._lastATR ?? void 0
|
|
2087
|
+
},
|
|
2088
|
+
mfeR: openPos._mfeR ?? 0,
|
|
2089
|
+
maeR: openPos._maeR ?? 0,
|
|
2090
|
+
adds: openPos._adds ?? 0
|
|
2091
|
+
};
|
|
2092
|
+
this.closed.push(record);
|
|
2093
|
+
openPos.size -= qty;
|
|
2094
|
+
openPos._realized = (openPos._realized || 0) + pnl;
|
|
2095
|
+
return record;
|
|
1872
2096
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2097
|
+
tightenStopToNetBreakeven(openPos, lastClose) {
|
|
2098
|
+
if (!openPos || openPos.size <= 0) return;
|
|
2099
|
+
const realized = openPos._realized || 0;
|
|
2100
|
+
if (realized <= 0) return;
|
|
2101
|
+
const direction = openPos.side === "long" ? 1 : -1;
|
|
2102
|
+
const breakevenDelta = Math.abs(realized / openPos.size);
|
|
2103
|
+
const breakevenPrice = direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
|
|
2104
|
+
const tightened = direction === 1 ? Math.max(openPos.stop, breakevenPrice) : Math.min(openPos.stop, breakevenPrice);
|
|
2105
|
+
openPos.stop = this.options.oco.clampStops ? clampStop(lastClose, tightened, openPos.side, this.options.oco) : tightened;
|
|
2106
|
+
}
|
|
2107
|
+
forceExit(reason, bar, overridePrice = null) {
|
|
2108
|
+
if (!this.open || !bar) return;
|
|
2109
|
+
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
2110
|
+
const exitBasePrice = overridePrice ?? bar.close;
|
|
2111
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(exitBasePrice, exitSide, {
|
|
2112
|
+
slippageBps: this.options.slippageBps,
|
|
2113
|
+
feeBps: this.options.feeBps,
|
|
2114
|
+
kind: "market",
|
|
2115
|
+
qty: this.open.size,
|
|
2116
|
+
costs: this.options.costs
|
|
2117
|
+
});
|
|
2118
|
+
this.closeLeg({
|
|
2119
|
+
openPos: this.open,
|
|
2120
|
+
qty: this.open.size,
|
|
2121
|
+
exitPx: filled,
|
|
2122
|
+
exitFeeTotal,
|
|
2123
|
+
time: bar.time,
|
|
2124
|
+
reason
|
|
2125
|
+
});
|
|
2126
|
+
this.cooldown = this.open?._cooldownBars || 0;
|
|
2127
|
+
this.open = null;
|
|
2128
|
+
}
|
|
2129
|
+
cancelPending() {
|
|
2130
|
+
this.pending = null;
|
|
2131
|
+
}
|
|
2132
|
+
openFromPending(bar, signalEquity, entryPrice, fillKind = "limit", resolveEntrySize) {
|
|
2133
|
+
if (!this.pending) return false;
|
|
2134
|
+
const plannedRisk = Math.max(
|
|
2135
|
+
1e-8,
|
|
2136
|
+
this.pending.plannedRiskAbs ?? Math.abs(this.pending.entry - this.pending.stop)
|
|
2137
|
+
);
|
|
2138
|
+
const slipR = Math.abs(entryPrice - this.pending.entry) / plannedRisk;
|
|
2139
|
+
if (slipR > this.options.maxSlipROnFill) return false;
|
|
2140
|
+
let stopPrice = this.pending.stop;
|
|
2141
|
+
if (this.options.reanchorStopOnFill) {
|
|
2142
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2143
|
+
stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
|
|
1882
2144
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
});
|
|
1892
|
-
if (hit) {
|
|
1893
|
-
closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
|
|
2145
|
+
let takeProfit = this.pending.tp;
|
|
2146
|
+
const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
|
|
2147
|
+
const rrHint = this.pending.meta?._rr;
|
|
2148
|
+
if (this.options.reanchorStopOnFill && Number.isFinite(rrHint)) {
|
|
2149
|
+
const plannedTarget = this.pending.side === "long" ? this.pending.entry + rrHint * plannedRisk : this.pending.entry - rrHint * plannedRisk;
|
|
2150
|
+
const closeEnough = Math.abs((this.pending.tp ?? plannedTarget) - plannedTarget) <= Math.max(1e-8, plannedRisk * 1e-6);
|
|
2151
|
+
if (closeEnough) {
|
|
2152
|
+
takeProfit = this.pending.side === "long" ? entryPrice + rrHint * immediateRisk : entryPrice - rrHint * immediateRisk;
|
|
1894
2153
|
}
|
|
1895
2154
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
if (touched && deterministicFill(queueFillProbability, [
|
|
1945
|
-
symbol,
|
|
1946
|
-
tick.time,
|
|
1947
|
-
pending.entry,
|
|
1948
|
-
pending.stop,
|
|
1949
|
-
pending.side
|
|
1950
|
-
])) {
|
|
1951
|
-
const rawSize = pending.fixedQty ?? calculatePositionSize({
|
|
1952
|
-
equity: currentEquity,
|
|
1953
|
-
entry: pending.entry,
|
|
1954
|
-
stop: pending.stop,
|
|
1955
|
-
riskFraction: pending.riskFrac,
|
|
1956
|
-
qtyStep,
|
|
1957
|
-
minQty,
|
|
1958
|
-
maxLeverage
|
|
1959
|
-
});
|
|
1960
|
-
const size = roundStep2(rawSize, qtyStep);
|
|
1961
|
-
if (size >= minQty) {
|
|
1962
|
-
const { price, feeTotal } = applyFill(pending.entry, pending.side, {
|
|
1963
|
-
slippageBps,
|
|
1964
|
-
feeBps,
|
|
1965
|
-
kind: "limit",
|
|
1966
|
-
qty: size,
|
|
1967
|
-
costs
|
|
1968
|
-
});
|
|
1969
|
-
open = {
|
|
1970
|
-
symbol,
|
|
1971
|
-
id: ++tradeIdCounter,
|
|
1972
|
-
side: pending.side,
|
|
1973
|
-
entry: pending.entry,
|
|
1974
|
-
stop: pending.stop,
|
|
1975
|
-
takeProfit: pending.takeProfit,
|
|
1976
|
-
size,
|
|
1977
|
-
openTime: tick.time,
|
|
1978
|
-
entryFill: price,
|
|
1979
|
-
entryFeeTotal: feeTotal,
|
|
1980
|
-
_initRisk: Math.abs(pending.entry - pending.stop)
|
|
1981
|
-
};
|
|
1982
|
-
dayTrades += 1;
|
|
1983
|
-
if (collectReplay) {
|
|
1984
|
-
replayEvents.push({
|
|
1985
|
-
t: new Date(tick.time).toISOString(),
|
|
1986
|
-
price,
|
|
1987
|
-
type: "entry",
|
|
1988
|
-
side: open.side,
|
|
1989
|
-
size,
|
|
1990
|
-
tradeId: open.id
|
|
1991
|
-
});
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
pending = null;
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
2155
|
+
const desiredSize = this.pending.fixedQty ?? calculatePositionSize({
|
|
2156
|
+
equity: signalEquity,
|
|
2157
|
+
entry: entryPrice,
|
|
2158
|
+
stop: stopPrice,
|
|
2159
|
+
riskFraction: this.pending.riskFrac,
|
|
2160
|
+
qtyStep: this.options.qtyStep,
|
|
2161
|
+
minQty: this.options.minQty,
|
|
2162
|
+
maxLeverage: this.options.maxLeverage
|
|
2163
|
+
});
|
|
2164
|
+
const approvedSize = typeof resolveEntrySize === "function" ? resolveEntrySize({
|
|
2165
|
+
runner: this,
|
|
2166
|
+
desiredSize,
|
|
2167
|
+
entryPrice,
|
|
2168
|
+
stopPrice,
|
|
2169
|
+
pending: this.pending,
|
|
2170
|
+
fillKind
|
|
2171
|
+
}) : desiredSize;
|
|
2172
|
+
const size = roundStep2(approvedSize, this.options.qtyStep);
|
|
2173
|
+
if (size < this.options.minQty) return false;
|
|
2174
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, this.pending.side, {
|
|
2175
|
+
slippageBps: this.options.slippageBps,
|
|
2176
|
+
feeBps: this.options.feeBps,
|
|
2177
|
+
kind: fillKind,
|
|
2178
|
+
qty: size,
|
|
2179
|
+
costs: this.options.costs
|
|
2180
|
+
});
|
|
2181
|
+
this.open = {
|
|
2182
|
+
symbol: this.symbol,
|
|
2183
|
+
...this.pending.meta,
|
|
2184
|
+
id: ++this.tradeIdCounter,
|
|
2185
|
+
side: this.pending.side,
|
|
2186
|
+
entry: entryPrice,
|
|
2187
|
+
stop: stopPrice,
|
|
2188
|
+
takeProfit,
|
|
2189
|
+
size,
|
|
2190
|
+
openTime: bar.time,
|
|
2191
|
+
entryFill,
|
|
2192
|
+
entryFeeTotal,
|
|
2193
|
+
initSize: size,
|
|
2194
|
+
baseSize: size,
|
|
2195
|
+
_mfeR: 0,
|
|
2196
|
+
_maeR: 0,
|
|
2197
|
+
_adds: 0,
|
|
2198
|
+
_initRisk: Math.abs(entryPrice - stopPrice) || 1e-8
|
|
2199
|
+
};
|
|
2200
|
+
if (this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2201
|
+
this.open.entryATR = this.atrValues[this.index];
|
|
2202
|
+
this.open._lastATR = this.atrValues[this.index];
|
|
1997
2203
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
openPosition: open,
|
|
2011
|
-
pendingOrder: pending
|
|
2012
|
-
},
|
|
2013
|
-
index,
|
|
2014
|
-
bar: tick,
|
|
2015
|
-
symbol
|
|
2016
|
-
}),
|
|
2017
|
-
tick,
|
|
2018
|
-
finalTP_R
|
|
2019
|
-
);
|
|
2020
|
-
if (nextSignal) {
|
|
2021
|
-
pending = {
|
|
2022
|
-
side: nextSignal.side,
|
|
2023
|
-
entry: nextSignal.entry,
|
|
2024
|
-
stop: nextSignal.stop,
|
|
2025
|
-
takeProfit: nextSignal.takeProfit,
|
|
2026
|
-
fixedQty: nextSignal.qty,
|
|
2027
|
-
riskFrac: Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : riskPct / 100,
|
|
2028
|
-
orderType: nextSignal.orderType,
|
|
2029
|
-
createdAtIndex: index
|
|
2030
|
-
};
|
|
2031
|
-
}
|
|
2204
|
+
this.dayTrades += 1;
|
|
2205
|
+
this.pending = null;
|
|
2206
|
+
if (this.wantReplay) {
|
|
2207
|
+
this.replayEvents.push({
|
|
2208
|
+
t: new Date(bar.time).toISOString(),
|
|
2209
|
+
price: entryFill,
|
|
2210
|
+
type: "entry",
|
|
2211
|
+
side: this.open.side,
|
|
2212
|
+
size,
|
|
2213
|
+
tradeId: this.open.id,
|
|
2214
|
+
symbol: this.symbol
|
|
2215
|
+
});
|
|
2032
2216
|
}
|
|
2033
|
-
|
|
2217
|
+
return true;
|
|
2034
2218
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2219
|
+
buildSignalContext(index, bar, signalEquity) {
|
|
2220
|
+
if (this.options.strict && this.history.length !== index + 1) {
|
|
2221
|
+
throw new Error(
|
|
2222
|
+
`strict mode: signal() received ${this.history.length} candles at index ${index}`
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
return {
|
|
2226
|
+
candles: this.options.strict ? strictHistoryView2(this.history, index) : this.history,
|
|
2227
|
+
index,
|
|
2228
|
+
bar,
|
|
2229
|
+
equity: signalEquity,
|
|
2230
|
+
openPosition: this.open,
|
|
2231
|
+
pendingOrder: this.pending
|
|
2232
|
+
};
|
|
2039
2233
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
range,
|
|
2053
|
-
trades,
|
|
2054
|
-
positions,
|
|
2055
|
-
openPositions: [],
|
|
2056
|
-
metrics,
|
|
2057
|
-
eqSeries,
|
|
2058
|
-
replay: {
|
|
2059
|
-
frames: replayFrames,
|
|
2060
|
-
events: replayEvents
|
|
2234
|
+
_preSignal({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
|
|
2235
|
+
if (!this.hasNext()) return { handled: true, bar: null };
|
|
2236
|
+
const bar = this.candles[this.index];
|
|
2237
|
+
this.history.push(bar);
|
|
2238
|
+
this.lastBar = bar;
|
|
2239
|
+
const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
|
|
2240
|
+
const dayKey = this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC2(bar.time);
|
|
2241
|
+
if (this.currentDay === null || dayKey !== this.currentDay) {
|
|
2242
|
+
this.currentDay = dayKey;
|
|
2243
|
+
this.dayPnl = 0;
|
|
2244
|
+
this.dayTrades = 0;
|
|
2245
|
+
this.dayEquityStart = this.currentEquity;
|
|
2061
2246
|
}
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
}
|
|
2070
|
-
function equityPoint3(time, equity, extra = {}) {
|
|
2071
|
-
return { time, timestamp: time, equity, ...extra };
|
|
2072
|
-
}
|
|
2073
|
-
function isArrayIndexKey2(property) {
|
|
2074
|
-
if (typeof property !== "string") return false;
|
|
2075
|
-
const numeric = Number(property);
|
|
2076
|
-
return Number.isInteger(numeric) && numeric >= 0;
|
|
2077
|
-
}
|
|
2078
|
-
function strictHistoryView2(candles, currentIndex) {
|
|
2079
|
-
return new Proxy(candles, {
|
|
2080
|
-
get(target, property, receiver) {
|
|
2081
|
-
if (isArrayIndexKey2(property) && Number(property) >= target.length) {
|
|
2082
|
-
throw new Error(
|
|
2083
|
-
`strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
|
|
2084
|
-
);
|
|
2085
|
-
}
|
|
2086
|
-
return Reflect.get(target, property, receiver);
|
|
2087
|
-
}
|
|
2088
|
-
});
|
|
2089
|
-
}
|
|
2090
|
-
function describeValue3(value) {
|
|
2091
|
-
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
2092
|
-
if (value === null) return "null";
|
|
2093
|
-
return typeof value;
|
|
2094
|
-
}
|
|
2095
|
-
function formatIsoTime3(time) {
|
|
2096
|
-
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
2097
|
-
}
|
|
2098
|
-
function callSignalWithContext3({ signal, context, index, bar, symbol }) {
|
|
2099
|
-
try {
|
|
2100
|
-
return signal(context);
|
|
2101
|
-
} catch (error) {
|
|
2102
|
-
const cause = error instanceof Error ? error.message : String(error);
|
|
2103
|
-
throw new Error(
|
|
2104
|
-
`signal() threw at index=${index}, time=${formatIsoTime3(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
2105
|
-
);
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
function snapshotOpenPosition2(open, markPrice) {
|
|
2109
|
-
if (!open) return null;
|
|
2110
|
-
const entryPrice = open.entryFill ?? open.entry;
|
|
2111
|
-
const direction = open.side === "long" ? 1 : -1;
|
|
2112
|
-
const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
|
|
2113
|
-
return {
|
|
2114
|
-
id: open.id,
|
|
2115
|
-
symbol: open.symbol,
|
|
2116
|
-
side: open.side,
|
|
2117
|
-
size: open.size,
|
|
2118
|
-
entry: open.entry,
|
|
2119
|
-
entryFill: open.entryFill,
|
|
2120
|
-
stop: open.stop,
|
|
2121
|
-
takeProfit: open.takeProfit,
|
|
2122
|
-
openTime: open.openTime,
|
|
2123
|
-
markPrice,
|
|
2124
|
-
unrealizedPnl,
|
|
2125
|
-
_initRisk: open._initRisk
|
|
2126
|
-
};
|
|
2127
|
-
}
|
|
2128
|
-
function normalizeSide3(value) {
|
|
2129
|
-
if (value === "long" || value === "buy") return "long";
|
|
2130
|
-
if (value === "short" || value === "sell") return "short";
|
|
2131
|
-
return null;
|
|
2132
|
-
}
|
|
2133
|
-
function normalizeSignal3(signal, bar, fallbackR) {
|
|
2134
|
-
if (!signal) return null;
|
|
2135
|
-
const side = normalizeSide3(signal.side ?? signal.direction ?? signal.action);
|
|
2136
|
-
if (!side) return null;
|
|
2137
|
-
const entry = asNumber3(signal.entry ?? signal.limit ?? signal.price) ?? asNumber3(bar?.close);
|
|
2138
|
-
const stop = asNumber3(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
2139
|
-
if (entry === null || stop === null) return null;
|
|
2140
|
-
const risk = Math.abs(entry - stop);
|
|
2141
|
-
if (!(risk > 0)) return null;
|
|
2142
|
-
let takeProfit = asNumber3(signal.takeProfit ?? signal.target ?? signal.tp);
|
|
2143
|
-
const rrHint = asNumber3(signal._rr ?? signal.rr);
|
|
2144
|
-
const targetR = rrHint ?? fallbackR;
|
|
2145
|
-
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
2146
|
-
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
2147
|
-
}
|
|
2148
|
-
if (takeProfit === null) return null;
|
|
2149
|
-
return {
|
|
2150
|
-
...signal,
|
|
2151
|
-
side,
|
|
2152
|
-
entry,
|
|
2153
|
-
stop,
|
|
2154
|
-
takeProfit,
|
|
2155
|
-
qty: asNumber3(signal.qty ?? signal.size),
|
|
2156
|
-
riskPct: asNumber3(signal.riskPct),
|
|
2157
|
-
riskFraction: asNumber3(signal.riskFraction),
|
|
2158
|
-
_rr: rrHint ?? signal._rr,
|
|
2159
|
-
_initRisk: asNumber3(signal._initRisk) ?? signal._initRisk
|
|
2160
|
-
};
|
|
2161
|
-
}
|
|
2162
|
-
function mergeOptions2(options) {
|
|
2163
|
-
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
2164
|
-
return {
|
|
2165
|
-
candles: normalizeCandles(options.candles ?? []),
|
|
2166
|
-
symbol: options.symbol ?? "UNKNOWN",
|
|
2167
|
-
equity: options.equity ?? 1e4,
|
|
2168
|
-
riskPct: normalizedRiskPct ?? 1,
|
|
2169
|
-
signal: options.signal,
|
|
2170
|
-
interval: options.interval,
|
|
2171
|
-
range: options.range,
|
|
2172
|
-
warmupBars: options.warmupBars ?? 200,
|
|
2173
|
-
slippageBps: options.slippageBps ?? 1,
|
|
2174
|
-
feeBps: options.feeBps ?? 0,
|
|
2175
|
-
costs: options.costs ?? null,
|
|
2176
|
-
scaleOutAtR: options.scaleOutAtR ?? 1,
|
|
2177
|
-
scaleOutFrac: options.scaleOutFrac ?? 0.5,
|
|
2178
|
-
finalTP_R: options.finalTP_R ?? 3,
|
|
2179
|
-
maxDailyLossPct: options.maxDailyLossPct ?? 2,
|
|
2180
|
-
atrTrailMult: options.atrTrailMult ?? 0,
|
|
2181
|
-
atrTrailPeriod: options.atrTrailPeriod ?? 14,
|
|
2182
|
-
oco: {
|
|
2183
|
-
mode: "intrabar",
|
|
2184
|
-
tieBreak: "pessimistic",
|
|
2185
|
-
clampStops: true,
|
|
2186
|
-
clampEpsBps: 0.25,
|
|
2187
|
-
...options.oco || {}
|
|
2188
|
-
},
|
|
2189
|
-
triggerMode: options.triggerMode,
|
|
2190
|
-
flattenAtClose: options.flattenAtClose ?? true,
|
|
2191
|
-
dailyMaxTrades: options.dailyMaxTrades ?? 0,
|
|
2192
|
-
postLossCooldownBars: options.postLossCooldownBars ?? 0,
|
|
2193
|
-
mfeTrail: {
|
|
2194
|
-
enabled: false,
|
|
2195
|
-
armR: 1,
|
|
2196
|
-
givebackR: 0.5,
|
|
2197
|
-
...options.mfeTrail || {}
|
|
2198
|
-
},
|
|
2199
|
-
pyramiding: {
|
|
2200
|
-
enabled: false,
|
|
2201
|
-
addAtR: 1,
|
|
2202
|
-
addFrac: 0.25,
|
|
2203
|
-
maxAdds: 1,
|
|
2204
|
-
onlyAfterBreakEven: true,
|
|
2205
|
-
...options.pyramiding || {}
|
|
2206
|
-
},
|
|
2207
|
-
volScale: {
|
|
2208
|
-
enabled: false,
|
|
2209
|
-
atrPeriod: options.atrTrailPeriod ?? 14,
|
|
2210
|
-
cutIfAtrX: 1.3,
|
|
2211
|
-
cutFrac: 0.33,
|
|
2212
|
-
noCutAboveR: 1.5,
|
|
2213
|
-
...options.volScale || {}
|
|
2214
|
-
},
|
|
2215
|
-
qtyStep: options.qtyStep ?? 1e-3,
|
|
2216
|
-
minQty: options.minQty ?? 1e-3,
|
|
2217
|
-
maxLeverage: options.maxLeverage ?? 2,
|
|
2218
|
-
entryChase: {
|
|
2219
|
-
enabled: true,
|
|
2220
|
-
afterBars: 2,
|
|
2221
|
-
maxSlipR: 0.2,
|
|
2222
|
-
convertOnExpiry: false,
|
|
2223
|
-
...options.entryChase || {}
|
|
2224
|
-
},
|
|
2225
|
-
reanchorStopOnFill: options.reanchorStopOnFill ?? true,
|
|
2226
|
-
maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
|
|
2227
|
-
collectEqSeries: options.collectEqSeries ?? true,
|
|
2228
|
-
collectReplay: options.collectReplay ?? true,
|
|
2229
|
-
strict: options.strict ?? false
|
|
2230
|
-
};
|
|
2231
|
-
}
|
|
2232
|
-
function capitalForSize(entryPrice, size, maxLeverage) {
|
|
2233
|
-
const leverage = Math.max(1, Number(maxLeverage) || 1);
|
|
2234
|
-
return Math.abs(entryPrice) * Math.max(0, size) / leverage;
|
|
2235
|
-
}
|
|
2236
|
-
var BarSystemRunner = class {
|
|
2237
|
-
constructor(rawOptions = {}) {
|
|
2238
|
-
this.options = mergeOptions2(rawOptions);
|
|
2239
|
-
const { candles, signal } = this.options;
|
|
2240
|
-
if (!Array.isArray(candles) || candles.length === 0) {
|
|
2241
|
-
throw new Error(
|
|
2242
|
-
`backtestPortfolio() requires each system to include non-empty candles, got ${describeValue3(
|
|
2243
|
-
candles
|
|
2244
|
-
)} for ${this.options.symbol}`
|
|
2245
|
-
);
|
|
2246
|
-
}
|
|
2247
|
-
if (typeof signal !== "function") {
|
|
2248
|
-
throw new Error(
|
|
2249
|
-
`backtestPortfolio() requires each system to include a signal function, got ${describeValue3(
|
|
2250
|
-
signal
|
|
2251
|
-
)} for ${this.options.symbol}`
|
|
2252
|
-
);
|
|
2253
|
-
}
|
|
2254
|
-
this.symbol = this.options.symbol;
|
|
2255
|
-
this.candles = candles;
|
|
2256
|
-
this.closed = [];
|
|
2257
|
-
this.currentEquity = this.options.equity;
|
|
2258
|
-
this.open = null;
|
|
2259
|
-
this.cooldown = 0;
|
|
2260
|
-
this.pending = null;
|
|
2261
|
-
this.currentDay = null;
|
|
2262
|
-
this.dayPnl = 0;
|
|
2263
|
-
this.dayTrades = 0;
|
|
2264
|
-
this.dayEquityStart = this.options.equity;
|
|
2265
|
-
this.tradeIdCounter = 0;
|
|
2266
|
-
this.estimatedBarMs = estimateBarMs(candles);
|
|
2267
|
-
const atrSourcePeriod = this.options.volScale.enabled ? this.options.volScale.atrPeriod : this.options.atrTrailPeriod;
|
|
2268
|
-
const needAtr = this.options.atrTrailMult > 0 || this.options.volScale.enabled;
|
|
2269
|
-
this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
|
|
2270
|
-
this.wantEqSeries = Boolean(this.options.collectEqSeries);
|
|
2271
|
-
this.wantReplay = Boolean(this.options.collectReplay);
|
|
2272
|
-
this.eqSeries = this.wantEqSeries ? [equityPoint3(candles[0].time, this.currentEquity)] : [];
|
|
2273
|
-
this.replayFrames = this.wantReplay ? [] : [];
|
|
2274
|
-
this.replayEvents = this.wantReplay ? [] : [];
|
|
2275
|
-
this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
|
|
2276
|
-
this.history = candles.slice(0, this.startIndex);
|
|
2277
|
-
this.index = this.startIndex;
|
|
2278
|
-
this.lastBar = this.history.length ? this.history[this.history.length - 1] : null;
|
|
2279
|
-
}
|
|
2280
|
-
hasNext() {
|
|
2281
|
-
return this.index < this.candles.length;
|
|
2282
|
-
}
|
|
2283
|
-
peekTime() {
|
|
2284
|
-
return this.hasNext() ? this.candles[this.index].time : Infinity;
|
|
2285
|
-
}
|
|
2286
|
-
getLockedCapital() {
|
|
2287
|
-
if (!this.open) return 0;
|
|
2288
|
-
return capitalForSize(
|
|
2289
|
-
this.open.entryFill ?? this.open.entry,
|
|
2290
|
-
this.open.size,
|
|
2291
|
-
this.options.maxLeverage
|
|
2292
|
-
);
|
|
2293
|
-
}
|
|
2294
|
-
getMarkPrice() {
|
|
2295
|
-
return this.lastBar?.close ?? null;
|
|
2296
|
-
}
|
|
2297
|
-
getMarkedEquity() {
|
|
2298
|
-
if (!this.open || !this.lastBar) return this.currentEquity;
|
|
2299
|
-
const direction = this.open.side === "long" ? 1 : -1;
|
|
2300
|
-
const markPnl = (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
|
|
2301
|
-
return this.currentEquity + markPnl;
|
|
2302
|
-
}
|
|
2303
|
-
recordFrame(bar, extraFrame = {}) {
|
|
2304
|
-
if (this.wantEqSeries) {
|
|
2305
|
-
this.eqSeries.push(equityPoint3(bar.time, this.currentEquity));
|
|
2306
|
-
}
|
|
2307
|
-
if (this.wantReplay) {
|
|
2308
|
-
this.replayFrames.push({
|
|
2309
|
-
t: new Date(bar.time).toISOString(),
|
|
2310
|
-
price: bar.close,
|
|
2311
|
-
equity: this.currentEquity,
|
|
2312
|
-
posSide: this.open ? this.open.side : null,
|
|
2313
|
-
posSize: this.open ? this.open.size : 0,
|
|
2314
|
-
...extraFrame
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
}
|
|
2318
|
-
closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
|
|
2319
|
-
const direction = openPos.side === "long" ? 1 : -1;
|
|
2320
|
-
const entryFill = openPos.entryFill;
|
|
2321
|
-
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
2322
|
-
const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
2323
|
-
const pnl = grossPnl - entryFeePortion - exitFeeTotal;
|
|
2324
|
-
this.currentEquity += pnl;
|
|
2325
|
-
this.dayPnl += pnl;
|
|
2326
|
-
if (this.wantEqSeries) {
|
|
2327
|
-
this.eqSeries.push(equityPoint3(time, this.currentEquity));
|
|
2328
|
-
}
|
|
2329
|
-
const remaining = openPos.size - qty;
|
|
2330
|
-
const eventType = reason === "SCALE" ? "scale-out" : reason === "TP" ? "tp" : reason === "SL" ? "sl" : reason === "EOD" ? "eod" : remaining <= 0 ? "exit" : "scale-out";
|
|
2331
|
-
if (this.wantReplay) {
|
|
2332
|
-
this.replayEvents.push({
|
|
2333
|
-
t: new Date(time).toISOString(),
|
|
2334
|
-
price: exitPx,
|
|
2335
|
-
type: eventType,
|
|
2336
|
-
side: openPos.side,
|
|
2337
|
-
size: qty,
|
|
2338
|
-
tradeId: openPos.id,
|
|
2339
|
-
reason,
|
|
2340
|
-
pnl,
|
|
2341
|
-
symbol: this.symbol
|
|
2342
|
-
});
|
|
2343
|
-
}
|
|
2344
|
-
const record = {
|
|
2345
|
-
...openPos,
|
|
2346
|
-
size: qty,
|
|
2347
|
-
exit: {
|
|
2348
|
-
price: exitPx,
|
|
2349
|
-
time,
|
|
2350
|
-
reason,
|
|
2351
|
-
pnl,
|
|
2352
|
-
exitATR: openPos._lastATR ?? void 0
|
|
2353
|
-
},
|
|
2354
|
-
mfeR: openPos._mfeR ?? 0,
|
|
2355
|
-
maeR: openPos._maeR ?? 0,
|
|
2356
|
-
adds: openPos._adds ?? 0
|
|
2357
|
-
};
|
|
2358
|
-
this.closed.push(record);
|
|
2359
|
-
openPos.size -= qty;
|
|
2360
|
-
openPos._realized = (openPos._realized || 0) + pnl;
|
|
2361
|
-
return record;
|
|
2362
|
-
}
|
|
2363
|
-
tightenStopToNetBreakeven(openPos, lastClose) {
|
|
2364
|
-
if (!openPos || openPos.size <= 0) return;
|
|
2365
|
-
const realized = openPos._realized || 0;
|
|
2366
|
-
if (realized <= 0) return;
|
|
2367
|
-
const direction = openPos.side === "long" ? 1 : -1;
|
|
2368
|
-
const breakevenDelta = Math.abs(realized / openPos.size);
|
|
2369
|
-
const breakevenPrice = direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
|
|
2370
|
-
const tightened = direction === 1 ? Math.max(openPos.stop, breakevenPrice) : Math.min(openPos.stop, breakevenPrice);
|
|
2371
|
-
openPos.stop = this.options.oco.clampStops ? clampStop(lastClose, tightened, openPos.side, this.options.oco) : tightened;
|
|
2372
|
-
}
|
|
2373
|
-
forceExit(reason, bar, overridePrice = null) {
|
|
2374
|
-
if (!this.open || !bar) return;
|
|
2375
|
-
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
2376
|
-
const exitBasePrice = overridePrice ?? bar.close;
|
|
2377
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(exitBasePrice, exitSide, {
|
|
2378
|
-
slippageBps: this.options.slippageBps,
|
|
2379
|
-
feeBps: this.options.feeBps,
|
|
2380
|
-
kind: "market",
|
|
2381
|
-
qty: this.open.size,
|
|
2382
|
-
costs: this.options.costs
|
|
2383
|
-
});
|
|
2384
|
-
this.closeLeg({
|
|
2385
|
-
openPos: this.open,
|
|
2386
|
-
qty: this.open.size,
|
|
2387
|
-
exitPx: filled,
|
|
2388
|
-
exitFeeTotal,
|
|
2389
|
-
time: bar.time,
|
|
2390
|
-
reason
|
|
2391
|
-
});
|
|
2392
|
-
this.cooldown = this.open?._cooldownBars || 0;
|
|
2393
|
-
this.open = null;
|
|
2394
|
-
}
|
|
2395
|
-
cancelPending() {
|
|
2396
|
-
this.pending = null;
|
|
2397
|
-
}
|
|
2398
|
-
openFromPending(bar, signalEquity, entryPrice, fillKind = "limit", resolveEntrySize) {
|
|
2399
|
-
if (!this.pending) return false;
|
|
2400
|
-
const plannedRisk = Math.max(
|
|
2401
|
-
1e-8,
|
|
2402
|
-
this.pending.plannedRiskAbs ?? Math.abs(this.pending.entry - this.pending.stop)
|
|
2403
|
-
);
|
|
2404
|
-
const slipR = Math.abs(entryPrice - this.pending.entry) / plannedRisk;
|
|
2405
|
-
if (slipR > this.options.maxSlipROnFill) return false;
|
|
2406
|
-
let stopPrice = this.pending.stop;
|
|
2407
|
-
if (this.options.reanchorStopOnFill) {
|
|
2408
|
-
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2409
|
-
stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
|
|
2410
|
-
}
|
|
2411
|
-
let takeProfit = this.pending.tp;
|
|
2412
|
-
const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
|
|
2413
|
-
const rrHint = this.pending.meta?._rr;
|
|
2414
|
-
if (this.options.reanchorStopOnFill && Number.isFinite(rrHint)) {
|
|
2415
|
-
const plannedTarget = this.pending.side === "long" ? this.pending.entry + rrHint * plannedRisk : this.pending.entry - rrHint * plannedRisk;
|
|
2416
|
-
const closeEnough = Math.abs((this.pending.tp ?? plannedTarget) - plannedTarget) <= Math.max(1e-8, plannedRisk * 1e-6);
|
|
2417
|
-
if (closeEnough) {
|
|
2418
|
-
takeProfit = this.pending.side === "long" ? entryPrice + rrHint * immediateRisk : entryPrice - rrHint * immediateRisk;
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
const desiredSize = this.pending.fixedQty ?? calculatePositionSize({
|
|
2422
|
-
equity: signalEquity,
|
|
2423
|
-
entry: entryPrice,
|
|
2424
|
-
stop: stopPrice,
|
|
2425
|
-
riskFraction: this.pending.riskFrac,
|
|
2426
|
-
qtyStep: this.options.qtyStep,
|
|
2427
|
-
minQty: this.options.minQty,
|
|
2428
|
-
maxLeverage: this.options.maxLeverage
|
|
2429
|
-
});
|
|
2430
|
-
const approvedSize = typeof resolveEntrySize === "function" ? resolveEntrySize({
|
|
2431
|
-
runner: this,
|
|
2432
|
-
desiredSize,
|
|
2433
|
-
entryPrice,
|
|
2434
|
-
stopPrice,
|
|
2435
|
-
pending: this.pending,
|
|
2436
|
-
fillKind
|
|
2437
|
-
}) : desiredSize;
|
|
2438
|
-
const size = roundStep2(approvedSize, this.options.qtyStep);
|
|
2439
|
-
if (size < this.options.minQty) return false;
|
|
2440
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, this.pending.side, {
|
|
2441
|
-
slippageBps: this.options.slippageBps,
|
|
2442
|
-
feeBps: this.options.feeBps,
|
|
2443
|
-
kind: fillKind,
|
|
2444
|
-
qty: size,
|
|
2445
|
-
costs: this.options.costs
|
|
2446
|
-
});
|
|
2447
|
-
this.open = {
|
|
2448
|
-
symbol: this.symbol,
|
|
2449
|
-
...this.pending.meta,
|
|
2450
|
-
id: ++this.tradeIdCounter,
|
|
2451
|
-
side: this.pending.side,
|
|
2452
|
-
entry: entryPrice,
|
|
2453
|
-
stop: stopPrice,
|
|
2454
|
-
takeProfit,
|
|
2455
|
-
size,
|
|
2456
|
-
openTime: bar.time,
|
|
2457
|
-
entryFill,
|
|
2458
|
-
entryFeeTotal,
|
|
2459
|
-
initSize: size,
|
|
2460
|
-
baseSize: size,
|
|
2461
|
-
_mfeR: 0,
|
|
2462
|
-
_maeR: 0,
|
|
2463
|
-
_adds: 0,
|
|
2464
|
-
_initRisk: Math.abs(entryPrice - stopPrice) || 1e-8
|
|
2465
|
-
};
|
|
2466
|
-
if (this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2467
|
-
this.open.entryATR = this.atrValues[this.index];
|
|
2468
|
-
this.open._lastATR = this.atrValues[this.index];
|
|
2469
|
-
}
|
|
2470
|
-
this.dayTrades += 1;
|
|
2471
|
-
this.pending = null;
|
|
2472
|
-
if (this.wantReplay) {
|
|
2473
|
-
this.replayEvents.push({
|
|
2474
|
-
t: new Date(bar.time).toISOString(),
|
|
2475
|
-
price: entryFill,
|
|
2476
|
-
type: "entry",
|
|
2477
|
-
side: this.open.side,
|
|
2478
|
-
size,
|
|
2479
|
-
tradeId: this.open.id,
|
|
2480
|
-
symbol: this.symbol
|
|
2481
|
-
});
|
|
2482
|
-
}
|
|
2483
|
-
return true;
|
|
2484
|
-
}
|
|
2485
|
-
buildSignalContext(index, bar, signalEquity) {
|
|
2486
|
-
if (this.options.strict && this.history.length !== index + 1) {
|
|
2487
|
-
throw new Error(
|
|
2488
|
-
`strict mode: signal() received ${this.history.length} candles at index ${index}`
|
|
2489
|
-
);
|
|
2490
|
-
}
|
|
2491
|
-
return {
|
|
2492
|
-
candles: this.options.strict ? strictHistoryView2(this.history, index) : this.history,
|
|
2493
|
-
index,
|
|
2494
|
-
bar,
|
|
2495
|
-
equity: signalEquity,
|
|
2496
|
-
openPosition: this.open,
|
|
2497
|
-
pendingOrder: this.pending
|
|
2498
|
-
};
|
|
2499
|
-
}
|
|
2500
|
-
step({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
|
|
2501
|
-
if (!this.hasNext()) return null;
|
|
2502
|
-
const bar = this.candles[this.index];
|
|
2503
|
-
this.history.push(bar);
|
|
2504
|
-
this.lastBar = bar;
|
|
2505
|
-
const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
|
|
2506
|
-
const dayKey = this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC2(bar.time);
|
|
2507
|
-
if (this.currentDay === null || dayKey !== this.currentDay) {
|
|
2508
|
-
this.currentDay = dayKey;
|
|
2509
|
-
this.dayPnl = 0;
|
|
2510
|
-
this.dayTrades = 0;
|
|
2511
|
-
this.dayEquityStart = this.currentEquity;
|
|
2512
|
-
}
|
|
2513
|
-
if (this.open && this.open._maxBarsInTrade > 0) {
|
|
2514
|
-
const barsHeld = Math.max(
|
|
2515
|
-
1,
|
|
2516
|
-
Math.round((bar.time - this.open.openTime) / this.estimatedBarMs)
|
|
2517
|
-
);
|
|
2518
|
-
if (barsHeld >= this.open._maxBarsInTrade) {
|
|
2519
|
-
this.forceExit("TIME", bar);
|
|
2247
|
+
if (this.open && this.open._maxBarsInTrade > 0) {
|
|
2248
|
+
const barsHeld = Math.max(
|
|
2249
|
+
1,
|
|
2250
|
+
Math.round((bar.time - this.open.openTime) / this.estimatedBarMs)
|
|
2251
|
+
);
|
|
2252
|
+
if (barsHeld >= this.open._maxBarsInTrade) {
|
|
2253
|
+
this.forceExit("TIME", bar);
|
|
2520
2254
|
}
|
|
2521
2255
|
}
|
|
2522
2256
|
if (this.open && Number.isFinite(this.open._maxHoldMin) && this.open._maxHoldMin > 0) {
|
|
@@ -2641,205 +2375,706 @@ var BarSystemRunner = class {
|
|
|
2641
2375
|
}
|
|
2642
2376
|
}
|
|
2643
2377
|
}
|
|
2644
|
-
if (!addedThisBar && !this.open._scaled && this.options.scaleOutAtR > 0) {
|
|
2645
|
-
const triggerPrice = this.open.side === "long" ? this.open.entry + this.options.scaleOutAtR * risk : this.open.entry - this.options.scaleOutAtR * risk;
|
|
2646
|
-
const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
2647
|
-
if (touched) {
|
|
2648
|
-
const exitSide2 = this.open.side === "long" ? "short" : "long";
|
|
2649
|
-
const qty = roundStep2(this.open.size * this.options.scaleOutFrac, this.options.qtyStep);
|
|
2650
|
-
if (qty >= this.options.minQty && qty < this.open.size) {
|
|
2651
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
|
|
2652
|
-
slippageBps: this.options.slippageBps,
|
|
2653
|
-
feeBps: this.options.feeBps,
|
|
2654
|
-
kind: "limit",
|
|
2655
|
-
qty,
|
|
2656
|
-
costs: this.options.costs
|
|
2657
|
-
});
|
|
2658
|
-
this.closeLeg({
|
|
2659
|
-
openPos: this.open,
|
|
2660
|
-
qty,
|
|
2661
|
-
exitPx: filled,
|
|
2662
|
-
exitFeeTotal,
|
|
2663
|
-
time: bar.time,
|
|
2664
|
-
reason: "SCALE"
|
|
2665
|
-
});
|
|
2666
|
-
this.open._scaled = true;
|
|
2667
|
-
this.open.takeProfit = this.open.side === "long" ? this.open.entry + this.options.finalTP_R * risk : this.open.entry - this.options.finalTP_R * risk;
|
|
2668
|
-
this.tightenStopToNetBreakeven(this.open, bar.close);
|
|
2669
|
-
this.open._beArmed = true;
|
|
2670
|
-
}
|
|
2378
|
+
if (!addedThisBar && !this.open._scaled && this.options.scaleOutAtR > 0) {
|
|
2379
|
+
const triggerPrice = this.open.side === "long" ? this.open.entry + this.options.scaleOutAtR * risk : this.open.entry - this.options.scaleOutAtR * risk;
|
|
2380
|
+
const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
2381
|
+
if (touched) {
|
|
2382
|
+
const exitSide2 = this.open.side === "long" ? "short" : "long";
|
|
2383
|
+
const qty = roundStep2(this.open.size * this.options.scaleOutFrac, this.options.qtyStep);
|
|
2384
|
+
if (qty >= this.options.minQty && qty < this.open.size) {
|
|
2385
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
|
|
2386
|
+
slippageBps: this.options.slippageBps,
|
|
2387
|
+
feeBps: this.options.feeBps,
|
|
2388
|
+
kind: "limit",
|
|
2389
|
+
qty,
|
|
2390
|
+
costs: this.options.costs
|
|
2391
|
+
});
|
|
2392
|
+
this.closeLeg({
|
|
2393
|
+
openPos: this.open,
|
|
2394
|
+
qty,
|
|
2395
|
+
exitPx: filled,
|
|
2396
|
+
exitFeeTotal,
|
|
2397
|
+
time: bar.time,
|
|
2398
|
+
reason: "SCALE"
|
|
2399
|
+
});
|
|
2400
|
+
this.open._scaled = true;
|
|
2401
|
+
this.open.takeProfit = this.open.side === "long" ? this.open.entry + this.options.finalTP_R * risk : this.open.entry - this.options.finalTP_R * risk;
|
|
2402
|
+
this.tightenStopToNetBreakeven(this.open, bar.close);
|
|
2403
|
+
this.open._beArmed = true;
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
2408
|
+
const { hit, px } = ocoExitCheck({
|
|
2409
|
+
side: this.open.side,
|
|
2410
|
+
stop: this.open.stop,
|
|
2411
|
+
tp: this.open.takeProfit,
|
|
2412
|
+
bar,
|
|
2413
|
+
mode: this.options.oco.mode,
|
|
2414
|
+
tieBreak: this.options.oco.tieBreak
|
|
2415
|
+
});
|
|
2416
|
+
if (hit) {
|
|
2417
|
+
const exitKind = hit === "TP" ? "limit" : "stop";
|
|
2418
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
|
|
2419
|
+
slippageBps: this.options.slippageBps,
|
|
2420
|
+
feeBps: this.options.feeBps,
|
|
2421
|
+
kind: exitKind,
|
|
2422
|
+
qty: this.open.size,
|
|
2423
|
+
costs: this.options.costs
|
|
2424
|
+
});
|
|
2425
|
+
const localCooldown = this.open._cooldownBars || 0;
|
|
2426
|
+
this.closeLeg({
|
|
2427
|
+
openPos: this.open,
|
|
2428
|
+
qty: this.open.size,
|
|
2429
|
+
exitPx: filled,
|
|
2430
|
+
exitFeeTotal,
|
|
2431
|
+
time: bar.time,
|
|
2432
|
+
reason: hit
|
|
2433
|
+
});
|
|
2434
|
+
this.cooldown = (hit === "SL" ? Math.max(this.cooldown, this.options.postLossCooldownBars) : this.cooldown) || localCooldown;
|
|
2435
|
+
this.open = null;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
const maxLossDollars = this.options.maxDailyLossPct / 100 * this.dayEquityStart;
|
|
2439
|
+
const dailyLossHit = this.dayPnl <= -Math.abs(maxLossDollars);
|
|
2440
|
+
const dailyTradeCapHit = this.options.dailyMaxTrades > 0 && this.dayTrades >= this.options.dailyMaxTrades;
|
|
2441
|
+
if (!this.open && this.pending) {
|
|
2442
|
+
if (!canTrade) {
|
|
2443
|
+
this.pending = null;
|
|
2444
|
+
} else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
2445
|
+
if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
|
|
2446
|
+
const riskAtEdge = Math.abs(
|
|
2447
|
+
this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
|
|
2448
|
+
);
|
|
2449
|
+
const priceNow = bar.close;
|
|
2450
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2451
|
+
const slippedR = Math.max(
|
|
2452
|
+
0,
|
|
2453
|
+
direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
|
|
2454
|
+
) / Math.max(1e-8, riskAtEdge);
|
|
2455
|
+
if (slippedR > this.options.maxSlipROnFill) {
|
|
2456
|
+
this.pending = null;
|
|
2457
|
+
} else if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
|
|
2458
|
+
this.pending = null;
|
|
2459
|
+
}
|
|
2460
|
+
} else {
|
|
2461
|
+
this.pending = null;
|
|
2462
|
+
}
|
|
2463
|
+
} else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
2464
|
+
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
2465
|
+
this.pending = null;
|
|
2466
|
+
}
|
|
2467
|
+
} else if (this.options.entryChase.enabled) {
|
|
2468
|
+
const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
|
|
2469
|
+
const midpoint = asNumber2(this.pending.meta?._imb?.mid);
|
|
2470
|
+
if (!this.pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
|
|
2471
|
+
this.pending.entry = midpoint;
|
|
2472
|
+
this.pending._chasedCE = true;
|
|
2473
|
+
}
|
|
2474
|
+
if (this.pending._chasedCE) {
|
|
2475
|
+
const riskRef = Math.abs(
|
|
2476
|
+
this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
|
|
2477
|
+
);
|
|
2478
|
+
const priceNow = bar.close;
|
|
2479
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2480
|
+
const slippedR = Math.max(
|
|
2481
|
+
0,
|
|
2482
|
+
direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
|
|
2483
|
+
) / Math.max(1e-8, riskRef);
|
|
2484
|
+
if (slippedR > this.options.maxSlipROnFill) {
|
|
2485
|
+
this.pending = null;
|
|
2486
|
+
} else if (slippedR > 0 && slippedR <= this.options.entryChase.maxSlipR) {
|
|
2487
|
+
if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
|
|
2488
|
+
this.pending = null;
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
if (this.open || this.cooldown > 0) {
|
|
2495
|
+
if (this.cooldown > 0) this.cooldown -= 1;
|
|
2496
|
+
this.recordFrame(bar);
|
|
2497
|
+
this.index += 1;
|
|
2498
|
+
return { handled: true, bar };
|
|
2499
|
+
}
|
|
2500
|
+
if (!canTrade || dailyLossHit || dailyTradeCapHit) {
|
|
2501
|
+
this.pending = null;
|
|
2502
|
+
this.recordFrame(bar);
|
|
2503
|
+
this.index += 1;
|
|
2504
|
+
return { handled: true, bar };
|
|
2505
|
+
}
|
|
2506
|
+
if (this.pending) {
|
|
2507
|
+
this.recordFrame(bar);
|
|
2508
|
+
this.index += 1;
|
|
2509
|
+
return { handled: true, bar };
|
|
2510
|
+
}
|
|
2511
|
+
return {
|
|
2512
|
+
handled: false,
|
|
2513
|
+
bar,
|
|
2514
|
+
trigger,
|
|
2515
|
+
signalEquity,
|
|
2516
|
+
resolveEntrySize
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
_applyRawSignal(rawSignal, pre) {
|
|
2520
|
+
const { bar, trigger, signalEquity, resolveEntrySize } = pre;
|
|
2521
|
+
const nextSignal = normalizeSignal2(rawSignal, bar, this.options.finalTP_R);
|
|
2522
|
+
if (nextSignal) {
|
|
2523
|
+
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
|
|
2524
|
+
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
2525
|
+
this.pending = {
|
|
2526
|
+
side: nextSignal.side,
|
|
2527
|
+
entry: nextSignal.entry,
|
|
2528
|
+
stop: nextSignal.stop,
|
|
2529
|
+
tp: nextSignal.takeProfit,
|
|
2530
|
+
riskFrac: signalRiskFraction,
|
|
2531
|
+
fixedQty: nextSignal.qty,
|
|
2532
|
+
expiresAt: this.index + Math.max(1, expiryBars),
|
|
2533
|
+
startedAtIndex: this.index,
|
|
2534
|
+
meta: nextSignal,
|
|
2535
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
2536
|
+
};
|
|
2537
|
+
if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
2538
|
+
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
2539
|
+
this.pending = null;
|
|
2671
2540
|
}
|
|
2672
2541
|
}
|
|
2673
|
-
|
|
2542
|
+
}
|
|
2543
|
+
this.recordFrame(bar);
|
|
2544
|
+
this.index += 1;
|
|
2545
|
+
return bar;
|
|
2546
|
+
}
|
|
2547
|
+
step(options = {}) {
|
|
2548
|
+
const pre = this._preSignal(options);
|
|
2549
|
+
if (pre.handled) return pre.bar;
|
|
2550
|
+
const rawSignal = callSignalWithContext2({
|
|
2551
|
+
signal: this.options.signal,
|
|
2552
|
+
context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
|
|
2553
|
+
index: this.index,
|
|
2554
|
+
bar: pre.bar,
|
|
2555
|
+
symbol: this.symbol
|
|
2556
|
+
});
|
|
2557
|
+
return this._applyRawSignal(rawSignal, pre);
|
|
2558
|
+
}
|
|
2559
|
+
async stepAsync(options = {}) {
|
|
2560
|
+
const pre = this._preSignal(options);
|
|
2561
|
+
if (pre.handled) return pre.bar;
|
|
2562
|
+
const rawSignal = await callSignalWithContextAsync({
|
|
2563
|
+
signal: this.options.signal,
|
|
2564
|
+
context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
|
|
2565
|
+
index: this.index,
|
|
2566
|
+
bar: pre.bar,
|
|
2567
|
+
symbol: this.symbol
|
|
2568
|
+
});
|
|
2569
|
+
return this._applyRawSignal(rawSignal, pre);
|
|
2570
|
+
}
|
|
2571
|
+
buildResult() {
|
|
2572
|
+
const metrics = buildMetrics({
|
|
2573
|
+
closed: this.closed,
|
|
2574
|
+
equityStart: this.options.equity,
|
|
2575
|
+
equityFinal: this.currentEquity,
|
|
2576
|
+
candles: this.candles,
|
|
2577
|
+
estBarMs: this.estimatedBarMs,
|
|
2578
|
+
eqSeries: this.eqSeries,
|
|
2579
|
+
interval: this.options.interval
|
|
2580
|
+
});
|
|
2581
|
+
const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
2582
|
+
const lastPrice = asNumber2(this.candles[this.candles.length - 1]?.close);
|
|
2583
|
+
const openPositions = this.open ? [snapshotOpenPosition2(this.open, lastPrice ?? this.open.entryFill ?? this.open.entry)] : [];
|
|
2584
|
+
return {
|
|
2585
|
+
symbol: this.options.symbol,
|
|
2586
|
+
interval: this.options.interval,
|
|
2587
|
+
range: this.options.range,
|
|
2588
|
+
trades: this.closed,
|
|
2589
|
+
positions,
|
|
2590
|
+
openPositions,
|
|
2591
|
+
metrics,
|
|
2592
|
+
eqSeries: this.eqSeries,
|
|
2593
|
+
replay: {
|
|
2594
|
+
frames: this.replayFrames,
|
|
2595
|
+
events: this.replayEvents
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct) {
|
|
2601
|
+
const limits = [];
|
|
2602
|
+
if (Number.isFinite(capPct) && capPct > 0) limits.push(totalEquity * capPct);
|
|
2603
|
+
if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
|
|
2604
|
+
if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
|
|
2605
|
+
limits.push(totalEquity * maxAllocationPct);
|
|
2606
|
+
}
|
|
2607
|
+
return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// src/engine/asyncSignal.js
|
|
2611
|
+
var BudgetExceededError = class extends Error {
|
|
2612
|
+
constructor(ms) {
|
|
2613
|
+
super(`signal() exceeded its ${ms}ms per-bar budget`);
|
|
2614
|
+
this.name = "BudgetExceededError";
|
|
2615
|
+
this.budgetMs = ms;
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2618
|
+
function withBudget(promise, budgetMs) {
|
|
2619
|
+
if (!budgetMs || budgetMs <= 0) return Promise.resolve(promise);
|
|
2620
|
+
return new Promise((resolve, reject) => {
|
|
2621
|
+
const timer = setTimeout(() => reject(new BudgetExceededError(budgetMs)), budgetMs);
|
|
2622
|
+
Promise.resolve(promise).then(
|
|
2623
|
+
(value) => {
|
|
2624
|
+
clearTimeout(timer);
|
|
2625
|
+
resolve(value);
|
|
2626
|
+
},
|
|
2627
|
+
(err) => {
|
|
2628
|
+
clearTimeout(timer);
|
|
2629
|
+
reject(err);
|
|
2630
|
+
}
|
|
2631
|
+
);
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// src/engine/backtestAsync.js
|
|
2636
|
+
async function backtestAsync(rawOptions = {}) {
|
|
2637
|
+
const budgetMs = rawOptions.signalBudgetMs ?? 0;
|
|
2638
|
+
const userSignal = rawOptions.signal;
|
|
2639
|
+
const budgetedSignal = (context) => withBudget(
|
|
2640
|
+
Promise.resolve().then(() => userSignal(context)),
|
|
2641
|
+
budgetMs
|
|
2642
|
+
);
|
|
2643
|
+
const runner = new BarSystemRunner({ ...rawOptions, signal: budgetedSignal });
|
|
2644
|
+
while (runner.hasNext()) {
|
|
2645
|
+
await runner.stepAsync({ signalEquity: runner.getMarkedEquity() });
|
|
2646
|
+
}
|
|
2647
|
+
return runner.buildResult();
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// src/engine/backtestTicks.js
|
|
2651
|
+
function asNumber3(value) {
|
|
2652
|
+
const numeric = Number(value);
|
|
2653
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
2654
|
+
}
|
|
2655
|
+
function describeValue3(value) {
|
|
2656
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
2657
|
+
if (value === null) return "null";
|
|
2658
|
+
return typeof value;
|
|
2659
|
+
}
|
|
2660
|
+
function formatIsoTime3(time) {
|
|
2661
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
2662
|
+
}
|
|
2663
|
+
function callSignalWithContext3({ signal, context, index, bar, symbol }) {
|
|
2664
|
+
try {
|
|
2665
|
+
return signal(context);
|
|
2666
|
+
} catch (error) {
|
|
2667
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
2668
|
+
throw new Error(
|
|
2669
|
+
`signal() threw at index=${index}, time=${formatIsoTime3(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
2670
|
+
);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
function normalizeSide3(value) {
|
|
2674
|
+
if (value === "long" || value === "buy") return "long";
|
|
2675
|
+
if (value === "short" || value === "sell") return "short";
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
function normalizeTick(tick) {
|
|
2679
|
+
const time = Number(tick?.time);
|
|
2680
|
+
const bid = asNumber3(tick?.bid);
|
|
2681
|
+
const ask = asNumber3(tick?.ask);
|
|
2682
|
+
const last = asNumber3(tick?.price ?? tick?.last ?? tick?.close);
|
|
2683
|
+
const mid = bid !== null && ask !== null ? (bid + ask) / 2 : last ?? bid ?? ask;
|
|
2684
|
+
if (!Number.isFinite(time) || !Number.isFinite(mid)) return null;
|
|
2685
|
+
const prices = [asNumber3(tick?.low), asNumber3(tick?.high), bid, ask, last, mid].filter(
|
|
2686
|
+
Number.isFinite
|
|
2687
|
+
);
|
|
2688
|
+
const low = prices.length ? Math.min(...prices) : mid;
|
|
2689
|
+
const high = prices.length ? Math.max(...prices) : mid;
|
|
2690
|
+
return {
|
|
2691
|
+
...tick,
|
|
2692
|
+
time,
|
|
2693
|
+
open: mid,
|
|
2694
|
+
high,
|
|
2695
|
+
low,
|
|
2696
|
+
close: mid,
|
|
2697
|
+
volume: asNumber3(tick?.size ?? tick?.volume) ?? void 0
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
function normalizeSignal3(signal, bar, fallbackR) {
|
|
2701
|
+
if (!signal) return null;
|
|
2702
|
+
const side = normalizeSide3(signal.side ?? signal.direction ?? signal.action);
|
|
2703
|
+
if (!side) return null;
|
|
2704
|
+
const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
|
|
2705
|
+
const entry = asNumber3(signal.entry ?? signal.limit ?? signal.price) ?? asNumber3(bar?.close);
|
|
2706
|
+
const stop = asNumber3(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
2707
|
+
if (entry === null || stop === null) return null;
|
|
2708
|
+
const risk = Math.abs(entry - stop);
|
|
2709
|
+
if (!(risk > 0)) return null;
|
|
2710
|
+
let takeProfit = asNumber3(signal.takeProfit ?? signal.target ?? signal.tp);
|
|
2711
|
+
const rrHint = asNumber3(signal._rr ?? signal.rr);
|
|
2712
|
+
const targetR = rrHint ?? fallbackR;
|
|
2713
|
+
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
2714
|
+
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
2715
|
+
}
|
|
2716
|
+
if (takeProfit === null) return null;
|
|
2717
|
+
return {
|
|
2718
|
+
...signal,
|
|
2719
|
+
side,
|
|
2720
|
+
entry,
|
|
2721
|
+
stop,
|
|
2722
|
+
takeProfit,
|
|
2723
|
+
qty: asNumber3(signal.qty ?? signal.size),
|
|
2724
|
+
riskPct: asNumber3(signal.riskPct),
|
|
2725
|
+
riskFraction: asNumber3(signal.riskFraction),
|
|
2726
|
+
orderType: hasExplicitEntry ? "limit" : "market"
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
function equityPoint3(time, equity) {
|
|
2730
|
+
return { time, timestamp: time, equity };
|
|
2731
|
+
}
|
|
2732
|
+
function xmur3(seed) {
|
|
2733
|
+
let hash = 1779033703 ^ seed.length;
|
|
2734
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
2735
|
+
hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353);
|
|
2736
|
+
hash = hash << 13 | hash >>> 19;
|
|
2737
|
+
}
|
|
2738
|
+
return () => {
|
|
2739
|
+
hash = Math.imul(hash ^ hash >>> 16, 2246822507);
|
|
2740
|
+
hash = Math.imul(hash ^ hash >>> 13, 3266489909);
|
|
2741
|
+
return (hash ^= hash >>> 16) >>> 0;
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
function mulberry32(seed) {
|
|
2745
|
+
let state = seed >>> 0;
|
|
2746
|
+
return () => {
|
|
2747
|
+
state = state + 1831565813 >>> 0;
|
|
2748
|
+
let value = Math.imul(state ^ state >>> 15, state | 1);
|
|
2749
|
+
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
|
|
2750
|
+
return ((value ^ value >>> 14) >>> 0) / 4294967296;
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
function seededUnitInterval(seedParts) {
|
|
2754
|
+
const seed = seedParts.map((part) => String(part)).join("|");
|
|
2755
|
+
const seedFn = xmur3(seed);
|
|
2756
|
+
return mulberry32(seedFn())();
|
|
2757
|
+
}
|
|
2758
|
+
function deterministicFill(probability, seedParts) {
|
|
2759
|
+
if (probability >= 1) return true;
|
|
2760
|
+
if (probability <= 0) return false;
|
|
2761
|
+
const normalized = seededUnitInterval(seedParts);
|
|
2762
|
+
return normalized <= probability;
|
|
2763
|
+
}
|
|
2764
|
+
function backtestTicks({
|
|
2765
|
+
ticks = [],
|
|
2766
|
+
symbol = "UNKNOWN",
|
|
2767
|
+
equity = 1e4,
|
|
2768
|
+
riskPct = 1,
|
|
2769
|
+
signal,
|
|
2770
|
+
interval,
|
|
2771
|
+
range,
|
|
2772
|
+
slippageBps = 1,
|
|
2773
|
+
feeBps = 0,
|
|
2774
|
+
costs = null,
|
|
2775
|
+
finalTP_R = 3,
|
|
2776
|
+
maxDailyLossPct = 0,
|
|
2777
|
+
dailyMaxTrades = 0,
|
|
2778
|
+
qtyStep = 1e-3,
|
|
2779
|
+
minQty = 1e-3,
|
|
2780
|
+
maxLeverage = 2,
|
|
2781
|
+
collectEqSeries = true,
|
|
2782
|
+
collectReplay = true,
|
|
2783
|
+
queueFillProbability = 1,
|
|
2784
|
+
seed = "tradelab-ticks",
|
|
2785
|
+
oco = {}
|
|
2786
|
+
} = {}) {
|
|
2787
|
+
if (!Array.isArray(ticks) || ticks.length === 0) {
|
|
2788
|
+
throw new Error(
|
|
2789
|
+
`backtestTicks() requires a non-empty ticks array, got ${describeValue3(ticks)}`
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
2792
|
+
if (typeof signal !== "function") {
|
|
2793
|
+
throw new Error(`backtestTicks() requires a signal function, got ${describeValue3(signal)}`);
|
|
2794
|
+
}
|
|
2795
|
+
const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
|
|
2796
|
+
if (!normalizedTicks.length) {
|
|
2797
|
+
throw new Error(
|
|
2798
|
+
`backtestTicks() could not normalize any ticks from ${ticks.length} input rows`
|
|
2799
|
+
);
|
|
2800
|
+
}
|
|
2801
|
+
const ocoOptions = {
|
|
2802
|
+
mode: "intrabar",
|
|
2803
|
+
tieBreak: "pessimistic",
|
|
2804
|
+
...oco
|
|
2805
|
+
};
|
|
2806
|
+
const trades = [];
|
|
2807
|
+
const eqSeries = collectEqSeries ? [equityPoint3(normalizedTicks[0].time, equity)] : [];
|
|
2808
|
+
const replayFrames = collectReplay ? [] : [];
|
|
2809
|
+
const replayEvents = collectReplay ? [] : [];
|
|
2810
|
+
const history = [];
|
|
2811
|
+
let open = null;
|
|
2812
|
+
let pending = null;
|
|
2813
|
+
let currentEquity = equity;
|
|
2814
|
+
let dayKey = null;
|
|
2815
|
+
let dayStartEquity = equity;
|
|
2816
|
+
let dayPnl = 0;
|
|
2817
|
+
let dayTrades = 0;
|
|
2818
|
+
let tradeIdCounter = 0;
|
|
2819
|
+
function markedEquity(tick) {
|
|
2820
|
+
if (!open) return currentEquity;
|
|
2821
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
2822
|
+
return currentEquity + (tick.close - open.entryFill) * direction * open.size;
|
|
2823
|
+
}
|
|
2824
|
+
function recordFrame(tick) {
|
|
2825
|
+
const equityNow = markedEquity(tick);
|
|
2826
|
+
if (collectEqSeries) {
|
|
2827
|
+
eqSeries.push(equityPoint3(tick.time, equityNow));
|
|
2828
|
+
}
|
|
2829
|
+
if (collectReplay) {
|
|
2830
|
+
replayFrames.push({
|
|
2831
|
+
t: new Date(tick.time).toISOString(),
|
|
2832
|
+
price: tick.close,
|
|
2833
|
+
equity: equityNow,
|
|
2834
|
+
posSide: open?.side ?? null,
|
|
2835
|
+
posSize: open?.size ?? 0
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
function closePosition(tick, reason, rawPrice, fillKind) {
|
|
2840
|
+
if (!open) return;
|
|
2841
|
+
const exitSide = open.side === "long" ? "short" : "long";
|
|
2842
|
+
const { price, feeTotal } = applyFill(rawPrice, exitSide, {
|
|
2843
|
+
slippageBps,
|
|
2844
|
+
feeBps,
|
|
2845
|
+
kind: fillKind,
|
|
2846
|
+
qty: open.size,
|
|
2847
|
+
costs
|
|
2848
|
+
});
|
|
2849
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
2850
|
+
const grossPnl = (price - open.entryFill) * direction * open.size;
|
|
2851
|
+
const financing = financingCost({
|
|
2852
|
+
side: open.side,
|
|
2853
|
+
notional: open.entryFill * open.size,
|
|
2854
|
+
fromMs: open.openTime,
|
|
2855
|
+
toMs: tick.time,
|
|
2856
|
+
costs
|
|
2857
|
+
});
|
|
2858
|
+
const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal - financing;
|
|
2859
|
+
currentEquity += pnl;
|
|
2860
|
+
dayPnl += pnl;
|
|
2861
|
+
const trade = {
|
|
2862
|
+
...open,
|
|
2863
|
+
exit: {
|
|
2864
|
+
price,
|
|
2865
|
+
time: tick.time,
|
|
2866
|
+
reason,
|
|
2867
|
+
pnl,
|
|
2868
|
+
financing
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
trades.push(trade);
|
|
2872
|
+
if (collectReplay) {
|
|
2873
|
+
replayEvents.push({
|
|
2874
|
+
t: new Date(tick.time).toISOString(),
|
|
2875
|
+
price,
|
|
2876
|
+
type: reason === "TP" ? "tp" : reason === "SL" ? "sl" : "exit",
|
|
2877
|
+
side: open.side,
|
|
2878
|
+
size: open.size,
|
|
2879
|
+
tradeId: open.id,
|
|
2880
|
+
reason,
|
|
2881
|
+
pnl
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
open = null;
|
|
2885
|
+
}
|
|
2886
|
+
for (let index = 0; index < normalizedTicks.length; index += 1) {
|
|
2887
|
+
const tick = normalizedTicks[index];
|
|
2888
|
+
history.push(tick);
|
|
2889
|
+
const currentDayKey = dayKeyUTC2(tick.time);
|
|
2890
|
+
if (dayKey === null || currentDayKey !== dayKey) {
|
|
2891
|
+
dayKey = currentDayKey;
|
|
2892
|
+
dayStartEquity = currentEquity;
|
|
2893
|
+
dayPnl = 0;
|
|
2894
|
+
dayTrades = 0;
|
|
2895
|
+
}
|
|
2896
|
+
if (open) {
|
|
2674
2897
|
const { hit, px } = ocoExitCheck({
|
|
2675
|
-
side:
|
|
2676
|
-
stop:
|
|
2677
|
-
tp:
|
|
2678
|
-
bar,
|
|
2679
|
-
mode:
|
|
2680
|
-
tieBreak:
|
|
2898
|
+
side: open.side,
|
|
2899
|
+
stop: open.stop,
|
|
2900
|
+
tp: open.takeProfit,
|
|
2901
|
+
bar: tick,
|
|
2902
|
+
mode: "intrabar",
|
|
2903
|
+
tieBreak: ocoOptions.tieBreak
|
|
2681
2904
|
});
|
|
2682
2905
|
if (hit) {
|
|
2683
|
-
|
|
2684
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
|
|
2685
|
-
slippageBps: this.options.slippageBps,
|
|
2686
|
-
feeBps: this.options.feeBps,
|
|
2687
|
-
kind: exitKind,
|
|
2688
|
-
qty: this.open.size,
|
|
2689
|
-
costs: this.options.costs
|
|
2690
|
-
});
|
|
2691
|
-
const localCooldown = this.open._cooldownBars || 0;
|
|
2692
|
-
this.closeLeg({
|
|
2693
|
-
openPos: this.open,
|
|
2694
|
-
qty: this.open.size,
|
|
2695
|
-
exitPx: filled,
|
|
2696
|
-
exitFeeTotal,
|
|
2697
|
-
time: bar.time,
|
|
2698
|
-
reason: hit
|
|
2699
|
-
});
|
|
2700
|
-
this.cooldown = (hit === "SL" ? Math.max(this.cooldown, this.options.postLossCooldownBars) : this.cooldown) || localCooldown;
|
|
2701
|
-
this.open = null;
|
|
2906
|
+
closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
|
|
2702
2907
|
}
|
|
2703
2908
|
}
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
const
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2909
|
+
if (!open && pending && index > pending.createdAtIndex) {
|
|
2910
|
+
if (pending.orderType === "market") {
|
|
2911
|
+
const rawSize = pending.fixedQty ?? calculatePositionSize({
|
|
2912
|
+
equity: currentEquity,
|
|
2913
|
+
entry: tick.close,
|
|
2914
|
+
stop: pending.stop,
|
|
2915
|
+
riskFraction: pending.riskFrac,
|
|
2916
|
+
qtyStep,
|
|
2917
|
+
minQty,
|
|
2918
|
+
maxLeverage
|
|
2919
|
+
});
|
|
2920
|
+
const size = roundStep2(rawSize, qtyStep);
|
|
2921
|
+
if (size >= minQty) {
|
|
2922
|
+
const { price, feeTotal } = applyFill(tick.close, pending.side, {
|
|
2923
|
+
slippageBps,
|
|
2924
|
+
feeBps,
|
|
2925
|
+
kind: "market",
|
|
2926
|
+
qty: size,
|
|
2927
|
+
costs
|
|
2928
|
+
});
|
|
2929
|
+
open = {
|
|
2930
|
+
symbol,
|
|
2931
|
+
id: ++tradeIdCounter,
|
|
2932
|
+
side: pending.side,
|
|
2933
|
+
entry: tick.close,
|
|
2934
|
+
stop: pending.stop,
|
|
2935
|
+
takeProfit: pending.takeProfit,
|
|
2936
|
+
size,
|
|
2937
|
+
openTime: tick.time,
|
|
2938
|
+
entryFill: price,
|
|
2939
|
+
entryFeeTotal: feeTotal,
|
|
2940
|
+
_initRisk: Math.abs(tick.close - pending.stop)
|
|
2941
|
+
};
|
|
2942
|
+
dayTrades += 1;
|
|
2943
|
+
if (collectReplay) {
|
|
2944
|
+
replayEvents.push({
|
|
2945
|
+
t: new Date(tick.time).toISOString(),
|
|
2946
|
+
price,
|
|
2947
|
+
type: "entry",
|
|
2948
|
+
side: open.side,
|
|
2949
|
+
size,
|
|
2950
|
+
tradeId: open.id
|
|
2951
|
+
});
|
|
2725
2952
|
}
|
|
2726
|
-
} else {
|
|
2727
|
-
this.pending = null;
|
|
2728
|
-
}
|
|
2729
|
-
} else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
2730
|
-
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
2731
|
-
this.pending = null;
|
|
2732
|
-
}
|
|
2733
|
-
} else if (this.options.entryChase.enabled) {
|
|
2734
|
-
const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
|
|
2735
|
-
const midpoint = asNumber3(this.pending.meta?._imb?.mid);
|
|
2736
|
-
if (!this.pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
|
|
2737
|
-
this.pending.entry = midpoint;
|
|
2738
|
-
this.pending._chasedCE = true;
|
|
2739
2953
|
}
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2954
|
+
pending = null;
|
|
2955
|
+
} else {
|
|
2956
|
+
const touched = pending.side === "long" ? tick.low <= pending.entry : tick.high >= pending.entry;
|
|
2957
|
+
if (touched && deterministicFill(queueFillProbability, [
|
|
2958
|
+
seed,
|
|
2959
|
+
symbol,
|
|
2960
|
+
tick.time,
|
|
2961
|
+
pending.entry,
|
|
2962
|
+
pending.stop,
|
|
2963
|
+
pending.side
|
|
2964
|
+
])) {
|
|
2965
|
+
const rawSize = pending.fixedQty ?? calculatePositionSize({
|
|
2966
|
+
equity: currentEquity,
|
|
2967
|
+
entry: pending.entry,
|
|
2968
|
+
stop: pending.stop,
|
|
2969
|
+
riskFraction: pending.riskFrac,
|
|
2970
|
+
qtyStep,
|
|
2971
|
+
minQty,
|
|
2972
|
+
maxLeverage
|
|
2973
|
+
});
|
|
2974
|
+
const size = roundStep2(rawSize, qtyStep);
|
|
2975
|
+
if (size >= minQty) {
|
|
2976
|
+
const { price, feeTotal } = applyFill(pending.entry, pending.side, {
|
|
2977
|
+
slippageBps,
|
|
2978
|
+
feeBps,
|
|
2979
|
+
kind: "limit",
|
|
2980
|
+
qty: size,
|
|
2981
|
+
costs
|
|
2982
|
+
});
|
|
2983
|
+
open = {
|
|
2984
|
+
symbol,
|
|
2985
|
+
id: ++tradeIdCounter,
|
|
2986
|
+
side: pending.side,
|
|
2987
|
+
entry: pending.entry,
|
|
2988
|
+
stop: pending.stop,
|
|
2989
|
+
takeProfit: pending.takeProfit,
|
|
2990
|
+
size,
|
|
2991
|
+
openTime: tick.time,
|
|
2992
|
+
entryFill: price,
|
|
2993
|
+
entryFeeTotal: feeTotal,
|
|
2994
|
+
_initRisk: Math.abs(pending.entry - pending.stop)
|
|
2995
|
+
};
|
|
2996
|
+
dayTrades += 1;
|
|
2997
|
+
if (collectReplay) {
|
|
2998
|
+
replayEvents.push({
|
|
2999
|
+
t: new Date(tick.time).toISOString(),
|
|
3000
|
+
price,
|
|
3001
|
+
type: "entry",
|
|
3002
|
+
side: open.side,
|
|
3003
|
+
size,
|
|
3004
|
+
tradeId: open.id
|
|
3005
|
+
});
|
|
2755
3006
|
}
|
|
2756
3007
|
}
|
|
3008
|
+
pending = null;
|
|
2757
3009
|
}
|
|
2758
3010
|
}
|
|
2759
3011
|
}
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
3012
|
+
const maxLossDollars = Math.abs(maxDailyLossPct) / 100 * dayStartEquity;
|
|
3013
|
+
const dailyLossHit = maxDailyLossPct > 0 && dayPnl <= -maxLossDollars;
|
|
3014
|
+
const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
|
|
3015
|
+
if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
|
|
3016
|
+
const nextSignal = normalizeSignal3(
|
|
3017
|
+
callSignalWithContext3({
|
|
3018
|
+
signal,
|
|
3019
|
+
context: {
|
|
3020
|
+
candles: history,
|
|
3021
|
+
index,
|
|
3022
|
+
bar: tick,
|
|
3023
|
+
equity: markedEquity(tick),
|
|
3024
|
+
openPosition: open,
|
|
3025
|
+
pendingOrder: pending
|
|
3026
|
+
},
|
|
3027
|
+
index,
|
|
3028
|
+
bar: tick,
|
|
3029
|
+
symbol
|
|
3030
|
+
}),
|
|
3031
|
+
tick,
|
|
3032
|
+
finalTP_R
|
|
3033
|
+
);
|
|
2781
3034
|
if (nextSignal) {
|
|
2782
|
-
|
|
2783
|
-
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
2784
|
-
this.pending = {
|
|
3035
|
+
pending = {
|
|
2785
3036
|
side: nextSignal.side,
|
|
2786
3037
|
entry: nextSignal.entry,
|
|
2787
3038
|
stop: nextSignal.stop,
|
|
2788
|
-
|
|
2789
|
-
riskFrac: signalRiskFraction,
|
|
3039
|
+
takeProfit: nextSignal.takeProfit,
|
|
2790
3040
|
fixedQty: nextSignal.qty,
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
3041
|
+
riskFrac: Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : riskPct / 100,
|
|
3042
|
+
orderType: nextSignal.orderType,
|
|
3043
|
+
createdAtIndex: index
|
|
2795
3044
|
};
|
|
2796
|
-
if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
2797
|
-
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
2798
|
-
this.pending = null;
|
|
2799
|
-
}
|
|
2800
|
-
}
|
|
2801
3045
|
}
|
|
2802
3046
|
}
|
|
2803
|
-
|
|
2804
|
-
this.index += 1;
|
|
2805
|
-
return bar;
|
|
2806
|
-
}
|
|
2807
|
-
buildResult() {
|
|
2808
|
-
const metrics = buildMetrics({
|
|
2809
|
-
closed: this.closed,
|
|
2810
|
-
equityStart: this.options.equity,
|
|
2811
|
-
equityFinal: this.currentEquity,
|
|
2812
|
-
candles: this.candles,
|
|
2813
|
-
estBarMs: this.estimatedBarMs,
|
|
2814
|
-
eqSeries: this.eqSeries
|
|
2815
|
-
});
|
|
2816
|
-
const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
2817
|
-
const lastPrice = asNumber3(this.candles[this.candles.length - 1]?.close);
|
|
2818
|
-
const openPositions = this.open ? [snapshotOpenPosition2(this.open, lastPrice ?? this.open.entryFill ?? this.open.entry)] : [];
|
|
2819
|
-
return {
|
|
2820
|
-
symbol: this.options.symbol,
|
|
2821
|
-
interval: this.options.interval,
|
|
2822
|
-
range: this.options.range,
|
|
2823
|
-
trades: this.closed,
|
|
2824
|
-
positions,
|
|
2825
|
-
openPositions,
|
|
2826
|
-
metrics,
|
|
2827
|
-
eqSeries: this.eqSeries,
|
|
2828
|
-
replay: {
|
|
2829
|
-
frames: this.replayFrames,
|
|
2830
|
-
events: this.replayEvents
|
|
2831
|
-
}
|
|
2832
|
-
};
|
|
3047
|
+
recordFrame(tick);
|
|
2833
3048
|
}
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
|
|
2839
|
-
if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
|
|
2840
|
-
limits.push(totalEquity * maxAllocationPct);
|
|
3049
|
+
if (open) {
|
|
3050
|
+
const lastTick = normalizedTicks[normalizedTicks.length - 1];
|
|
3051
|
+
closePosition(lastTick, "EOT", lastTick.close, "market");
|
|
3052
|
+
recordFrame(lastTick);
|
|
2841
3053
|
}
|
|
2842
|
-
|
|
3054
|
+
const positions = trades;
|
|
3055
|
+
const metrics = buildMetrics({
|
|
3056
|
+
closed: trades,
|
|
3057
|
+
equityStart: equity,
|
|
3058
|
+
equityFinal: currentEquity,
|
|
3059
|
+
candles: normalizedTicks,
|
|
3060
|
+
estBarMs: normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
|
|
3061
|
+
eqSeries,
|
|
3062
|
+
interval
|
|
3063
|
+
});
|
|
3064
|
+
return {
|
|
3065
|
+
symbol,
|
|
3066
|
+
interval,
|
|
3067
|
+
range,
|
|
3068
|
+
trades,
|
|
3069
|
+
positions,
|
|
3070
|
+
openPositions: [],
|
|
3071
|
+
metrics,
|
|
3072
|
+
eqSeries,
|
|
3073
|
+
replay: {
|
|
3074
|
+
frames: replayFrames,
|
|
3075
|
+
events: replayEvents
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
2843
3078
|
}
|
|
2844
3079
|
|
|
2845
3080
|
// src/engine/portfolio.js
|
|
@@ -3114,6 +3349,74 @@ function backtestPortfolio({
|
|
|
3114
3349
|
};
|
|
3115
3350
|
}
|
|
3116
3351
|
|
|
3352
|
+
// src/engine/llmSignal.js
|
|
3353
|
+
function isArrayIndexKey3(property) {
|
|
3354
|
+
if (typeof property !== "string") return false;
|
|
3355
|
+
const n = Number(property);
|
|
3356
|
+
return Number.isInteger(n) && n >= 0;
|
|
3357
|
+
}
|
|
3358
|
+
function noLookaheadView(candles, index) {
|
|
3359
|
+
return new Proxy(candles, {
|
|
3360
|
+
get(target, property, receiver) {
|
|
3361
|
+
if (isArrayIndexKey3(property) && Number(property) > index) {
|
|
3362
|
+
throw new Error(
|
|
3363
|
+
`LlmSignal: lookahead access to candles[${String(property)}] (current index ${index})`
|
|
3364
|
+
);
|
|
3365
|
+
}
|
|
3366
|
+
return Reflect.get(target, property, receiver);
|
|
3367
|
+
}
|
|
3368
|
+
});
|
|
3369
|
+
}
|
|
3370
|
+
var LlmSignal = class {
|
|
3371
|
+
constructor({ resolve, budgetMs = 0, onError = "skip" } = {}) {
|
|
3372
|
+
if (typeof resolve !== "function") {
|
|
3373
|
+
throw new Error("LlmSignal requires a resolve(context) function");
|
|
3374
|
+
}
|
|
3375
|
+
this.resolve = resolve;
|
|
3376
|
+
this.budgetMs = budgetMs;
|
|
3377
|
+
this.onError = onError;
|
|
3378
|
+
this.log = [];
|
|
3379
|
+
this._cache = /* @__PURE__ */ new Map();
|
|
3380
|
+
this.signal = this.signal.bind(this);
|
|
3381
|
+
}
|
|
3382
|
+
async signal(context) {
|
|
3383
|
+
const key = context.bar?.time ?? context.index;
|
|
3384
|
+
if (this._cache.has(key)) return this._cache.get(key);
|
|
3385
|
+
const safeContext = {
|
|
3386
|
+
...context,
|
|
3387
|
+
candles: noLookaheadView(context.candles, context.index)
|
|
3388
|
+
};
|
|
3389
|
+
const startedAt = Date.now();
|
|
3390
|
+
try {
|
|
3391
|
+
const result = await withBudget(
|
|
3392
|
+
Promise.resolve().then(() => this.resolve(safeContext)),
|
|
3393
|
+
this.budgetMs
|
|
3394
|
+
);
|
|
3395
|
+
this._cache.set(key, result ?? null);
|
|
3396
|
+
this.log.push({
|
|
3397
|
+
index: context.index,
|
|
3398
|
+
time: context.bar?.time,
|
|
3399
|
+
close: context.bar?.close,
|
|
3400
|
+
latencyMs: Date.now() - startedAt,
|
|
3401
|
+
result: result ?? null
|
|
3402
|
+
});
|
|
3403
|
+
return result ?? null;
|
|
3404
|
+
} catch (error) {
|
|
3405
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3406
|
+
this.log.push({
|
|
3407
|
+
index: context.index,
|
|
3408
|
+
time: context.bar?.time,
|
|
3409
|
+
close: context.bar?.close,
|
|
3410
|
+
latencyMs: Date.now() - startedAt,
|
|
3411
|
+
error: message
|
|
3412
|
+
});
|
|
3413
|
+
this._cache.set(key, null);
|
|
3414
|
+
if (this.onError === "throw") throw error;
|
|
3415
|
+
return null;
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
};
|
|
3419
|
+
|
|
3117
3420
|
// src/engine/walkForward.js
|
|
3118
3421
|
function scoreOf(metrics, scoreBy) {
|
|
3119
3422
|
const value = metrics?.[scoreBy];
|
|
@@ -3306,7 +3609,8 @@ function walkForwardOptimize({
|
|
|
3306
3609
|
equityFinal: rollingEquity,
|
|
3307
3610
|
candles,
|
|
3308
3611
|
estBarMs: estimateBarMs(candles),
|
|
3309
|
-
eqSeries
|
|
3612
|
+
eqSeries,
|
|
3613
|
+
interval: backtestOptions.interval
|
|
3310
3614
|
});
|
|
3311
3615
|
const bestParamsSummary = summarizeBestParams(windows);
|
|
3312
3616
|
return {
|
|
@@ -3325,6 +3629,580 @@ function walkForwardOptimize({
|
|
|
3325
3629
|
};
|
|
3326
3630
|
}
|
|
3327
3631
|
|
|
3632
|
+
// src/engine/optimize.js
|
|
3633
|
+
var import_node_worker_threads = require("node:worker_threads");
|
|
3634
|
+
var import_node_os = __toESM(require("node:os"), 1);
|
|
3635
|
+
var import_meta = {};
|
|
3636
|
+
function defaultConcurrency() {
|
|
3637
|
+
return Math.max(1, (import_node_os.default.cpus()?.length ?? 2) - 1);
|
|
3638
|
+
}
|
|
3639
|
+
function scoreValue(metrics, scoreBy) {
|
|
3640
|
+
const v = metrics?.[scoreBy];
|
|
3641
|
+
return Number.isFinite(v) ? v : -Infinity;
|
|
3642
|
+
}
|
|
3643
|
+
function optimize({
|
|
3644
|
+
candles,
|
|
3645
|
+
signalModulePath,
|
|
3646
|
+
parameterSets,
|
|
3647
|
+
interval,
|
|
3648
|
+
backtestOptions = {},
|
|
3649
|
+
concurrency,
|
|
3650
|
+
scoreBy = "profitFactor"
|
|
3651
|
+
}) {
|
|
3652
|
+
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
3653
|
+
return Promise.resolve({ results: [], leaderboard: [], best: null });
|
|
3654
|
+
}
|
|
3655
|
+
return new Promise((resolve, reject) => {
|
|
3656
|
+
const poolSize = Math.min(concurrency || defaultConcurrency(), parameterSets.length);
|
|
3657
|
+
const results = new Array(parameterSets.length);
|
|
3658
|
+
const workers = [];
|
|
3659
|
+
let nextIndex = 0;
|
|
3660
|
+
let completed = 0;
|
|
3661
|
+
let settled = false;
|
|
3662
|
+
const finish = () => {
|
|
3663
|
+
if (settled) return;
|
|
3664
|
+
settled = true;
|
|
3665
|
+
for (const w of workers) w.terminate();
|
|
3666
|
+
const ranked = results.filter((r) => r && r.metrics).sort((a, b) => scoreValue(b.metrics, scoreBy) - scoreValue(a.metrics, scoreBy));
|
|
3667
|
+
resolve({ results, leaderboard: ranked, best: ranked[0] ?? null });
|
|
3668
|
+
};
|
|
3669
|
+
const fail = (error) => {
|
|
3670
|
+
if (settled) return;
|
|
3671
|
+
settled = true;
|
|
3672
|
+
for (const w of workers) w.terminate();
|
|
3673
|
+
reject(error);
|
|
3674
|
+
};
|
|
3675
|
+
const dispatch = (worker) => {
|
|
3676
|
+
if (nextIndex >= parameterSets.length) {
|
|
3677
|
+
worker.postMessage({ type: "stop" });
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
const index = nextIndex;
|
|
3681
|
+
nextIndex += 1;
|
|
3682
|
+
worker.postMessage({ type: "run", index, params: parameterSets[index] });
|
|
3683
|
+
};
|
|
3684
|
+
for (let i = 0; i < poolSize; i += 1) {
|
|
3685
|
+
const worker = new import_node_worker_threads.Worker(new URL("./optimizeWorker.js", import_meta.url), {
|
|
3686
|
+
workerData: { candles, signalModulePath, interval, backtestOptions }
|
|
3687
|
+
});
|
|
3688
|
+
workers.push(worker);
|
|
3689
|
+
worker.on("message", (msg) => {
|
|
3690
|
+
if (msg.type === "ready") {
|
|
3691
|
+
dispatch(worker);
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
if (msg.type === "result" || msg.type === "error") {
|
|
3695
|
+
results[msg.index] = msg.type === "result" ? { params: msg.params, metrics: msg.metrics } : { params: msg.params, error: msg.error };
|
|
3696
|
+
completed += 1;
|
|
3697
|
+
if (completed === parameterSets.length) finish();
|
|
3698
|
+
else dispatch(worker);
|
|
3699
|
+
}
|
|
3700
|
+
});
|
|
3701
|
+
worker.on("error", fail);
|
|
3702
|
+
}
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// src/engine/grid.js
|
|
3707
|
+
function grid(spec = {}) {
|
|
3708
|
+
const keys = Object.keys(spec);
|
|
3709
|
+
if (!keys.length) return [{}];
|
|
3710
|
+
return keys.reduce(
|
|
3711
|
+
(acc, key) => {
|
|
3712
|
+
const values = Array.isArray(spec[key]) ? spec[key] : [spec[key]];
|
|
3713
|
+
return acc.flatMap((base) => values.map((v) => ({ ...base, [key]: v })));
|
|
3714
|
+
},
|
|
3715
|
+
[{}]
|
|
3716
|
+
);
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
// src/ta/oscillators.js
|
|
3720
|
+
function rsi(closes, period = 14) {
|
|
3721
|
+
const out = new Array(closes.length).fill(void 0);
|
|
3722
|
+
if (closes.length <= period) return out;
|
|
3723
|
+
let gainSum = 0;
|
|
3724
|
+
let lossSum = 0;
|
|
3725
|
+
for (let i = 1; i <= period; i += 1) {
|
|
3726
|
+
const change = closes[i] - closes[i - 1];
|
|
3727
|
+
if (change >= 0) gainSum += change;
|
|
3728
|
+
else lossSum -= change;
|
|
3729
|
+
}
|
|
3730
|
+
let avgGain = gainSum / period;
|
|
3731
|
+
let avgLoss = lossSum / period;
|
|
3732
|
+
out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
|
|
3733
|
+
for (let i = period + 1; i < closes.length; i += 1) {
|
|
3734
|
+
const change = closes[i] - closes[i - 1];
|
|
3735
|
+
const gain = change > 0 ? change : 0;
|
|
3736
|
+
const loss = change < 0 ? -change : 0;
|
|
3737
|
+
avgGain = (avgGain * (period - 1) + gain) / period;
|
|
3738
|
+
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
|
3739
|
+
out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
|
|
3740
|
+
}
|
|
3741
|
+
return out;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
// src/ta/channels.js
|
|
3745
|
+
function donchian(bars, period = 20) {
|
|
3746
|
+
const upper = new Array(bars.length).fill(void 0);
|
|
3747
|
+
const lower = new Array(bars.length).fill(void 0);
|
|
3748
|
+
const middle = new Array(bars.length).fill(void 0);
|
|
3749
|
+
for (let i = period - 1; i < bars.length; i += 1) {
|
|
3750
|
+
let hh = -Infinity;
|
|
3751
|
+
let ll = Infinity;
|
|
3752
|
+
for (let j = i - period + 1; j <= i; j += 1) {
|
|
3753
|
+
if (bars[j].high > hh) hh = bars[j].high;
|
|
3754
|
+
if (bars[j].low < ll) ll = bars[j].low;
|
|
3755
|
+
}
|
|
3756
|
+
upper[i] = hh;
|
|
3757
|
+
lower[i] = ll;
|
|
3758
|
+
middle[i] = (hh + ll) / 2;
|
|
3759
|
+
}
|
|
3760
|
+
return { upper, lower, middle };
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
// src/strategies/builtins.js
|
|
3764
|
+
var BUILTINS = {
|
|
3765
|
+
"ema-cross": {
|
|
3766
|
+
description: "Long when fast EMA crosses above slow EMA; stop at recent swing low.",
|
|
3767
|
+
params: {
|
|
3768
|
+
fast: { type: "number", default: 10, description: "fast EMA period" },
|
|
3769
|
+
slow: { type: "number", default: 30, description: "slow EMA period" },
|
|
3770
|
+
rr: { type: "number", default: 2, description: "reward:risk target" },
|
|
3771
|
+
lookback: { type: "number", default: 15, description: "swing-low lookback for stop" }
|
|
3772
|
+
},
|
|
3773
|
+
factory({ fast = 10, slow = 30, rr = 2, lookback = 15 } = {}) {
|
|
3774
|
+
return ({ candles, bar }) => {
|
|
3775
|
+
if (candles.length < slow + 2) return null;
|
|
3776
|
+
const closes = candles.map((c) => c.close);
|
|
3777
|
+
const f = ema(closes, fast);
|
|
3778
|
+
const s = ema(closes, slow);
|
|
3779
|
+
const last = closes.length - 1;
|
|
3780
|
+
if (f[last - 1] <= s[last - 1] && f[last] > s[last]) {
|
|
3781
|
+
const stop = Math.min(...candles.slice(-lookback).map((c) => c.low));
|
|
3782
|
+
if (stop >= bar.close) return null;
|
|
3783
|
+
return { side: "long", entry: bar.close, stop, rr };
|
|
3784
|
+
}
|
|
3785
|
+
return null;
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3788
|
+
},
|
|
3789
|
+
"rsi-reversion": {
|
|
3790
|
+
description: "Long when RSI dips below `oversold`; stop a fixed pct below entry.",
|
|
3791
|
+
params: {
|
|
3792
|
+
period: { type: "number", default: 14, description: "RSI period" },
|
|
3793
|
+
oversold: { type: "number", default: 30, description: "RSI entry threshold" },
|
|
3794
|
+
stopPct: { type: "number", default: 2, description: "stop distance in percent" },
|
|
3795
|
+
rr: { type: "number", default: 1.5, description: "reward:risk target" }
|
|
3796
|
+
},
|
|
3797
|
+
factory({ period = 14, oversold = 30, stopPct = 2, rr = 1.5 } = {}) {
|
|
3798
|
+
return ({ candles, bar }) => {
|
|
3799
|
+
if (candles.length < period + 2) return null;
|
|
3800
|
+
const values = rsi(
|
|
3801
|
+
candles.map((c) => c.close),
|
|
3802
|
+
period
|
|
3803
|
+
);
|
|
3804
|
+
const r = values[values.length - 1];
|
|
3805
|
+
if (r === void 0 || r > oversold) return null;
|
|
3806
|
+
return { side: "long", entry: bar.close, stop: bar.close * (1 - stopPct / 100), rr };
|
|
3807
|
+
};
|
|
3808
|
+
}
|
|
3809
|
+
},
|
|
3810
|
+
"donchian-breakout": {
|
|
3811
|
+
description: "Long on a close above the prior Donchian upper channel.",
|
|
3812
|
+
params: {
|
|
3813
|
+
period: { type: "number", default: 20, description: "channel lookback" },
|
|
3814
|
+
rr: { type: "number", default: 2, description: "reward:risk target" }
|
|
3815
|
+
},
|
|
3816
|
+
factory({ period = 20, rr = 2 } = {}) {
|
|
3817
|
+
return ({ candles, bar }) => {
|
|
3818
|
+
if (candles.length < period + 2) return null;
|
|
3819
|
+
const ch = donchian(candles, period);
|
|
3820
|
+
const i = candles.length - 1;
|
|
3821
|
+
const priorUpper = ch.upper[i - 1];
|
|
3822
|
+
const priorLower = ch.lower[i - 1];
|
|
3823
|
+
if (priorUpper === void 0) return null;
|
|
3824
|
+
if (bar.close > priorUpper) {
|
|
3825
|
+
return { side: "long", entry: bar.close, stop: priorLower, rr };
|
|
3826
|
+
}
|
|
3827
|
+
return null;
|
|
3828
|
+
};
|
|
3829
|
+
}
|
|
3830
|
+
},
|
|
3831
|
+
"buy-hold": {
|
|
3832
|
+
description: "Enter once at the first eligible bar and hold for `holdBars`.",
|
|
3833
|
+
params: {
|
|
3834
|
+
holdBars: { type: "number", default: 5, description: "bars to hold before exit" },
|
|
3835
|
+
stopPct: { type: "number", default: 10, description: "protective stop distance in percent" }
|
|
3836
|
+
},
|
|
3837
|
+
factory({ holdBars = 5, stopPct = 10 } = {}) {
|
|
3838
|
+
let entered = false;
|
|
3839
|
+
return ({ bar }) => {
|
|
3840
|
+
if (entered) return null;
|
|
3841
|
+
entered = true;
|
|
3842
|
+
return {
|
|
3843
|
+
side: "long",
|
|
3844
|
+
entry: bar.close,
|
|
3845
|
+
stop: bar.close * (1 - stopPct / 100),
|
|
3846
|
+
rr: 5,
|
|
3847
|
+
_maxBarsInTrade: holdBars
|
|
3848
|
+
};
|
|
3849
|
+
};
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
|
|
3854
|
+
// src/strategies/index.js
|
|
3855
|
+
var registry = new Map(Object.entries(BUILTINS));
|
|
3856
|
+
function registerStrategy(name, def) {
|
|
3857
|
+
if (typeof def?.factory !== "function") {
|
|
3858
|
+
throw new Error(`registerStrategy("${name}") requires a factory function`);
|
|
3859
|
+
}
|
|
3860
|
+
registry.set(name, def);
|
|
3861
|
+
}
|
|
3862
|
+
function listStrategies() {
|
|
3863
|
+
return [...registry.entries()].map(([name, def]) => ({
|
|
3864
|
+
name,
|
|
3865
|
+
description: def.description,
|
|
3866
|
+
params: def.params
|
|
3867
|
+
}));
|
|
3868
|
+
}
|
|
3869
|
+
function getStrategy(name) {
|
|
3870
|
+
const def = registry.get(name);
|
|
3871
|
+
if (!def) {
|
|
3872
|
+
const available = [...registry.keys()].join(", ");
|
|
3873
|
+
throw new Error(`Unknown strategy "${name}". Available: ${available}`);
|
|
3874
|
+
}
|
|
3875
|
+
return def.factory;
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
// src/research/index.js
|
|
3879
|
+
var research_exports = {};
|
|
3880
|
+
__export(research_exports, {
|
|
3881
|
+
combinations: () => combinations,
|
|
3882
|
+
combinatorialPurgedSplits: () => combinatorialPurgedSplits,
|
|
3883
|
+
deflatedSharpe: () => deflatedSharpe,
|
|
3884
|
+
moments: () => moments,
|
|
3885
|
+
monteCarlo: () => monteCarlo,
|
|
3886
|
+
normalCdf: () => normalCdf,
|
|
3887
|
+
normalPpf: () => normalPpf,
|
|
3888
|
+
probabilityOfBacktestOverfitting: () => probabilityOfBacktestOverfitting,
|
|
3889
|
+
sweepHaircut: () => sweepHaircut
|
|
3890
|
+
});
|
|
3891
|
+
|
|
3892
|
+
// src/utils/random.js
|
|
3893
|
+
function xmur32(str) {
|
|
3894
|
+
let h = 1779033703 ^ str.length;
|
|
3895
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
3896
|
+
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
|
3897
|
+
h = h << 13 | h >>> 19;
|
|
3898
|
+
}
|
|
3899
|
+
return () => {
|
|
3900
|
+
h = Math.imul(h ^ h >>> 16, 2246822507);
|
|
3901
|
+
h = Math.imul(h ^ h >>> 13, 3266489909);
|
|
3902
|
+
return (h ^= h >>> 16) >>> 0;
|
|
3903
|
+
};
|
|
3904
|
+
}
|
|
3905
|
+
function mulberry322(seed) {
|
|
3906
|
+
let state = seed >>> 0;
|
|
3907
|
+
return () => {
|
|
3908
|
+
state = state + 1831565813 >>> 0;
|
|
3909
|
+
let t = Math.imul(state ^ state >>> 15, state | 1);
|
|
3910
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
3911
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3914
|
+
function makeRng(seed = "tradelab") {
|
|
3915
|
+
const seedFn = xmur32(String(seed));
|
|
3916
|
+
return mulberry322(seedFn());
|
|
3917
|
+
}
|
|
3918
|
+
function randInt(rng, maxExclusive) {
|
|
3919
|
+
return Math.floor(rng() * maxExclusive);
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
// src/research/monteCarlo.js
|
|
3923
|
+
function percentile2(sorted, p) {
|
|
3924
|
+
if (!sorted.length) return 0;
|
|
3925
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p)));
|
|
3926
|
+
return sorted[idx];
|
|
3927
|
+
}
|
|
3928
|
+
function maxDrawdownOf(equityPath) {
|
|
3929
|
+
let peak = equityPath[0];
|
|
3930
|
+
let maxDd = 0;
|
|
3931
|
+
for (const e of equityPath) {
|
|
3932
|
+
if (e > peak) peak = e;
|
|
3933
|
+
const dd = peak > 0 ? (peak - e) / peak : 0;
|
|
3934
|
+
if (dd > maxDd) maxDd = dd;
|
|
3935
|
+
}
|
|
3936
|
+
return maxDd;
|
|
3937
|
+
}
|
|
3938
|
+
function monteCarlo({
|
|
3939
|
+
tradePnls,
|
|
3940
|
+
equityStart = 1e4,
|
|
3941
|
+
iterations = 1e3,
|
|
3942
|
+
blockSize = 1,
|
|
3943
|
+
seed = "tradelab-mc"
|
|
3944
|
+
}) {
|
|
3945
|
+
if (!Array.isArray(tradePnls) || tradePnls.length === 0) {
|
|
3946
|
+
throw new Error("monteCarlo() requires a non-empty tradePnls array");
|
|
3947
|
+
}
|
|
3948
|
+
const rng = makeRng(seed);
|
|
3949
|
+
const n = tradePnls.length;
|
|
3950
|
+
const block = Math.max(1, Math.floor(blockSize));
|
|
3951
|
+
const finals = [];
|
|
3952
|
+
const drawdowns = [];
|
|
3953
|
+
const pathSamples = Array.from({ length: n + 1 }, () => []);
|
|
3954
|
+
for (let it = 0; it < iterations; it += 1) {
|
|
3955
|
+
const path6 = [equityStart];
|
|
3956
|
+
let equity = equityStart;
|
|
3957
|
+
let filled = 0;
|
|
3958
|
+
while (filled < n) {
|
|
3959
|
+
const start = randInt(rng, n);
|
|
3960
|
+
for (let k = 0; k < block && filled < n; k += 1) {
|
|
3961
|
+
equity += tradePnls[(start + k) % n];
|
|
3962
|
+
path6.push(equity);
|
|
3963
|
+
filled += 1;
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
for (let step = 0; step < path6.length; step += 1) {
|
|
3967
|
+
pathSamples[step].push(path6[step]);
|
|
3968
|
+
}
|
|
3969
|
+
finals.push(equity);
|
|
3970
|
+
drawdowns.push(maxDrawdownOf(path6));
|
|
3971
|
+
}
|
|
3972
|
+
const sortedFinals = [...finals].sort((a, b) => a - b);
|
|
3973
|
+
const sortedDd = [...drawdowns].sort((a, b) => a - b);
|
|
3974
|
+
const pathBands = pathSamples.map((samples) => {
|
|
3975
|
+
const s = [...samples].sort((a, b) => a - b);
|
|
3976
|
+
return { p5: percentile2(s, 0.05), p50: percentile2(s, 0.5), p95: percentile2(s, 0.95) };
|
|
3977
|
+
});
|
|
3978
|
+
const bands = (sorted) => ({
|
|
3979
|
+
p5: percentile2(sorted, 0.05),
|
|
3980
|
+
p25: percentile2(sorted, 0.25),
|
|
3981
|
+
p50: percentile2(sorted, 0.5),
|
|
3982
|
+
p75: percentile2(sorted, 0.75),
|
|
3983
|
+
p95: percentile2(sorted, 0.95)
|
|
3984
|
+
});
|
|
3985
|
+
return {
|
|
3986
|
+
iterations,
|
|
3987
|
+
blockSize: block,
|
|
3988
|
+
finalEquity: bands(sortedFinals),
|
|
3989
|
+
maxDrawdown: bands(sortedDd),
|
|
3990
|
+
pathBands,
|
|
3991
|
+
probProfit: finals.filter((f) => f > equityStart).length / iterations
|
|
3992
|
+
};
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
// src/research/stats.js
|
|
3996
|
+
function normalCdf(x) {
|
|
3997
|
+
const sign = x < 0 ? -1 : 1;
|
|
3998
|
+
const ax = Math.abs(x) / Math.SQRT2;
|
|
3999
|
+
const t = 1 / (1 + 0.3275911 * ax);
|
|
4000
|
+
const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
|
|
4001
|
+
return 0.5 * (1 + sign * y);
|
|
4002
|
+
}
|
|
4003
|
+
function normalPpf(p) {
|
|
4004
|
+
if (p <= 0) return -Infinity;
|
|
4005
|
+
if (p >= 1) return Infinity;
|
|
4006
|
+
const a = [
|
|
4007
|
+
-39.69683028665376,
|
|
4008
|
+
220.9460984245205,
|
|
4009
|
+
-275.9285104469687,
|
|
4010
|
+
138.357751867269,
|
|
4011
|
+
-30.66479806614716,
|
|
4012
|
+
2.506628277459239
|
|
4013
|
+
];
|
|
4014
|
+
const b = [
|
|
4015
|
+
-54.47609879822406,
|
|
4016
|
+
161.5858368580409,
|
|
4017
|
+
-155.6989798598866,
|
|
4018
|
+
66.80131188771972,
|
|
4019
|
+
-13.28068155288572
|
|
4020
|
+
];
|
|
4021
|
+
const c = [
|
|
4022
|
+
-0.007784894002430293,
|
|
4023
|
+
-0.3223964580411365,
|
|
4024
|
+
-2.400758277161838,
|
|
4025
|
+
-2.549732539343734,
|
|
4026
|
+
4.374664141464968,
|
|
4027
|
+
2.938163982698783
|
|
4028
|
+
];
|
|
4029
|
+
const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
|
|
4030
|
+
const plow = 0.02425;
|
|
4031
|
+
const phigh = 1 - plow;
|
|
4032
|
+
let q;
|
|
4033
|
+
let r;
|
|
4034
|
+
if (p < plow) {
|
|
4035
|
+
q = Math.sqrt(-2 * Math.log(p));
|
|
4036
|
+
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
4037
|
+
}
|
|
4038
|
+
if (p <= phigh) {
|
|
4039
|
+
q = p - 0.5;
|
|
4040
|
+
r = q * q;
|
|
4041
|
+
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
|
4042
|
+
}
|
|
4043
|
+
q = Math.sqrt(-2 * Math.log(1 - p));
|
|
4044
|
+
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
4045
|
+
}
|
|
4046
|
+
function moments(values) {
|
|
4047
|
+
const n = values.length;
|
|
4048
|
+
if (n < 2) return { mean: values[0] ?? 0, std: 0, skew: 0, kurtosis: 3 };
|
|
4049
|
+
const mean3 = values.reduce((a, b) => a + b, 0) / n;
|
|
4050
|
+
let m2 = 0;
|
|
4051
|
+
let m3 = 0;
|
|
4052
|
+
let m4 = 0;
|
|
4053
|
+
for (const v of values) {
|
|
4054
|
+
const d = v - mean3;
|
|
4055
|
+
m2 += d * d;
|
|
4056
|
+
m3 += d * d * d;
|
|
4057
|
+
m4 += d * d * d * d;
|
|
4058
|
+
}
|
|
4059
|
+
m2 /= n;
|
|
4060
|
+
m3 /= n;
|
|
4061
|
+
m4 /= n;
|
|
4062
|
+
const std = Math.sqrt(m2);
|
|
4063
|
+
const skew = std === 0 ? 0 : m3 / std ** 3;
|
|
4064
|
+
const kurtosis = m2 === 0 ? 3 : m4 / m2 ** 2;
|
|
4065
|
+
return { mean: mean3, std, skew, kurtosis };
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
// src/research/deflatedSharpe.js
|
|
4069
|
+
var EULER_MASCHERONI = 0.5772156649015329;
|
|
4070
|
+
function sweepHaircut({ numTrials, sharpeStd }) {
|
|
4071
|
+
const N = Math.max(1, numTrials);
|
|
4072
|
+
const a = normalPpf(1 - 1 / N);
|
|
4073
|
+
const b = normalPpf(1 - 1 / (N * Math.E));
|
|
4074
|
+
const expectedMaxSharpe = sharpeStd * ((1 - EULER_MASCHERONI) * a + EULER_MASCHERONI * b);
|
|
4075
|
+
return { expectedMaxSharpe, numTrials: N };
|
|
4076
|
+
}
|
|
4077
|
+
function deflatedSharpe({
|
|
4078
|
+
sharpe,
|
|
4079
|
+
sampleSize,
|
|
4080
|
+
numTrials = 1,
|
|
4081
|
+
sharpeStd = 0,
|
|
4082
|
+
skew = 0,
|
|
4083
|
+
kurtosis = 3
|
|
4084
|
+
}) {
|
|
4085
|
+
const sr0 = sweepHaircut({ numTrials, sharpeStd }).expectedMaxSharpe;
|
|
4086
|
+
const denom = Math.sqrt(
|
|
4087
|
+
Math.max(1e-12, 1 - skew * sharpe + (kurtosis - 1) / 4 * sharpe * sharpe)
|
|
4088
|
+
);
|
|
4089
|
+
const z = (sharpe - sr0) * Math.sqrt(Math.max(1, sampleSize - 1)) / denom;
|
|
4090
|
+
return normalCdf(z);
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// src/research/combinations.js
|
|
4094
|
+
function combinations(n, k) {
|
|
4095
|
+
const result = [];
|
|
4096
|
+
const combo = [];
|
|
4097
|
+
function recurse(start) {
|
|
4098
|
+
if (combo.length === k) {
|
|
4099
|
+
result.push([...combo]);
|
|
4100
|
+
return;
|
|
4101
|
+
}
|
|
4102
|
+
for (let i = start; i < n; i += 1) {
|
|
4103
|
+
combo.push(i);
|
|
4104
|
+
recurse(i + 1);
|
|
4105
|
+
combo.pop();
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
recurse(0);
|
|
4109
|
+
return result;
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
// src/research/pbo.js
|
|
4113
|
+
function sharpeOf(returns) {
|
|
4114
|
+
const n = returns.length;
|
|
4115
|
+
if (n < 2) return 0;
|
|
4116
|
+
const mean3 = returns.reduce((a, b) => a + b, 0) / n;
|
|
4117
|
+
let variance = 0;
|
|
4118
|
+
for (const r of returns) variance += (r - mean3) ** 2;
|
|
4119
|
+
variance /= n - 1;
|
|
4120
|
+
const std = Math.sqrt(variance);
|
|
4121
|
+
if (std === 0) {
|
|
4122
|
+
if (mean3 > 0) return Infinity;
|
|
4123
|
+
if (mean3 < 0) return -Infinity;
|
|
4124
|
+
return 0;
|
|
4125
|
+
}
|
|
4126
|
+
return mean3 / std;
|
|
4127
|
+
}
|
|
4128
|
+
function probabilityOfBacktestOverfitting(performanceMatrix, { groups = 16 } = {}) {
|
|
4129
|
+
const nStrategies = performanceMatrix.length;
|
|
4130
|
+
if (nStrategies < 2) throw new Error("PBO needs at least 2 strategies");
|
|
4131
|
+
const nObs = performanceMatrix[0].length;
|
|
4132
|
+
const S = Math.min(groups, nObs);
|
|
4133
|
+
if (S % 2 !== 0) throw new Error("groups must be even");
|
|
4134
|
+
const groupIdx = Array.from({ length: S }, () => []);
|
|
4135
|
+
for (let i = 0; i < nObs; i += 1) groupIdx[Math.floor(i * S / nObs)].push(i);
|
|
4136
|
+
const isCombos = combinations(S, S / 2);
|
|
4137
|
+
const logits = [];
|
|
4138
|
+
let overfitCount = 0;
|
|
4139
|
+
for (const isGroups of isCombos) {
|
|
4140
|
+
const isSet = new Set(isGroups);
|
|
4141
|
+
const isIndices = [];
|
|
4142
|
+
const osIndices = [];
|
|
4143
|
+
for (let g = 0; g < S; g += 1) {
|
|
4144
|
+
(isSet.has(g) ? isIndices : osIndices).push(...groupIdx[g]);
|
|
4145
|
+
}
|
|
4146
|
+
const isScores = performanceMatrix.map((row) => sharpeOf(isIndices.map((i) => row[i])));
|
|
4147
|
+
const osScores = performanceMatrix.map((row) => sharpeOf(osIndices.map((i) => row[i])));
|
|
4148
|
+
let bestStrategy = 0;
|
|
4149
|
+
for (let s = 1; s < nStrategies; s += 1) {
|
|
4150
|
+
if (isScores[s] > isScores[bestStrategy]) bestStrategy = s;
|
|
4151
|
+
}
|
|
4152
|
+
const winnerOs = osScores[bestStrategy];
|
|
4153
|
+
let rank = 1;
|
|
4154
|
+
for (let s = 0; s < nStrategies; s += 1) {
|
|
4155
|
+
if (s !== bestStrategy && osScores[s] < winnerOs) rank += 1;
|
|
4156
|
+
}
|
|
4157
|
+
const relativeRank = rank / (nStrategies + 1);
|
|
4158
|
+
const logit = Math.log(relativeRank / (1 - relativeRank));
|
|
4159
|
+
logits.push(logit);
|
|
4160
|
+
if (relativeRank <= 0.5) overfitCount += 1;
|
|
4161
|
+
}
|
|
4162
|
+
return {
|
|
4163
|
+
pbo: overfitCount / isCombos.length,
|
|
4164
|
+
combos: isCombos.length,
|
|
4165
|
+
medianLogit: [...logits].sort((a, b) => a - b)[Math.floor(logits.length / 2)]
|
|
4166
|
+
};
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
// src/research/cpcv.js
|
|
4170
|
+
function combinatorialPurgedSplits({
|
|
4171
|
+
nObservations,
|
|
4172
|
+
nGroups = 6,
|
|
4173
|
+
nTestGroups = 2,
|
|
4174
|
+
embargo = 0
|
|
4175
|
+
}) {
|
|
4176
|
+
if (!(nObservations > 0)) throw new Error("nObservations must be positive");
|
|
4177
|
+
if (nTestGroups >= nGroups) throw new Error("nTestGroups must be < nGroups");
|
|
4178
|
+
const bounds = [];
|
|
4179
|
+
for (let g = 0; g < nGroups; g += 1) {
|
|
4180
|
+
bounds.push([
|
|
4181
|
+
Math.floor(g * nObservations / nGroups),
|
|
4182
|
+
Math.floor((g + 1) * nObservations / nGroups)
|
|
4183
|
+
]);
|
|
4184
|
+
}
|
|
4185
|
+
const splits = [];
|
|
4186
|
+
for (const testGroups of combinations(nGroups, nTestGroups)) {
|
|
4187
|
+
const testSet = /* @__PURE__ */ new Set();
|
|
4188
|
+
const purgeZones = [];
|
|
4189
|
+
for (const g of testGroups) {
|
|
4190
|
+
const [start, end] = bounds[g];
|
|
4191
|
+
for (let i = start; i < end; i += 1) testSet.add(i);
|
|
4192
|
+
purgeZones.push([start - embargo, end + embargo]);
|
|
4193
|
+
}
|
|
4194
|
+
const inPurge = (i) => purgeZones.some(([lo, hi]) => i >= lo && i < hi);
|
|
4195
|
+
const train = [];
|
|
4196
|
+
const testIdx = [];
|
|
4197
|
+
for (let i = 0; i < nObservations; i += 1) {
|
|
4198
|
+
if (testSet.has(i)) testIdx.push(i);
|
|
4199
|
+
else if (!inPurge(i)) train.push(i);
|
|
4200
|
+
}
|
|
4201
|
+
splits.push({ train, test: testIdx, testGroups });
|
|
4202
|
+
}
|
|
4203
|
+
return splits;
|
|
4204
|
+
}
|
|
4205
|
+
|
|
3328
4206
|
// src/data/index.js
|
|
3329
4207
|
var import_path2 = __toESM(require("path"), 1);
|
|
3330
4208
|
|
|
@@ -4025,16 +4903,21 @@ function exportBacktestArtifacts({
|
|
|
4025
4903
|
}
|
|
4026
4904
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4027
4905
|
0 && (module.exports = {
|
|
4906
|
+
BIG_NUMBER,
|
|
4907
|
+
LlmSignal,
|
|
4028
4908
|
atr,
|
|
4029
4909
|
backtest,
|
|
4910
|
+
backtestAsync,
|
|
4030
4911
|
backtestHistorical,
|
|
4031
4912
|
backtestPortfolio,
|
|
4032
4913
|
backtestTicks,
|
|
4914
|
+
benchmarkStats,
|
|
4033
4915
|
bpsOf,
|
|
4034
4916
|
buildMetrics,
|
|
4035
4917
|
cachedCandlesPath,
|
|
4036
4918
|
calculatePositionSize,
|
|
4037
4919
|
candleStats,
|
|
4920
|
+
clampFinite,
|
|
4038
4921
|
detectFVG,
|
|
4039
4922
|
ema,
|
|
4040
4923
|
exportBacktestArtifacts,
|
|
@@ -4044,18 +4927,25 @@ function exportBacktestArtifacts({
|
|
|
4044
4927
|
fetchHistorical,
|
|
4045
4928
|
fetchLatestCandle,
|
|
4046
4929
|
getHistoricalCandles,
|
|
4930
|
+
getStrategy,
|
|
4931
|
+
grid,
|
|
4047
4932
|
inWindowsET,
|
|
4048
4933
|
isSession,
|
|
4049
4934
|
lastSwing,
|
|
4935
|
+
listStrategies,
|
|
4050
4936
|
loadCandlesFromCSV,
|
|
4051
4937
|
loadCandlesFromCache,
|
|
4052
4938
|
mergeCandles,
|
|
4053
4939
|
minutesET,
|
|
4054
4940
|
normalizeCandles,
|
|
4055
4941
|
offsetET,
|
|
4942
|
+
optimize,
|
|
4056
4943
|
parseWindowsCSV,
|
|
4057
4944
|
pct,
|
|
4945
|
+
periodsPerYear,
|
|
4946
|
+
registerStrategy,
|
|
4058
4947
|
renderHtmlReport,
|
|
4948
|
+
research,
|
|
4059
4949
|
saveCandlesToCache,
|
|
4060
4950
|
structureState,
|
|
4061
4951
|
swingHigh,
|