tradelab 0.4.0 → 1.0.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/README.md +121 -52
- package/bin/tradelab.js +340 -49
- package/dist/cjs/data.cjs +210 -155
- package/dist/cjs/index.cjs +1782 -274
- package/dist/cjs/live.cjs +3350 -0
- package/docs/README.md +26 -9
- package/docs/api-reference.md +89 -26
- package/docs/backtest-engine.md +74 -60
- package/docs/data-reporting-cli.md +66 -36
- package/docs/examples.md +275 -0
- package/docs/live-trading.md +186 -0
- package/examples/yahooEmaCross.js +1 -6
- package/package.json +18 -3
- package/src/data/csv.js +24 -14
- package/src/data/index.js +1 -5
- package/src/data/yahoo.js +6 -19
- package/src/engine/backtest.js +137 -144
- package/src/engine/backtestTicks.js +481 -0
- package/src/engine/barSystemRunner.js +1027 -0
- package/src/engine/execution.js +11 -39
- package/src/engine/portfolio.js +237 -66
- package/src/engine/walkForward.js +132 -13
- package/src/index.js +3 -11
- package/src/live/broker/alpaca.js +254 -0
- package/src/live/broker/binance.js +351 -0
- package/src/live/broker/coinbase.js +339 -0
- package/src/live/broker/interactiveBrokers.js +123 -0
- package/src/live/broker/interface.js +74 -0
- package/src/live/clock.js +56 -0
- package/src/live/engine/candleAggregator.js +154 -0
- package/src/live/engine/liveEngine.js +694 -0
- package/src/live/engine/paperEngine.js +453 -0
- package/src/live/engine/riskManager.js +185 -0
- package/src/live/engine/stateManager.js +112 -0
- package/src/live/events.js +48 -0
- package/src/live/feed/brokerFeed.js +35 -0
- package/src/live/feed/interface.js +28 -0
- package/src/live/feed/pollingFeed.js +105 -0
- package/src/live/index.js +27 -0
- package/src/live/logger.js +82 -0
- package/src/live/orchestrator.js +133 -0
- package/src/live/storage/interface.js +36 -0
- package/src/live/storage/jsonFileStorage.js +112 -0
- package/src/metrics/buildMetrics.js +103 -100
- package/src/reporting/exportBacktestArtifacts.js +1 -4
- package/src/reporting/exportTradesCsv.js +2 -7
- package/src/reporting/renderHtmlReport.js +8 -13
- package/src/utils/indicators.js +1 -2
- package/src/utils/positionSizing.js +16 -2
- package/src/utils/time.js +4 -12
- package/templates/report.html +23 -9
- package/templates/report.js +83 -69
- package/types/index.d.ts +98 -4
- package/types/live.d.ts +382 -0
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
@@ -33,6 +34,7 @@ __export(index_exports, {
|
|
|
33
34
|
backtest: () => backtest,
|
|
34
35
|
backtestHistorical: () => backtestHistorical,
|
|
35
36
|
backtestPortfolio: () => backtestPortfolio,
|
|
37
|
+
backtestTicks: () => backtestTicks,
|
|
36
38
|
bpsOf: () => bpsOf,
|
|
37
39
|
buildMetrics: () => buildMetrics,
|
|
38
40
|
cachedCandlesPath: () => cachedCandlesPath,
|
|
@@ -188,6 +190,14 @@ var pct = (a, b) => (a - b) / b;
|
|
|
188
190
|
function roundStep(value, step) {
|
|
189
191
|
return Math.floor(value / step) * step;
|
|
190
192
|
}
|
|
193
|
+
var warnedNonPositiveEquity = false;
|
|
194
|
+
function warnNonPositiveEquity(equity) {
|
|
195
|
+
if (warnedNonPositiveEquity) return;
|
|
196
|
+
warnedNonPositiveEquity = true;
|
|
197
|
+
console.warn(
|
|
198
|
+
`[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
191
201
|
function calculatePositionSize({
|
|
192
202
|
equity,
|
|
193
203
|
entry,
|
|
@@ -197,6 +207,10 @@ function calculatePositionSize({
|
|
|
197
207
|
minQty = 1e-3,
|
|
198
208
|
maxLeverage = 2
|
|
199
209
|
}) {
|
|
210
|
+
if (!Number.isFinite(equity) || equity <= 0) {
|
|
211
|
+
warnNonPositiveEquity(equity);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
200
214
|
const riskPerUnit = Math.abs(entry - stop);
|
|
201
215
|
if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
|
|
202
216
|
const maxRiskDollars = Math.max(0, equity * riskFraction);
|
|
@@ -308,64 +322,95 @@ function percentile(values, percentileRank) {
|
|
|
308
322
|
const index = Math.floor((sorted.length - 1) * percentileRank);
|
|
309
323
|
return sorted[index];
|
|
310
324
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}) {
|
|
319
|
-
const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
320
|
-
const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
|
|
321
|
-
const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
|
|
322
|
-
const tradeRs = completedTrades.map(tradeRMultiple);
|
|
323
|
-
const totalR = sum(tradeRs);
|
|
324
|
-
const avgR = mean(tradeRs);
|
|
325
|
-
const labels = completedTrades.map(
|
|
326
|
-
(trade) => trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
|
|
327
|
-
);
|
|
328
|
-
const { maxWin, maxLoss } = streaks(labels);
|
|
329
|
-
const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
|
|
330
|
-
const expectancy = mean(tradePnls);
|
|
331
|
-
const tradeReturns = completedTrades.map(
|
|
332
|
-
(trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
|
|
333
|
-
);
|
|
334
|
-
const tradeReturnStd = stddev(tradeReturns);
|
|
335
|
-
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
336
|
-
const sortinoPerTrade = sortino(tradeReturns);
|
|
337
|
-
const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
|
|
338
|
-
const grossLossPositions = Math.abs(
|
|
339
|
-
sum(losingTrades.map((trade) => trade.exit.pnl))
|
|
340
|
-
);
|
|
341
|
-
const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
|
|
325
|
+
var PROFIT_FACTOR_CAP = 1e6;
|
|
326
|
+
function finiteProfitFactor(grossProfit, grossLoss) {
|
|
327
|
+
if (grossLoss === 0) {
|
|
328
|
+
return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
|
|
329
|
+
}
|
|
330
|
+
return grossProfit / grossLoss;
|
|
331
|
+
}
|
|
332
|
+
function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
|
|
342
333
|
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
334
|
+
const completedTrades = [];
|
|
335
|
+
const tradeRs = [];
|
|
336
|
+
const tradePnls = [];
|
|
337
|
+
const tradeReturns = [];
|
|
338
|
+
const holdDurationsMinutes = [];
|
|
339
|
+
const labels = [];
|
|
340
|
+
const longRs = [];
|
|
341
|
+
const shortRs = [];
|
|
342
|
+
let totalR = 0;
|
|
343
|
+
let realizedPnL = 0;
|
|
344
|
+
let winningTradeCount = 0;
|
|
345
|
+
let grossProfitPositions = 0;
|
|
346
|
+
let grossLossPositions = 0;
|
|
347
|
+
let grossProfitLegs = 0;
|
|
348
|
+
let grossLossLegs = 0;
|
|
349
|
+
let winningLegCount = 0;
|
|
350
|
+
let openBars = 0;
|
|
351
|
+
let longTradesCount = 0;
|
|
352
|
+
let longTradeWins = 0;
|
|
353
|
+
let longPnLSum = 0;
|
|
354
|
+
let shortTradesCount = 0;
|
|
355
|
+
let shortTradeWins = 0;
|
|
356
|
+
let shortPnLSum = 0;
|
|
348
357
|
let peakEquity = equityStart;
|
|
349
358
|
let currentEquity = equityStart;
|
|
350
359
|
let maxDrawdown = 0;
|
|
351
|
-
for (const
|
|
352
|
-
|
|
360
|
+
for (const trade of legs) {
|
|
361
|
+
const pnl = trade.exit.pnl;
|
|
362
|
+
realizedPnL += pnl;
|
|
363
|
+
if (pnl > 0) {
|
|
364
|
+
grossProfitLegs += pnl;
|
|
365
|
+
winningLegCount += 1;
|
|
366
|
+
} else if (pnl < 0) {
|
|
367
|
+
grossLossLegs += Math.abs(pnl);
|
|
368
|
+
}
|
|
369
|
+
currentEquity += pnl;
|
|
353
370
|
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
354
371
|
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
355
372
|
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
373
|
+
if (trade.exit.reason === "SCALE") continue;
|
|
374
|
+
completedTrades.push(trade);
|
|
375
|
+
tradePnls.push(pnl);
|
|
376
|
+
tradeReturns.push(pnl / Math.max(1e-12, equityStart));
|
|
377
|
+
const tradeR = tradeRMultiple(trade);
|
|
378
|
+
tradeRs.push(tradeR);
|
|
379
|
+
totalR += tradeR;
|
|
380
|
+
labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
|
|
381
|
+
const holdMinutes = (trade.exit.time - trade.openTime) / (1e3 * 60);
|
|
382
|
+
holdDurationsMinutes.push(holdMinutes);
|
|
383
|
+
openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
384
|
+
if (pnl > 0) {
|
|
385
|
+
winningTradeCount += 1;
|
|
386
|
+
grossProfitPositions += pnl;
|
|
387
|
+
} else if (pnl < 0) {
|
|
388
|
+
grossLossPositions += Math.abs(pnl);
|
|
389
|
+
}
|
|
390
|
+
if (trade.side === "long") {
|
|
391
|
+
longTradesCount += 1;
|
|
392
|
+
longPnLSum += pnl;
|
|
393
|
+
longRs.push(tradeR);
|
|
394
|
+
if (pnl > 0) longTradeWins += 1;
|
|
395
|
+
} else if (trade.side === "short") {
|
|
396
|
+
shortTradesCount += 1;
|
|
397
|
+
shortPnLSum += pnl;
|
|
398
|
+
shortRs.push(tradeR);
|
|
399
|
+
if (pnl > 0) shortTradeWins += 1;
|
|
400
|
+
}
|
|
356
401
|
}
|
|
357
|
-
const
|
|
402
|
+
const avgR = mean(tradeRs);
|
|
403
|
+
const { maxWin, maxLoss } = streaks(labels);
|
|
404
|
+
const expectancy = mean(tradePnls);
|
|
405
|
+
const tradeReturnStd = stddev(tradeReturns);
|
|
406
|
+
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
407
|
+
const sortinoPerTrade = sortino(tradeReturns);
|
|
408
|
+
const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
|
|
409
|
+
const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
|
|
358
410
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
359
411
|
const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
|
|
360
412
|
const totalBars = Math.max(1, candles.length);
|
|
361
|
-
const openBars = completedTrades.reduce((total, trade) => {
|
|
362
|
-
const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
363
|
-
return total + barsHeld;
|
|
364
|
-
}, 0);
|
|
365
413
|
const exposurePct = openBars / totalBars;
|
|
366
|
-
const holdDurationsMinutes = completedTrades.map(
|
|
367
|
-
(trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
|
|
368
|
-
);
|
|
369
414
|
const avgHoldMin = mean(holdDurationsMinutes);
|
|
370
415
|
const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
|
|
371
416
|
const dailyReturnsSeries = dailyReturns(equitySeries);
|
|
@@ -373,12 +418,6 @@ function buildMetrics({
|
|
|
373
418
|
const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
|
|
374
419
|
const sortinoDaily = sortino(dailyReturnsSeries);
|
|
375
420
|
const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
|
|
376
|
-
const longTrades = completedTrades.filter((trade) => trade.side === "long");
|
|
377
|
-
const shortTrades = completedTrades.filter((trade) => trade.side === "short");
|
|
378
|
-
const longRs = longTrades.map(tradeRMultiple);
|
|
379
|
-
const shortRs = shortTrades.map(tradeRMultiple);
|
|
380
|
-
const longPnls = longTrades.map((trade) => trade.exit.pnl);
|
|
381
|
-
const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
|
|
382
421
|
const rDistribution = {
|
|
383
422
|
p10: percentile(tradeRs, 0.1),
|
|
384
423
|
p25: percentile(tradeRs, 0.25),
|
|
@@ -395,21 +434,21 @@ function buildMetrics({
|
|
|
395
434
|
};
|
|
396
435
|
const sideBreakdown = {
|
|
397
436
|
long: {
|
|
398
|
-
trades:
|
|
399
|
-
winRate:
|
|
400
|
-
avgPnL:
|
|
437
|
+
trades: longTradesCount,
|
|
438
|
+
winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
|
|
439
|
+
avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
|
|
401
440
|
avgR: mean(longRs)
|
|
402
441
|
},
|
|
403
442
|
short: {
|
|
404
|
-
trades:
|
|
405
|
-
winRate:
|
|
406
|
-
avgPnL:
|
|
443
|
+
trades: shortTradesCount,
|
|
444
|
+
winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
|
|
445
|
+
avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
|
|
407
446
|
avgR: mean(shortRs)
|
|
408
447
|
}
|
|
409
448
|
};
|
|
410
449
|
return {
|
|
411
450
|
trades: completedTrades.length,
|
|
412
|
-
winRate: completedTrades.length ?
|
|
451
|
+
winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
413
452
|
profitFactor: profitFactorPositions,
|
|
414
453
|
expectancy,
|
|
415
454
|
totalR,
|
|
@@ -431,8 +470,8 @@ function buildMetrics({
|
|
|
431
470
|
startEquity: equityStart,
|
|
432
471
|
profitFactor_pos: profitFactorPositions,
|
|
433
472
|
profitFactor_leg: profitFactorLegs,
|
|
434
|
-
winRate_pos: completedTrades.length ?
|
|
435
|
-
winRate_leg: legs.length ?
|
|
473
|
+
winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
474
|
+
winRate_leg: legs.length ? winningLegCount / legs.length : 0,
|
|
436
475
|
sharpeDaily,
|
|
437
476
|
sortinoDaily,
|
|
438
477
|
sideBreakdown,
|
|
@@ -536,7 +575,7 @@ function normalizeDateBoundary(value, fallback) {
|
|
|
536
575
|
}
|
|
537
576
|
function normalizeCandles(candles) {
|
|
538
577
|
if (!Array.isArray(candles)) return [];
|
|
539
|
-
const
|
|
578
|
+
const parsed = candles.map((bar) => {
|
|
540
579
|
try {
|
|
541
580
|
const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
|
|
542
581
|
const open = Number(bar?.open ?? bar?.o);
|
|
@@ -558,7 +597,16 @@ function normalizeCandles(candles) {
|
|
|
558
597
|
} catch {
|
|
559
598
|
return null;
|
|
560
599
|
}
|
|
561
|
-
}).filter(Boolean)
|
|
600
|
+
}).filter(Boolean);
|
|
601
|
+
let reordered = false;
|
|
602
|
+
let duplicateCount = 0;
|
|
603
|
+
for (let index = 1; index < parsed.length; index += 1) {
|
|
604
|
+
const prev = parsed[index - 1].time;
|
|
605
|
+
const current = parsed[index].time;
|
|
606
|
+
if (current < prev) reordered = true;
|
|
607
|
+
if (current === prev) duplicateCount += 1;
|
|
608
|
+
}
|
|
609
|
+
const normalized = parsed.sort((left, right) => left.time - right.time);
|
|
562
610
|
const deduped = [];
|
|
563
611
|
let lastTime = null;
|
|
564
612
|
for (const candle of normalized) {
|
|
@@ -566,6 +614,12 @@ function normalizeCandles(candles) {
|
|
|
566
614
|
deduped.push(candle);
|
|
567
615
|
lastTime = candle.time;
|
|
568
616
|
}
|
|
617
|
+
const removedDuplicates = normalized.length - deduped.length;
|
|
618
|
+
if (reordered || removedDuplicates > 0 || duplicateCount > 0) {
|
|
619
|
+
console.warn(
|
|
620
|
+
`[tradelab] normalizeCandles() reordered or deduplicated candles (input=${candles.length}, valid=${parsed.length}, output=${deduped.length})`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
569
623
|
return deduped;
|
|
570
624
|
}
|
|
571
625
|
function loadCandlesFromCSV(filePath, options = {}) {
|
|
@@ -608,9 +662,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
|
|
|
608
662
|
const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
|
|
609
663
|
const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
|
|
610
664
|
if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
|
|
611
|
-
throw new Error(
|
|
612
|
-
`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
|
|
613
|
-
);
|
|
665
|
+
throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
|
|
614
666
|
}
|
|
615
667
|
const minTime = normalizeDateBoundary(startDate, -Infinity);
|
|
616
668
|
const maxTime = normalizeDateBoundary(endDate, Infinity);
|
|
@@ -725,18 +777,12 @@ function usDstBoundsUTC(year) {
|
|
|
725
777
|
if (sundaysSeen === 2) break;
|
|
726
778
|
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
727
779
|
}
|
|
728
|
-
const dstStart = new Date(
|
|
729
|
-
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
730
|
-
);
|
|
780
|
+
const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
|
|
731
781
|
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
732
782
|
while (novemberCursor.getUTCDay() !== 0) {
|
|
733
|
-
novemberCursor = new Date(
|
|
734
|
-
novemberCursor.getTime() + 24 * 60 * 60 * 1e3
|
|
735
|
-
);
|
|
783
|
+
novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
736
784
|
}
|
|
737
|
-
const dstEnd = new Date(
|
|
738
|
-
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
739
|
-
);
|
|
785
|
+
const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
|
|
740
786
|
return { dstStart, dstEnd };
|
|
741
787
|
}
|
|
742
788
|
function isUsEasternDST(timeMs) {
|
|
@@ -804,11 +850,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
|
|
|
804
850
|
const model = costs || {};
|
|
805
851
|
const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
|
|
806
852
|
const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
|
|
807
|
-
const effectiveSlippageBps = resolveSlippageBps(
|
|
808
|
-
kind,
|
|
809
|
-
modelSlippageBps,
|
|
810
|
-
model.slippageByKind
|
|
811
|
-
);
|
|
853
|
+
const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
|
|
812
854
|
const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
|
|
813
855
|
const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
|
|
814
856
|
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
@@ -835,14 +877,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
|
|
|
835
877
|
}
|
|
836
878
|
return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
|
|
837
879
|
}
|
|
838
|
-
function ocoExitCheck({
|
|
839
|
-
side,
|
|
840
|
-
stop,
|
|
841
|
-
tp,
|
|
842
|
-
bar,
|
|
843
|
-
mode = "intrabar",
|
|
844
|
-
tieBreak = "pessimistic"
|
|
845
|
-
}) {
|
|
880
|
+
function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
|
|
846
881
|
if (mode === "close") {
|
|
847
882
|
const close = bar.close;
|
|
848
883
|
if (side === "long") {
|
|
@@ -923,13 +958,51 @@ function strictHistoryView(candles, currentIndex) {
|
|
|
923
958
|
get(target, property, receiver) {
|
|
924
959
|
if (isArrayIndexKey(property) && Number(property) >= target.length) {
|
|
925
960
|
throw new Error(
|
|
926
|
-
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
961
|
+
`strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
|
|
927
962
|
);
|
|
928
963
|
}
|
|
929
964
|
return Reflect.get(target, property, receiver);
|
|
930
965
|
}
|
|
931
966
|
});
|
|
932
967
|
}
|
|
968
|
+
function describeValue(value) {
|
|
969
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
970
|
+
if (value === null) return "null";
|
|
971
|
+
return typeof value;
|
|
972
|
+
}
|
|
973
|
+
function formatIsoTime(time) {
|
|
974
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
975
|
+
}
|
|
976
|
+
function callSignalWithContext({ signal, context, index, bar, symbol }) {
|
|
977
|
+
try {
|
|
978
|
+
return signal(context);
|
|
979
|
+
} catch (error) {
|
|
980
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
981
|
+
throw new Error(
|
|
982
|
+
`signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function snapshotOpenPosition(open, markPrice) {
|
|
987
|
+
if (!open) return null;
|
|
988
|
+
const entryPrice = open.entryFill ?? open.entry;
|
|
989
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
990
|
+
const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
|
|
991
|
+
return {
|
|
992
|
+
id: open.id,
|
|
993
|
+
symbol: open.symbol,
|
|
994
|
+
side: open.side,
|
|
995
|
+
size: open.size,
|
|
996
|
+
entry: open.entry,
|
|
997
|
+
entryFill: open.entryFill,
|
|
998
|
+
stop: open.stop,
|
|
999
|
+
takeProfit: open.takeProfit,
|
|
1000
|
+
openTime: open.openTime,
|
|
1001
|
+
markPrice,
|
|
1002
|
+
unrealizedPnl,
|
|
1003
|
+
_initRisk: open._initRisk
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
933
1006
|
function mergeOptions(options) {
|
|
934
1007
|
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
935
1008
|
return {
|
|
@@ -1071,10 +1144,10 @@ function backtest(rawOptions) {
|
|
|
1071
1144
|
strict
|
|
1072
1145
|
} = options;
|
|
1073
1146
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
1074
|
-
throw new Error(
|
|
1147
|
+
throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
|
|
1075
1148
|
}
|
|
1076
1149
|
if (typeof signal !== "function") {
|
|
1077
|
-
throw new Error(
|
|
1150
|
+
throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
|
|
1078
1151
|
}
|
|
1079
1152
|
const closed = [];
|
|
1080
1153
|
let currentEquity = equity;
|
|
@@ -1223,17 +1296,13 @@ function backtest(rawOptions) {
|
|
|
1223
1296
|
});
|
|
1224
1297
|
const size = roundStep2(rawSize, qtyStep);
|
|
1225
1298
|
if (size < minQty) return false;
|
|
1226
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
qty: size,
|
|
1234
|
-
costs
|
|
1235
|
-
}
|
|
1236
|
-
);
|
|
1299
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
|
|
1300
|
+
slippageBps,
|
|
1301
|
+
feeBps,
|
|
1302
|
+
kind: fillKind,
|
|
1303
|
+
qty: size,
|
|
1304
|
+
costs
|
|
1305
|
+
});
|
|
1237
1306
|
open = {
|
|
1238
1307
|
symbol,
|
|
1239
1308
|
...pending.meta,
|
|
@@ -1284,10 +1353,7 @@ function backtest(rawOptions) {
|
|
|
1284
1353
|
dayEquityStart = currentEquity;
|
|
1285
1354
|
}
|
|
1286
1355
|
if (open && open._maxBarsInTrade > 0) {
|
|
1287
|
-
const barsHeld = Math.max(
|
|
1288
|
-
1,
|
|
1289
|
-
Math.round((bar.time - open.openTime) / estimatedBarMs)
|
|
1290
|
-
);
|
|
1356
|
+
const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
|
|
1291
1357
|
if (barsHeld >= open._maxBarsInTrade) {
|
|
1292
1358
|
forceExit("TIME", bar);
|
|
1293
1359
|
}
|
|
@@ -1341,11 +1407,13 @@ function backtest(rawOptions) {
|
|
|
1341
1407
|
const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
|
|
1342
1408
|
if (cutQty >= minQty && cutQty < open.size) {
|
|
1343
1409
|
const exitSide2 = open.side === "long" ? "short" : "long";
|
|
1344
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1410
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
|
|
1411
|
+
slippageBps,
|
|
1412
|
+
feeBps,
|
|
1413
|
+
kind: "market",
|
|
1414
|
+
qty: cutQty,
|
|
1415
|
+
costs
|
|
1416
|
+
});
|
|
1349
1417
|
closeLeg({
|
|
1350
1418
|
openPos: open,
|
|
1351
1419
|
qty: cutQty,
|
|
@@ -1370,11 +1438,13 @@ function backtest(rawOptions) {
|
|
|
1370
1438
|
const baseSize = open.baseSize || open.initSize;
|
|
1371
1439
|
const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
|
|
1372
1440
|
if (addQty >= minQty) {
|
|
1373
|
-
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1441
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
|
|
1442
|
+
slippageBps,
|
|
1443
|
+
feeBps,
|
|
1444
|
+
kind: "limit",
|
|
1445
|
+
qty: addQty,
|
|
1446
|
+
costs
|
|
1447
|
+
});
|
|
1378
1448
|
const newSize = open.size + addQty;
|
|
1379
1449
|
open.entryFeeTotal += addFeeTotal;
|
|
1380
1450
|
open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
|
|
@@ -1452,15 +1522,10 @@ function backtest(rawOptions) {
|
|
|
1452
1522
|
if (!open && pending) {
|
|
1453
1523
|
if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
1454
1524
|
if (entryChase.enabled && entryChase.convertOnExpiry) {
|
|
1455
|
-
const riskAtEdge = Math.abs(
|
|
1456
|
-
pending.meta._initRisk ?? pending.entry - pending.stop
|
|
1457
|
-
);
|
|
1525
|
+
const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
|
|
1458
1526
|
const priceNow = bar.close;
|
|
1459
1527
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1460
|
-
const slippedR = Math.max(
|
|
1461
|
-
0,
|
|
1462
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1463
|
-
) / Math.max(1e-8, riskAtEdge);
|
|
1528
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
|
|
1464
1529
|
if (slippedR > maxSlipROnFill) {
|
|
1465
1530
|
pending = null;
|
|
1466
1531
|
} else if (!openFromPending(bar, index, priceNow, "market")) {
|
|
@@ -1475,21 +1540,16 @@ function backtest(rawOptions) {
|
|
|
1475
1540
|
}
|
|
1476
1541
|
} else if (entryChase.enabled) {
|
|
1477
1542
|
const elapsedBars = index - (pending.startedAtIndex ?? index);
|
|
1478
|
-
const midpoint = pending.meta?._imb?.mid;
|
|
1479
|
-
if (!pending._chasedCE && midpoint !==
|
|
1543
|
+
const midpoint = asNumber(pending.meta?._imb?.mid);
|
|
1544
|
+
if (!pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, entryChase.afterBars)) {
|
|
1480
1545
|
pending.entry = midpoint;
|
|
1481
1546
|
pending._chasedCE = true;
|
|
1482
1547
|
}
|
|
1483
1548
|
if (pending._chasedCE) {
|
|
1484
|
-
const riskRef = Math.abs(
|
|
1485
|
-
pending.meta?._initRisk ?? pending.entry - pending.stop
|
|
1486
|
-
);
|
|
1549
|
+
const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
|
|
1487
1550
|
const priceNow = bar.close;
|
|
1488
1551
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1489
|
-
const slippedR = Math.max(
|
|
1490
|
-
0,
|
|
1491
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1492
|
-
) / Math.max(1e-8, riskRef);
|
|
1552
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
|
|
1493
1553
|
if (slippedR > maxSlipROnFill) {
|
|
1494
1554
|
pending = null;
|
|
1495
1555
|
} else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
|
|
@@ -1517,13 +1577,19 @@ function backtest(rawOptions) {
|
|
|
1517
1577
|
);
|
|
1518
1578
|
}
|
|
1519
1579
|
const signalCandles = strict ? strictHistoryView(history, index) : history;
|
|
1520
|
-
const rawSignal =
|
|
1521
|
-
|
|
1580
|
+
const rawSignal = callSignalWithContext({
|
|
1581
|
+
signal,
|
|
1582
|
+
context: {
|
|
1583
|
+
candles: signalCandles,
|
|
1584
|
+
index,
|
|
1585
|
+
bar,
|
|
1586
|
+
equity: currentEquity,
|
|
1587
|
+
openPosition: open,
|
|
1588
|
+
pendingOrder: pending
|
|
1589
|
+
},
|
|
1522
1590
|
index,
|
|
1523
1591
|
bar,
|
|
1524
|
-
|
|
1525
|
-
openPosition: open,
|
|
1526
|
-
pendingOrder: pending
|
|
1592
|
+
symbol
|
|
1527
1593
|
});
|
|
1528
1594
|
const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
|
|
1529
1595
|
if (nextSignal) {
|
|
@@ -1539,9 +1605,7 @@ function backtest(rawOptions) {
|
|
|
1539
1605
|
expiresAt: index + Math.max(1, expiryBars),
|
|
1540
1606
|
startedAtIndex: index,
|
|
1541
1607
|
meta: nextSignal,
|
|
1542
|
-
plannedRiskAbs: Math.abs(
|
|
1543
|
-
nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
|
|
1544
|
-
)
|
|
1608
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
1545
1609
|
};
|
|
1546
1610
|
if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
|
|
1547
1611
|
if (!openFromPending(bar, index, pending.entry, "limit")) {
|
|
@@ -1561,12 +1625,15 @@ function backtest(rawOptions) {
|
|
|
1561
1625
|
eqSeries
|
|
1562
1626
|
});
|
|
1563
1627
|
const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
1628
|
+
const lastPrice = asNumber(candles[candles.length - 1]?.close);
|
|
1629
|
+
const openPositions = open ? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)] : [];
|
|
1564
1630
|
return {
|
|
1565
1631
|
symbol: options.symbol,
|
|
1566
1632
|
interval: options.interval,
|
|
1567
1633
|
range: options.range,
|
|
1568
1634
|
trades: closed,
|
|
1569
1635
|
positions,
|
|
1636
|
+
openPositions,
|
|
1570
1637
|
metrics,
|
|
1571
1638
|
eqSeries,
|
|
1572
1639
|
replay: {
|
|
@@ -1576,126 +1643,1474 @@ function backtest(rawOptions) {
|
|
|
1576
1643
|
};
|
|
1577
1644
|
}
|
|
1578
1645
|
|
|
1579
|
-
// src/engine/
|
|
1580
|
-
function
|
|
1581
|
-
|
|
1646
|
+
// src/engine/backtestTicks.js
|
|
1647
|
+
function asNumber2(value) {
|
|
1648
|
+
const numeric = Number(value);
|
|
1649
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
1582
1650
|
}
|
|
1583
|
-
function
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1651
|
+
function describeValue2(value) {
|
|
1652
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
1653
|
+
if (value === null) return "null";
|
|
1654
|
+
return typeof value;
|
|
1655
|
+
}
|
|
1656
|
+
function formatIsoTime2(time) {
|
|
1657
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
1658
|
+
}
|
|
1659
|
+
function callSignalWithContext2({ signal, context, index, bar, symbol }) {
|
|
1660
|
+
try {
|
|
1661
|
+
return signal(context);
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
1664
|
+
throw new Error(
|
|
1665
|
+
`signal() threw at index=${index}, time=${formatIsoTime2(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
1666
|
+
);
|
|
1589
1667
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1668
|
+
}
|
|
1669
|
+
function normalizeSide2(value) {
|
|
1670
|
+
if (value === "long" || value === "buy") return "long";
|
|
1671
|
+
if (value === "short" || value === "sell") return "short";
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
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
|
+
function normalizeSignal2(signal, bar, fallbackR) {
|
|
1697
|
+
if (!signal) return null;
|
|
1698
|
+
const side = normalizeSide2(signal.side ?? signal.direction ?? signal.action);
|
|
1699
|
+
if (!side) return null;
|
|
1700
|
+
const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
|
|
1701
|
+
const entry = asNumber2(signal.entry ?? signal.limit ?? signal.price) ?? asNumber2(bar?.close);
|
|
1702
|
+
const stop = asNumber2(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
1703
|
+
if (entry === null || stop === null) return null;
|
|
1704
|
+
const risk = Math.abs(entry - stop);
|
|
1705
|
+
if (!(risk > 0)) return null;
|
|
1706
|
+
let takeProfit = asNumber2(signal.takeProfit ?? signal.target ?? signal.tp);
|
|
1707
|
+
const rrHint = asNumber2(signal._rr ?? signal.rr);
|
|
1708
|
+
const targetR = rrHint ?? fallbackR;
|
|
1709
|
+
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
1710
|
+
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
1593
1711
|
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
equity += state.lastEquity;
|
|
1607
|
-
});
|
|
1608
|
-
return { time, timestamp: time, equity };
|
|
1609
|
-
});
|
|
1712
|
+
if (takeProfit === null) return null;
|
|
1713
|
+
return {
|
|
1714
|
+
...signal,
|
|
1715
|
+
side,
|
|
1716
|
+
entry,
|
|
1717
|
+
stop,
|
|
1718
|
+
takeProfit,
|
|
1719
|
+
qty: asNumber2(signal.qty ?? signal.size),
|
|
1720
|
+
riskPct: asNumber2(signal.riskPct),
|
|
1721
|
+
riskFraction: asNumber2(signal.riskFraction),
|
|
1722
|
+
orderType: hasExplicitEntry ? "limit" : "market"
|
|
1723
|
+
};
|
|
1610
1724
|
}
|
|
1611
|
-
function
|
|
1612
|
-
|
|
1613
|
-
|
|
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;
|
|
1614
1733
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
|
|
1621
|
-
const frames = eqSeries.map((point) => ({
|
|
1622
|
-
t: new Date(point.time).toISOString(),
|
|
1623
|
-
price: 0,
|
|
1624
|
-
equity: point.equity,
|
|
1625
|
-
posSide: null,
|
|
1626
|
-
posSize: 0
|
|
1627
|
-
}));
|
|
1628
|
-
return { frames, events };
|
|
1734
|
+
return () => {
|
|
1735
|
+
hash = Math.imul(hash ^ hash >>> 16, 2246822507);
|
|
1736
|
+
hash = Math.imul(hash ^ hash >>> 13, 3266489909);
|
|
1737
|
+
return (hash ^= hash >>> 16) >>> 0;
|
|
1738
|
+
};
|
|
1629
1739
|
}
|
|
1630
|
-
function
|
|
1631
|
-
|
|
1740
|
+
function mulberry32(seed) {
|
|
1741
|
+
let state = seed >>> 0;
|
|
1742
|
+
return () => {
|
|
1743
|
+
state = state + 1831565813 >>> 0;
|
|
1744
|
+
let value = Math.imul(state ^ state >>> 15, state | 1);
|
|
1745
|
+
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
|
|
1746
|
+
return ((value ^ value >>> 14) >>> 0) / 4294967296;
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
function seededUnitInterval(seedParts) {
|
|
1750
|
+
const seed = seedParts.map((part) => String(part)).join("|");
|
|
1751
|
+
const seedFn = xmur3(seed);
|
|
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;
|
|
1759
|
+
}
|
|
1760
|
+
function backtestTicks({
|
|
1761
|
+
ticks = [],
|
|
1762
|
+
symbol = "UNKNOWN",
|
|
1632
1763
|
equity = 1e4,
|
|
1633
|
-
|
|
1764
|
+
riskPct = 1,
|
|
1765
|
+
signal,
|
|
1766
|
+
interval,
|
|
1767
|
+
range,
|
|
1768
|
+
slippageBps = 1,
|
|
1769
|
+
feeBps = 0,
|
|
1770
|
+
costs = null,
|
|
1771
|
+
finalTP_R = 3,
|
|
1772
|
+
maxDailyLossPct = 0,
|
|
1773
|
+
dailyMaxTrades = 0,
|
|
1774
|
+
qtyStep = 1e-3,
|
|
1775
|
+
minQty = 1e-3,
|
|
1776
|
+
maxLeverage = 2,
|
|
1634
1777
|
collectEqSeries = true,
|
|
1635
|
-
collectReplay =
|
|
1778
|
+
collectReplay = true,
|
|
1779
|
+
queueFillProbability = 1,
|
|
1780
|
+
oco = {}
|
|
1636
1781
|
} = {}) {
|
|
1637
|
-
if (!Array.isArray(
|
|
1638
|
-
throw new Error(
|
|
1782
|
+
if (!Array.isArray(ticks) || ticks.length === 0) {
|
|
1783
|
+
throw new Error(
|
|
1784
|
+
`backtestTicks() requires a non-empty ticks array, got ${describeValue2(ticks)}`
|
|
1785
|
+
);
|
|
1639
1786
|
}
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1787
|
+
if (typeof signal !== "function") {
|
|
1788
|
+
throw new Error(`backtestTicks() requires a signal function, got ${describeValue2(signal)}`);
|
|
1789
|
+
}
|
|
1790
|
+
const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
|
|
1791
|
+
if (!normalizedTicks.length) {
|
|
1792
|
+
throw new Error(
|
|
1793
|
+
`backtestTicks() could not normalize any ticks from ${ticks.length} input rows`
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
const ocoOptions = {
|
|
1797
|
+
mode: "intrabar",
|
|
1798
|
+
tieBreak: "pessimistic",
|
|
1799
|
+
...oco
|
|
1800
|
+
};
|
|
1801
|
+
const trades = [];
|
|
1802
|
+
const eqSeries = collectEqSeries ? [equityPoint2(normalizedTicks[0].time, equity)] : [];
|
|
1803
|
+
const replayFrames = collectReplay ? [] : [];
|
|
1804
|
+
const replayEvents = collectReplay ? [] : [];
|
|
1805
|
+
const history = [];
|
|
1806
|
+
let open = null;
|
|
1807
|
+
let pending = null;
|
|
1808
|
+
let currentEquity = equity;
|
|
1809
|
+
let dayKey = null;
|
|
1810
|
+
let dayStartEquity = equity;
|
|
1811
|
+
let dayPnl = 0;
|
|
1812
|
+
let dayTrades = 0;
|
|
1813
|
+
let tradeIdCounter = 0;
|
|
1814
|
+
function markedEquity(tick) {
|
|
1815
|
+
if (!open) return currentEquity;
|
|
1816
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
1817
|
+
return currentEquity + (tick.close - open.entryFill) * direction * open.size;
|
|
1818
|
+
}
|
|
1819
|
+
function recordFrame(tick) {
|
|
1820
|
+
const equityNow = markedEquity(tick);
|
|
1821
|
+
if (collectEqSeries) {
|
|
1822
|
+
eqSeries.push(equityPoint2(tick.time, equityNow));
|
|
1823
|
+
}
|
|
1824
|
+
if (collectReplay) {
|
|
1825
|
+
replayFrames.push({
|
|
1826
|
+
t: new Date(tick.time).toISOString(),
|
|
1827
|
+
price: tick.close,
|
|
1828
|
+
equity: equityNow,
|
|
1829
|
+
posSide: open?.side ?? null,
|
|
1830
|
+
posSize: open?.size ?? 0
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
function closePosition(tick, reason, rawPrice, fillKind) {
|
|
1835
|
+
if (!open) return;
|
|
1836
|
+
const exitSide = open.side === "long" ? "short" : "long";
|
|
1837
|
+
const { price, feeTotal } = applyFill(rawPrice, exitSide, {
|
|
1838
|
+
slippageBps,
|
|
1839
|
+
feeBps,
|
|
1840
|
+
kind: fillKind,
|
|
1841
|
+
qty: open.size,
|
|
1842
|
+
costs
|
|
1652
1843
|
});
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1844
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
1845
|
+
const grossPnl = (price - open.entryFill) * direction * open.size;
|
|
1846
|
+
const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal;
|
|
1847
|
+
currentEquity += pnl;
|
|
1848
|
+
dayPnl += pnl;
|
|
1849
|
+
const trade = {
|
|
1850
|
+
...open,
|
|
1851
|
+
exit: {
|
|
1852
|
+
price,
|
|
1853
|
+
time: tick.time,
|
|
1854
|
+
reason,
|
|
1855
|
+
pnl
|
|
1856
|
+
}
|
|
1658
1857
|
};
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1858
|
+
trades.push(trade);
|
|
1859
|
+
if (collectReplay) {
|
|
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,
|
|
1867
|
+
reason,
|
|
1868
|
+
pnl
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
open = null;
|
|
1872
|
+
}
|
|
1873
|
+
for (let index = 0; index < normalizedTicks.length; index += 1) {
|
|
1874
|
+
const tick = normalizedTicks[index];
|
|
1875
|
+
history.push(tick);
|
|
1876
|
+
const currentDayKey = dayKeyUTC2(tick.time);
|
|
1877
|
+
if (dayKey === null || currentDayKey !== dayKey) {
|
|
1878
|
+
dayKey = currentDayKey;
|
|
1879
|
+
dayStartEquity = currentEquity;
|
|
1880
|
+
dayPnl = 0;
|
|
1881
|
+
dayTrades = 0;
|
|
1882
|
+
}
|
|
1883
|
+
if (open) {
|
|
1884
|
+
const { hit, px } = ocoExitCheck({
|
|
1885
|
+
side: open.side,
|
|
1886
|
+
stop: open.stop,
|
|
1887
|
+
tp: open.takeProfit,
|
|
1888
|
+
bar: tick,
|
|
1889
|
+
mode: "intrabar",
|
|
1890
|
+
tieBreak: ocoOptions.tieBreak
|
|
1891
|
+
});
|
|
1892
|
+
if (hit) {
|
|
1893
|
+
closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
if (!open && pending && index > pending.createdAtIndex) {
|
|
1897
|
+
if (pending.orderType === "market") {
|
|
1898
|
+
const rawSize = pending.fixedQty ?? calculatePositionSize({
|
|
1899
|
+
equity: currentEquity,
|
|
1900
|
+
entry: tick.close,
|
|
1901
|
+
stop: pending.stop,
|
|
1902
|
+
riskFraction: pending.riskFrac,
|
|
1903
|
+
qtyStep,
|
|
1904
|
+
minQty,
|
|
1905
|
+
maxLeverage
|
|
1906
|
+
});
|
|
1907
|
+
const size = roundStep2(rawSize, qtyStep);
|
|
1908
|
+
if (size >= minQty) {
|
|
1909
|
+
const { price, feeTotal } = applyFill(tick.close, pending.side, {
|
|
1910
|
+
slippageBps,
|
|
1911
|
+
feeBps,
|
|
1912
|
+
kind: "market",
|
|
1913
|
+
qty: size,
|
|
1914
|
+
costs
|
|
1915
|
+
});
|
|
1916
|
+
open = {
|
|
1917
|
+
symbol,
|
|
1918
|
+
id: ++tradeIdCounter,
|
|
1919
|
+
side: pending.side,
|
|
1920
|
+
entry: tick.close,
|
|
1921
|
+
stop: pending.stop,
|
|
1922
|
+
takeProfit: pending.takeProfit,
|
|
1923
|
+
size,
|
|
1924
|
+
openTime: tick.time,
|
|
1925
|
+
entryFill: price,
|
|
1926
|
+
entryFeeTotal: feeTotal,
|
|
1927
|
+
_initRisk: Math.abs(tick.close - pending.stop)
|
|
1928
|
+
};
|
|
1929
|
+
dayTrades += 1;
|
|
1930
|
+
if (collectReplay) {
|
|
1931
|
+
replayEvents.push({
|
|
1932
|
+
t: new Date(tick.time).toISOString(),
|
|
1933
|
+
price,
|
|
1934
|
+
type: "entry",
|
|
1935
|
+
side: open.side,
|
|
1936
|
+
size,
|
|
1937
|
+
tradeId: open.id
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
pending = null;
|
|
1942
|
+
} else {
|
|
1943
|
+
const touched = pending.side === "long" ? tick.low <= pending.entry : tick.high >= pending.entry;
|
|
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
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
const maxLossDollars = Math.abs(maxDailyLossPct) / 100 * dayStartEquity;
|
|
1999
|
+
const dailyLossHit = maxDailyLossPct > 0 && dayPnl <= -maxLossDollars;
|
|
2000
|
+
const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
|
|
2001
|
+
if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
|
|
2002
|
+
const nextSignal = normalizeSignal2(
|
|
2003
|
+
callSignalWithContext2({
|
|
2004
|
+
signal,
|
|
2005
|
+
context: {
|
|
2006
|
+
candles: history,
|
|
2007
|
+
index,
|
|
2008
|
+
bar: tick,
|
|
2009
|
+
equity: markedEquity(tick),
|
|
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
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
recordFrame(tick);
|
|
2034
|
+
}
|
|
2035
|
+
if (open) {
|
|
2036
|
+
const lastTick = normalizedTicks[normalizedTicks.length - 1];
|
|
2037
|
+
closePosition(lastTick, "EOT", lastTick.close, "market");
|
|
2038
|
+
recordFrame(lastTick);
|
|
2039
|
+
}
|
|
2040
|
+
const positions = trades;
|
|
1676
2041
|
const metrics = buildMetrics({
|
|
1677
2042
|
closed: trades,
|
|
1678
2043
|
equityStart: equity,
|
|
1679
|
-
equityFinal:
|
|
1680
|
-
candles:
|
|
1681
|
-
estBarMs:
|
|
2044
|
+
equityFinal: currentEquity,
|
|
2045
|
+
candles: normalizedTicks,
|
|
2046
|
+
estBarMs: normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
|
|
1682
2047
|
eqSeries
|
|
1683
2048
|
});
|
|
1684
2049
|
return {
|
|
1685
|
-
symbol
|
|
1686
|
-
interval
|
|
1687
|
-
range
|
|
2050
|
+
symbol,
|
|
2051
|
+
interval,
|
|
2052
|
+
range,
|
|
1688
2053
|
trades,
|
|
1689
2054
|
positions,
|
|
2055
|
+
openPositions: [],
|
|
1690
2056
|
metrics,
|
|
1691
2057
|
eqSeries,
|
|
1692
|
-
replay
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
2058
|
+
replay: {
|
|
2059
|
+
frames: replayFrames,
|
|
2060
|
+
events: replayEvents
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/engine/barSystemRunner.js
|
|
2066
|
+
function asNumber3(value) {
|
|
2067
|
+
const numeric = Number(value);
|
|
2068
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
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);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
if (this.open && Number.isFinite(this.open._maxHoldMin) && this.open._maxHoldMin > 0) {
|
|
2523
|
+
const heldMinutes = (bar.time - this.open.openTime) / 6e4;
|
|
2524
|
+
if (heldMinutes >= this.open._maxHoldMin) {
|
|
2525
|
+
this.forceExit("TIME", bar);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
if (this.options.flattenAtClose && this.open && isEODBar(bar.time)) {
|
|
2529
|
+
this.forceExit("EOD", bar);
|
|
2530
|
+
}
|
|
2531
|
+
if (this.open) {
|
|
2532
|
+
const risk = this.open._initRisk || 1e-8;
|
|
2533
|
+
const highR = this.open.side === "long" ? (bar.high - this.open.entry) / risk : (this.open.entry - bar.low) / risk;
|
|
2534
|
+
const lowR = this.open.side === "long" ? (bar.low - this.open.entry) / risk : (this.open.entry - bar.high) / risk;
|
|
2535
|
+
const markR = this.open.side === "long" ? (bar.close - this.open.entry) / risk : (this.open.entry - bar.close) / risk;
|
|
2536
|
+
if (this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2537
|
+
this.open._lastATR = this.atrValues[this.index];
|
|
2538
|
+
}
|
|
2539
|
+
this.open._mfeR = Math.max(this.open._mfeR ?? -Infinity, highR);
|
|
2540
|
+
this.open._maeR = Math.min(this.open._maeR ?? Infinity, lowR);
|
|
2541
|
+
if (this.open._breakevenAtR > 0 && highR >= this.open._breakevenAtR && !this.open._beArmed) {
|
|
2542
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, this.open.entry) : Math.min(this.open.stop, this.open.entry);
|
|
2543
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2544
|
+
this.open._beArmed = true;
|
|
2545
|
+
}
|
|
2546
|
+
if (this.open._trailAfterR > 0 && highR >= this.open._trailAfterR) {
|
|
2547
|
+
const candidate = this.open.side === "long" ? bar.close - risk : bar.close + risk;
|
|
2548
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
|
|
2549
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2550
|
+
}
|
|
2551
|
+
if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
|
|
2552
|
+
const targetR = Math.max(0, this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR));
|
|
2553
|
+
const candidate = this.open.side === "long" ? this.open.entry + targetR * risk : this.open.entry - targetR * risk;
|
|
2554
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
|
|
2555
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2556
|
+
}
|
|
2557
|
+
if (this.options.atrTrailMult > 0 && this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2558
|
+
const trailDistance = this.atrValues[this.index] * this.options.atrTrailMult;
|
|
2559
|
+
const candidate = this.open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
|
|
2560
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
|
|
2561
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2562
|
+
}
|
|
2563
|
+
if (this.options.volScale.enabled && this.open.entryATR && this.open.size > this.options.minQty && this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2564
|
+
const ratio = this.atrValues[this.index] / Math.max(1e-12, this.open.entryATR);
|
|
2565
|
+
const shouldCut = ratio >= this.options.volScale.cutIfAtrX && markR < this.options.volScale.noCutAboveR && !this.open._volCutDone;
|
|
2566
|
+
if (shouldCut) {
|
|
2567
|
+
const cutQty = roundStep2(
|
|
2568
|
+
this.open.size * this.options.volScale.cutFrac,
|
|
2569
|
+
this.options.qtyStep
|
|
2570
|
+
);
|
|
2571
|
+
if (cutQty >= this.options.minQty && cutQty < this.open.size) {
|
|
2572
|
+
const exitSide2 = this.open.side === "long" ? "short" : "long";
|
|
2573
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
|
|
2574
|
+
slippageBps: this.options.slippageBps,
|
|
2575
|
+
feeBps: this.options.feeBps,
|
|
2576
|
+
kind: "market",
|
|
2577
|
+
qty: cutQty,
|
|
2578
|
+
costs: this.options.costs
|
|
2579
|
+
});
|
|
2580
|
+
this.closeLeg({
|
|
2581
|
+
openPos: this.open,
|
|
2582
|
+
qty: cutQty,
|
|
2583
|
+
exitPx: filled,
|
|
2584
|
+
exitFeeTotal,
|
|
2585
|
+
time: bar.time,
|
|
2586
|
+
reason: "SCALE"
|
|
2587
|
+
});
|
|
2588
|
+
this.tightenStopToNetBreakeven(this.open, bar.close);
|
|
2589
|
+
this.open._volCutDone = true;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
let addedThisBar = false;
|
|
2594
|
+
if (this.options.pyramiding.enabled && (this.open._adds ?? 0) < this.options.pyramiding.maxAdds) {
|
|
2595
|
+
const addNumber = (this.open._adds || 0) + 1;
|
|
2596
|
+
const triggerR = this.options.pyramiding.addAtR * addNumber;
|
|
2597
|
+
const triggerPrice = this.open.side === "long" ? this.open.entry + triggerR * risk : this.open.entry - triggerR * risk;
|
|
2598
|
+
const breakEvenSatisfied = !this.options.pyramiding.onlyAfterBreakEven || this.open.side === "long" && this.open.stop >= this.open.entry || this.open.side === "short" && this.open.stop <= this.open.entry;
|
|
2599
|
+
const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
2600
|
+
if (breakEvenSatisfied && touched) {
|
|
2601
|
+
const baseSize = this.open.baseSize || this.open.initSize;
|
|
2602
|
+
const requestedQty = roundStep2(
|
|
2603
|
+
baseSize * this.options.pyramiding.addFrac,
|
|
2604
|
+
this.options.qtyStep
|
|
2605
|
+
);
|
|
2606
|
+
const addQty = typeof resolveEntrySize === "function" ? roundStep2(
|
|
2607
|
+
resolveEntrySize({
|
|
2608
|
+
runner: this,
|
|
2609
|
+
desiredSize: requestedQty,
|
|
2610
|
+
entryPrice: triggerPrice,
|
|
2611
|
+
stopPrice: this.open.stop,
|
|
2612
|
+
pending: {
|
|
2613
|
+
side: this.open.side,
|
|
2614
|
+
meta: this.open,
|
|
2615
|
+
riskFrac: this.options.riskPct / 100
|
|
2616
|
+
},
|
|
2617
|
+
fillKind: "limit"
|
|
2618
|
+
}),
|
|
2619
|
+
this.options.qtyStep
|
|
2620
|
+
) : requestedQty;
|
|
2621
|
+
if (addQty >= this.options.minQty) {
|
|
2622
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
2623
|
+
triggerPrice,
|
|
2624
|
+
this.open.side,
|
|
2625
|
+
{
|
|
2626
|
+
slippageBps: this.options.slippageBps,
|
|
2627
|
+
feeBps: this.options.feeBps,
|
|
2628
|
+
kind: "limit",
|
|
2629
|
+
qty: addQty,
|
|
2630
|
+
costs: this.options.costs
|
|
2631
|
+
}
|
|
2632
|
+
);
|
|
2633
|
+
const newSize = this.open.size + addQty;
|
|
2634
|
+
this.open.entryFeeTotal += addFeeTotal;
|
|
2635
|
+
this.open.entryFill = (this.open.entryFill * this.open.size + addFill * addQty) / newSize;
|
|
2636
|
+
this.open.size = newSize;
|
|
2637
|
+
this.open.initSize += addQty;
|
|
2638
|
+
if (!this.open.baseSize) this.open.baseSize = baseSize;
|
|
2639
|
+
this.open._adds = addNumber;
|
|
2640
|
+
addedThisBar = true;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
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
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
2674
|
+
const { hit, px } = ocoExitCheck({
|
|
2675
|
+
side: this.open.side,
|
|
2676
|
+
stop: this.open.stop,
|
|
2677
|
+
tp: this.open.takeProfit,
|
|
2678
|
+
bar,
|
|
2679
|
+
mode: this.options.oco.mode,
|
|
2680
|
+
tieBreak: this.options.oco.tieBreak
|
|
2681
|
+
});
|
|
2682
|
+
if (hit) {
|
|
2683
|
+
const exitKind = hit === "TP" ? "limit" : "stop";
|
|
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;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
const maxLossDollars = this.options.maxDailyLossPct / 100 * this.dayEquityStart;
|
|
2705
|
+
const dailyLossHit = this.dayPnl <= -Math.abs(maxLossDollars);
|
|
2706
|
+
const dailyTradeCapHit = this.options.dailyMaxTrades > 0 && this.dayTrades >= this.options.dailyMaxTrades;
|
|
2707
|
+
if (!this.open && this.pending) {
|
|
2708
|
+
if (!canTrade) {
|
|
2709
|
+
this.pending = null;
|
|
2710
|
+
} else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
2711
|
+
if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
|
|
2712
|
+
const riskAtEdge = Math.abs(
|
|
2713
|
+
this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
|
|
2714
|
+
);
|
|
2715
|
+
const priceNow = bar.close;
|
|
2716
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2717
|
+
const slippedR = Math.max(
|
|
2718
|
+
0,
|
|
2719
|
+
direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
|
|
2720
|
+
) / Math.max(1e-8, riskAtEdge);
|
|
2721
|
+
if (slippedR > this.options.maxSlipROnFill) {
|
|
2722
|
+
this.pending = null;
|
|
2723
|
+
} else if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
|
|
2724
|
+
this.pending = null;
|
|
2725
|
+
}
|
|
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
|
+
}
|
|
2740
|
+
if (this.pending._chasedCE) {
|
|
2741
|
+
const riskRef = Math.abs(
|
|
2742
|
+
this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
|
|
2743
|
+
);
|
|
2744
|
+
const priceNow = bar.close;
|
|
2745
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2746
|
+
const slippedR = Math.max(
|
|
2747
|
+
0,
|
|
2748
|
+
direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
|
|
2749
|
+
) / Math.max(1e-8, riskRef);
|
|
2750
|
+
if (slippedR > this.options.maxSlipROnFill) {
|
|
2751
|
+
this.pending = null;
|
|
2752
|
+
} else if (slippedR > 0 && slippedR <= this.options.entryChase.maxSlipR) {
|
|
2753
|
+
if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
|
|
2754
|
+
this.pending = null;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
if (this.open || this.cooldown > 0) {
|
|
2761
|
+
if (this.cooldown > 0) this.cooldown -= 1;
|
|
2762
|
+
this.recordFrame(bar);
|
|
2763
|
+
this.index += 1;
|
|
2764
|
+
return bar;
|
|
2765
|
+
}
|
|
2766
|
+
if (!canTrade || dailyLossHit || dailyTradeCapHit) {
|
|
2767
|
+
this.pending = null;
|
|
2768
|
+
this.recordFrame(bar);
|
|
2769
|
+
this.index += 1;
|
|
2770
|
+
return bar;
|
|
2771
|
+
}
|
|
2772
|
+
if (!this.pending) {
|
|
2773
|
+
const rawSignal = callSignalWithContext3({
|
|
2774
|
+
signal: this.options.signal,
|
|
2775
|
+
context: this.buildSignalContext(this.index, bar, signalEquity),
|
|
2776
|
+
index: this.index,
|
|
2777
|
+
bar,
|
|
2778
|
+
symbol: this.symbol
|
|
2779
|
+
});
|
|
2780
|
+
const nextSignal = normalizeSignal3(rawSignal, bar, this.options.finalTP_R);
|
|
2781
|
+
if (nextSignal) {
|
|
2782
|
+
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
|
|
2783
|
+
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
2784
|
+
this.pending = {
|
|
2785
|
+
side: nextSignal.side,
|
|
2786
|
+
entry: nextSignal.entry,
|
|
2787
|
+
stop: nextSignal.stop,
|
|
2788
|
+
tp: nextSignal.takeProfit,
|
|
2789
|
+
riskFrac: signalRiskFraction,
|
|
2790
|
+
fixedQty: nextSignal.qty,
|
|
2791
|
+
expiresAt: this.index + Math.max(1, expiryBars),
|
|
2792
|
+
startedAtIndex: this.index,
|
|
2793
|
+
meta: nextSignal,
|
|
2794
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
2795
|
+
};
|
|
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
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
this.recordFrame(bar);
|
|
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
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
};
|
|
2835
|
+
function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct) {
|
|
2836
|
+
const limits = [];
|
|
2837
|
+
if (Number.isFinite(capPct) && capPct > 0) limits.push(totalEquity * capPct);
|
|
2838
|
+
if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
|
|
2839
|
+
if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
|
|
2840
|
+
limits.push(totalEquity * maxAllocationPct);
|
|
2841
|
+
}
|
|
2842
|
+
return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// src/engine/portfolio.js
|
|
2846
|
+
function asWeight(value) {
|
|
2847
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
2848
|
+
}
|
|
2849
|
+
function describeValue4(value) {
|
|
2850
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
2851
|
+
if (value === null) return "null";
|
|
2852
|
+
return typeof value;
|
|
2853
|
+
}
|
|
2854
|
+
function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
|
|
2855
|
+
return {
|
|
2856
|
+
time,
|
|
2857
|
+
timestamp: time,
|
|
2858
|
+
equity,
|
|
2859
|
+
lockedCapital,
|
|
2860
|
+
availableCapital
|
|
2861
|
+
};
|
|
2862
|
+
}
|
|
2863
|
+
function stableSystemOrder(left, right) {
|
|
2864
|
+
return left.index - right.index;
|
|
2865
|
+
}
|
|
2866
|
+
function hashedOrderScore(index, time, seed) {
|
|
2867
|
+
let value = (Number(time) ^ Math.imul(index + 1, 2654435761) ^ (seed | 0)) >>> 0;
|
|
2868
|
+
value = Math.imul(value ^ value >>> 16, 2246822507) >>> 0;
|
|
2869
|
+
value = Math.imul(value ^ value >>> 13, 3266489909) >>> 0;
|
|
2870
|
+
return (value ^ value >>> 16) >>> 0;
|
|
2871
|
+
}
|
|
2872
|
+
function orderActiveSystems(active, nextTime, processingOrder, shuffleSeed) {
|
|
2873
|
+
if (processingOrder !== "shuffle") {
|
|
2874
|
+
active.sort(stableSystemOrder);
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
active.sort((left, right) => {
|
|
2878
|
+
const leftScore = hashedOrderScore(left.index, nextTime, shuffleSeed);
|
|
2879
|
+
const rightScore = hashedOrderScore(right.index, nextTime, shuffleSeed);
|
|
2880
|
+
if (leftScore !== rightScore) return leftScore - rightScore;
|
|
2881
|
+
return stableSystemOrder(left, right);
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
function combineReplay(systemResults, eqSeries, collectReplay) {
|
|
2885
|
+
if (!collectReplay) {
|
|
2886
|
+
return { frames: [], events: [] };
|
|
2887
|
+
}
|
|
2888
|
+
const events = systemResults.flatMap(
|
|
2889
|
+
(entry) => (entry.result.replay?.events || []).map((event) => ({
|
|
2890
|
+
...event,
|
|
2891
|
+
symbol: event.symbol || entry.symbol
|
|
1698
2892
|
}))
|
|
2893
|
+
).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
|
|
2894
|
+
const frames = eqSeries.map((point) => ({
|
|
2895
|
+
t: new Date(point.time).toISOString(),
|
|
2896
|
+
price: 0,
|
|
2897
|
+
equity: point.equity,
|
|
2898
|
+
posSide: null,
|
|
2899
|
+
posSize: 0,
|
|
2900
|
+
lockedCapital: point.lockedCapital,
|
|
2901
|
+
availableCapital: point.availableCapital
|
|
2902
|
+
}));
|
|
2903
|
+
return { frames, events };
|
|
2904
|
+
}
|
|
2905
|
+
function portfolioState(runners, initialEquity) {
|
|
2906
|
+
let markedEquity = initialEquity;
|
|
2907
|
+
let lockedCapital = 0;
|
|
2908
|
+
for (const { runner, initialReferenceEquity } of runners) {
|
|
2909
|
+
markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
|
|
2910
|
+
lockedCapital += runner.getLockedCapital();
|
|
2911
|
+
}
|
|
2912
|
+
return {
|
|
2913
|
+
markedEquity,
|
|
2914
|
+
lockedCapital,
|
|
2915
|
+
availableCapital: markedEquity - lockedCapital
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
function findNextTimeAndActive(runners) {
|
|
2919
|
+
let nextTime = Infinity;
|
|
2920
|
+
const active = [];
|
|
2921
|
+
for (const entry of runners) {
|
|
2922
|
+
const time = entry.runner.peekTime();
|
|
2923
|
+
if (time < nextTime) {
|
|
2924
|
+
nextTime = time;
|
|
2925
|
+
active.length = 0;
|
|
2926
|
+
active.push(entry);
|
|
2927
|
+
continue;
|
|
2928
|
+
}
|
|
2929
|
+
if (time === nextTime) {
|
|
2930
|
+
active.push(entry);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
return { nextTime, active };
|
|
2934
|
+
}
|
|
2935
|
+
function initialPortfolioTime(runners) {
|
|
2936
|
+
let time = Infinity;
|
|
2937
|
+
for (const { runner } of runners) {
|
|
2938
|
+
const next = runner.candles[0]?.time ?? Infinity;
|
|
2939
|
+
if (next < time) time = next;
|
|
2940
|
+
}
|
|
2941
|
+
return Number.isFinite(time) ? time : 0;
|
|
2942
|
+
}
|
|
2943
|
+
function resolveSystemCap(systemEntry, totalEquity) {
|
|
2944
|
+
return defaultSystemCap(
|
|
2945
|
+
Math.max(0, totalEquity),
|
|
2946
|
+
systemEntry.defaultCapPct,
|
|
2947
|
+
systemEntry.system.maxAllocation,
|
|
2948
|
+
systemEntry.system.maxAllocationPct
|
|
2949
|
+
);
|
|
2950
|
+
}
|
|
2951
|
+
function forceExitAll(runners, time) {
|
|
2952
|
+
for (const { runner } of runners) {
|
|
2953
|
+
if (!runner.open) continue;
|
|
2954
|
+
const price = runner.getMarkPrice();
|
|
2955
|
+
if (!Number.isFinite(price)) continue;
|
|
2956
|
+
runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
function backtestPortfolio({
|
|
2960
|
+
systems = [],
|
|
2961
|
+
equity = 1e4,
|
|
2962
|
+
allocation = "equal",
|
|
2963
|
+
collectEqSeries = true,
|
|
2964
|
+
collectReplay = false,
|
|
2965
|
+
maxDailyLossPct = 0,
|
|
2966
|
+
processingOrder = "sequential",
|
|
2967
|
+
shuffleSeed = 0
|
|
2968
|
+
} = {}) {
|
|
2969
|
+
if (!Array.isArray(systems) || systems.length === 0) {
|
|
2970
|
+
throw new Error(
|
|
2971
|
+
`backtestPortfolio() requires a non-empty systems array, got ${describeValue4(systems)}`
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
if (processingOrder !== "sequential" && processingOrder !== "shuffle") {
|
|
2975
|
+
throw new Error(
|
|
2976
|
+
`backtestPortfolio() processingOrder must be "sequential" or "shuffle", got ${processingOrder}`
|
|
2977
|
+
);
|
|
2978
|
+
}
|
|
2979
|
+
const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
|
|
2980
|
+
const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
|
|
2981
|
+
if (!(totalWeight > 0)) {
|
|
2982
|
+
throw new Error(
|
|
2983
|
+
`backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
|
|
2984
|
+
);
|
|
2985
|
+
}
|
|
2986
|
+
const runners = systems.map((system, index) => {
|
|
2987
|
+
const defaultCapPct = weights[index] / totalWeight;
|
|
2988
|
+
const initialReferenceEquity = equity * defaultCapPct;
|
|
2989
|
+
return {
|
|
2990
|
+
index,
|
|
2991
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
2992
|
+
system,
|
|
2993
|
+
defaultCapPct,
|
|
2994
|
+
initialReferenceEquity,
|
|
2995
|
+
runner: new BarSystemRunner({
|
|
2996
|
+
...system,
|
|
2997
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
2998
|
+
equity: initialReferenceEquity,
|
|
2999
|
+
collectEqSeries,
|
|
3000
|
+
collectReplay
|
|
3001
|
+
})
|
|
3002
|
+
};
|
|
3003
|
+
});
|
|
3004
|
+
const eqSeries = collectEqSeries ? [] : [];
|
|
3005
|
+
let state = portfolioState(runners, equity);
|
|
3006
|
+
if (collectEqSeries) {
|
|
3007
|
+
eqSeries.push(
|
|
3008
|
+
buildPortfolioPoint(
|
|
3009
|
+
initialPortfolioTime(runners),
|
|
3010
|
+
state.markedEquity,
|
|
3011
|
+
state.lockedCapital,
|
|
3012
|
+
state.availableCapital
|
|
3013
|
+
)
|
|
3014
|
+
);
|
|
3015
|
+
}
|
|
3016
|
+
let currentDay = null;
|
|
3017
|
+
let dayStartEquity = equity;
|
|
3018
|
+
let portfolioHalted = false;
|
|
3019
|
+
while (true) {
|
|
3020
|
+
const { nextTime, active } = findNextTimeAndActive(runners);
|
|
3021
|
+
if (!Number.isFinite(nextTime)) break;
|
|
3022
|
+
orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
|
|
3023
|
+
const dayKey = dayKeyET(nextTime);
|
|
3024
|
+
if (currentDay === null || dayKey !== currentDay) {
|
|
3025
|
+
currentDay = dayKey;
|
|
3026
|
+
state = portfolioState(runners, equity);
|
|
3027
|
+
dayStartEquity = state.markedEquity;
|
|
3028
|
+
portfolioHalted = false;
|
|
3029
|
+
}
|
|
3030
|
+
for (const systemEntry of active) {
|
|
3031
|
+
state = portfolioState(runners, equity);
|
|
3032
|
+
const totalEquity = state.markedEquity;
|
|
3033
|
+
const availableCapital = Math.max(0, state.availableCapital);
|
|
3034
|
+
const systemLocked = systemEntry.runner.getLockedCapital();
|
|
3035
|
+
const systemCap = resolveSystemCap(systemEntry, totalEquity);
|
|
3036
|
+
const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
|
|
3037
|
+
systemEntry.runner.step({
|
|
3038
|
+
signalEquity: totalEquity,
|
|
3039
|
+
canTrade: !portfolioHalted,
|
|
3040
|
+
resolveEntrySize({ desiredSize, entryPrice }) {
|
|
3041
|
+
const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
|
|
3042
|
+
const byAvailable = availableCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
|
|
3043
|
+
const bySystemCap = systemRemainingCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
|
|
3044
|
+
return Math.min(desiredSize, byAvailable, bySystemCap);
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
3047
|
+
state = portfolioState(runners, equity);
|
|
3048
|
+
if (!portfolioHalted && maxDailyLossPct > 0 && state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)) {
|
|
3049
|
+
portfolioHalted = true;
|
|
3050
|
+
for (const { runner } of runners) runner.cancelPending();
|
|
3051
|
+
forceExitAll(runners, nextTime);
|
|
3052
|
+
state = portfolioState(runners, equity);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
if (collectEqSeries) {
|
|
3056
|
+
eqSeries.push(
|
|
3057
|
+
buildPortfolioPoint(
|
|
3058
|
+
nextTime,
|
|
3059
|
+
state.markedEquity,
|
|
3060
|
+
state.lockedCapital,
|
|
3061
|
+
state.availableCapital
|
|
3062
|
+
)
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
const systemResults = runners.map((entry) => ({
|
|
3067
|
+
symbol: entry.symbol,
|
|
3068
|
+
weight: entry.defaultCapPct,
|
|
3069
|
+
equity: entry.initialReferenceEquity,
|
|
3070
|
+
allocationCapPct: entry.defaultCapPct,
|
|
3071
|
+
allocationCap: resolveSystemCap(entry, equity),
|
|
3072
|
+
result: entry.runner.buildResult()
|
|
3073
|
+
}));
|
|
3074
|
+
const trades = systemResults.flatMap(
|
|
3075
|
+
(run) => run.result.trades.map((trade) => ({
|
|
3076
|
+
...trade,
|
|
3077
|
+
symbol: trade.symbol || run.symbol
|
|
3078
|
+
}))
|
|
3079
|
+
).sort((left, right) => left.exit.time - right.exit.time);
|
|
3080
|
+
const positions = systemResults.flatMap(
|
|
3081
|
+
(run) => run.result.positions.map((trade) => ({
|
|
3082
|
+
...trade,
|
|
3083
|
+
symbol: trade.symbol || run.symbol
|
|
3084
|
+
}))
|
|
3085
|
+
).sort((left, right) => left.exit.time - right.exit.time);
|
|
3086
|
+
const openPositions = systemResults.flatMap(
|
|
3087
|
+
(run) => (run.result.openPositions || []).map((position) => ({
|
|
3088
|
+
...position,
|
|
3089
|
+
symbol: position.symbol || run.symbol
|
|
3090
|
+
}))
|
|
3091
|
+
);
|
|
3092
|
+
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
3093
|
+
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
3094
|
+
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
3095
|
+
const metrics = buildMetrics({
|
|
3096
|
+
closed: trades,
|
|
3097
|
+
equityStart: equity,
|
|
3098
|
+
equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
|
|
3099
|
+
candles: orderedCandles,
|
|
3100
|
+
estBarMs: estimateBarMs(orderedCandles),
|
|
3101
|
+
eqSeries
|
|
3102
|
+
});
|
|
3103
|
+
return {
|
|
3104
|
+
symbol: "PORTFOLIO",
|
|
3105
|
+
interval: void 0,
|
|
3106
|
+
range: void 0,
|
|
3107
|
+
trades,
|
|
3108
|
+
positions,
|
|
3109
|
+
openPositions,
|
|
3110
|
+
metrics,
|
|
3111
|
+
eqSeries,
|
|
3112
|
+
replay,
|
|
3113
|
+
systems: systemResults
|
|
1699
3114
|
};
|
|
1700
3115
|
}
|
|
1701
3116
|
|
|
@@ -1714,6 +3129,61 @@ function stitchEquitySeries(target, source) {
|
|
|
1714
3129
|
const nextPoints = source.filter((point) => point.time > lastTime);
|
|
1715
3130
|
target.push(...nextPoints);
|
|
1716
3131
|
}
|
|
3132
|
+
function canonicalParams(params) {
|
|
3133
|
+
const entries = Object.entries(params || {}).sort(([left], [right]) => left.localeCompare(right));
|
|
3134
|
+
return JSON.stringify(Object.fromEntries(entries));
|
|
3135
|
+
}
|
|
3136
|
+
function describeValue5(value) {
|
|
3137
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
3138
|
+
if (value === null) return "null";
|
|
3139
|
+
return typeof value;
|
|
3140
|
+
}
|
|
3141
|
+
function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
|
|
3142
|
+
const ranges = [];
|
|
3143
|
+
for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
|
|
3144
|
+
const trainStart = mode === "anchored" ? 0 : start;
|
|
3145
|
+
const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
|
|
3146
|
+
const testStart = trainEnd;
|
|
3147
|
+
const testEnd = testStart + testBars;
|
|
3148
|
+
if (testEnd > length) break;
|
|
3149
|
+
ranges.push({ trainStart, trainEnd, testStart, testEnd });
|
|
3150
|
+
}
|
|
3151
|
+
return ranges;
|
|
3152
|
+
}
|
|
3153
|
+
function summarizeBestParams(windows) {
|
|
3154
|
+
const summaryBySignature = /* @__PURE__ */ new Map();
|
|
3155
|
+
let adjacentRepeats = 0;
|
|
3156
|
+
windows.forEach((window, index) => {
|
|
3157
|
+
const signature = window.bestParamsSignature ?? canonicalParams(window.bestParams);
|
|
3158
|
+
const current = summaryBySignature.get(signature) || {
|
|
3159
|
+
params: window.bestParams,
|
|
3160
|
+
wins: 0,
|
|
3161
|
+
profitableWindows: 0,
|
|
3162
|
+
oosTrades: 0
|
|
3163
|
+
};
|
|
3164
|
+
current.wins += 1;
|
|
3165
|
+
current.profitableWindows += window.profitable ? 1 : 0;
|
|
3166
|
+
current.oosTrades += window.oosTrades;
|
|
3167
|
+
summaryBySignature.set(signature, current);
|
|
3168
|
+
if (index > 0 && (windows[index - 1].bestParamsSignature ?? canonicalParams(windows[index - 1].bestParams)) === signature) {
|
|
3169
|
+
adjacentRepeats += 1;
|
|
3170
|
+
}
|
|
3171
|
+
});
|
|
3172
|
+
const byFrequency = [...summaryBySignature.values()].sort((left, right) => {
|
|
3173
|
+
if (right.wins !== left.wins) return right.wins - left.wins;
|
|
3174
|
+
return right.profitableWindows - left.profitableWindows;
|
|
3175
|
+
});
|
|
3176
|
+
const adjacentPairs = Math.max(0, windows.length - 1);
|
|
3177
|
+
return {
|
|
3178
|
+
winners: windows.map((window) => window.bestParams),
|
|
3179
|
+
stability: {
|
|
3180
|
+
adjacentRepeatRate: adjacentPairs ? adjacentRepeats / adjacentPairs : 0,
|
|
3181
|
+
uniqueWinnerCount: summaryBySignature.size,
|
|
3182
|
+
dominant: byFrequency[0] || null,
|
|
3183
|
+
leaderboard: byFrequency
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
1717
3187
|
function walkForwardOptimize({
|
|
1718
3188
|
candles = [],
|
|
1719
3189
|
signalFactory,
|
|
@@ -1721,33 +3191,63 @@ function walkForwardOptimize({
|
|
|
1721
3191
|
trainBars,
|
|
1722
3192
|
testBars,
|
|
1723
3193
|
stepBars = testBars,
|
|
3194
|
+
mode = "rolling",
|
|
1724
3195
|
scoreBy = "profitFactor",
|
|
1725
3196
|
backtestOptions = {}
|
|
1726
3197
|
} = {}) {
|
|
1727
3198
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
1728
|
-
throw new Error(
|
|
3199
|
+
throw new Error(
|
|
3200
|
+
`walkForwardOptimize() requires a non-empty candles array, got ${describeValue5(candles)}`
|
|
3201
|
+
);
|
|
1729
3202
|
}
|
|
1730
3203
|
if (typeof signalFactory !== "function") {
|
|
1731
|
-
throw new Error(
|
|
3204
|
+
throw new Error(
|
|
3205
|
+
`walkForwardOptimize() requires a signalFactory function, got ${describeValue5(signalFactory)}`
|
|
3206
|
+
);
|
|
1732
3207
|
}
|
|
1733
3208
|
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
1734
|
-
throw new Error(
|
|
3209
|
+
throw new Error(
|
|
3210
|
+
`walkForwardOptimize() requires parameterSets, got ${describeValue5(parameterSets)}`
|
|
3211
|
+
);
|
|
1735
3212
|
}
|
|
1736
3213
|
if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
|
|
1737
3214
|
throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
|
|
1738
3215
|
}
|
|
3216
|
+
if (mode !== "rolling" && mode !== "anchored") {
|
|
3217
|
+
throw new Error('walkForwardOptimize() mode must be "rolling" or "anchored"');
|
|
3218
|
+
}
|
|
1739
3219
|
const windows = [];
|
|
1740
3220
|
const allTrades = [];
|
|
1741
3221
|
const allPositions = [];
|
|
1742
3222
|
const eqSeries = [];
|
|
1743
3223
|
let rollingEquity = backtestOptions.equity ?? 1e4;
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
const
|
|
3224
|
+
const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
|
|
3225
|
+
if (!ranges.length) {
|
|
3226
|
+
const required = trainBars + testBars;
|
|
3227
|
+
throw new Error(
|
|
3228
|
+
`walkForwardOptimize() produced zero windows: need at least ${required} candles (trainBars=${trainBars} + testBars=${testBars}) but got ${candles.length}. Try reducing trainBars/testBars or adding more historical data.`
|
|
3229
|
+
);
|
|
3230
|
+
}
|
|
3231
|
+
const trainBacktestOptions = {
|
|
3232
|
+
...backtestOptions,
|
|
3233
|
+
collectEqSeries: false,
|
|
3234
|
+
collectReplay: false
|
|
3235
|
+
};
|
|
3236
|
+
const testBacktestOptions = { ...backtestOptions };
|
|
3237
|
+
for (const range of ranges) {
|
|
3238
|
+
const trainSlice = candles.slice(range.trainStart, range.trainEnd);
|
|
3239
|
+
const testSlice = candles.slice(range.testStart, range.testEnd);
|
|
3240
|
+
if (!trainSlice.length || !testSlice.length) {
|
|
3241
|
+
throw new Error(
|
|
3242
|
+
`walkForwardOptimize() generated an empty window (train=${trainSlice.length}, test=${testSlice.length}, range=${JSON.stringify(
|
|
3243
|
+
range
|
|
3244
|
+
)})`
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
1747
3247
|
let best = null;
|
|
1748
3248
|
for (const params of parameterSets) {
|
|
1749
3249
|
const trainResult = backtest({
|
|
1750
|
-
...
|
|
3250
|
+
...trainBacktestOptions,
|
|
1751
3251
|
candles: trainSlice,
|
|
1752
3252
|
equity: rollingEquity,
|
|
1753
3253
|
signal: signalFactory(params)
|
|
@@ -1758,11 +3258,12 @@ function walkForwardOptimize({
|
|
|
1758
3258
|
}
|
|
1759
3259
|
}
|
|
1760
3260
|
const testResult = backtest({
|
|
1761
|
-
...
|
|
3261
|
+
...testBacktestOptions,
|
|
1762
3262
|
candles: testSlice,
|
|
1763
3263
|
equity: rollingEquity,
|
|
1764
3264
|
signal: signalFactory(best.params)
|
|
1765
3265
|
});
|
|
3266
|
+
const bestParamsSignature = canonicalParams(best.params);
|
|
1766
3267
|
rollingEquity = testResult.metrics.finalEquity;
|
|
1767
3268
|
allTrades.push(...testResult.trades);
|
|
1768
3269
|
allPositions.push(...testResult.positions);
|
|
@@ -1780,9 +3281,25 @@ function walkForwardOptimize({
|
|
|
1780
3281
|
trainScore: best.score,
|
|
1781
3282
|
trainMetrics: best.metrics,
|
|
1782
3283
|
testMetrics: testResult.metrics,
|
|
3284
|
+
oosTrades: testResult.metrics.trades,
|
|
3285
|
+
profitable: testResult.metrics.totalPnL > 0,
|
|
3286
|
+
stabilityScore: 0,
|
|
3287
|
+
bestParamsSignature,
|
|
1783
3288
|
result: testResult
|
|
1784
3289
|
});
|
|
1785
3290
|
}
|
|
3291
|
+
for (let index = 0; index < windows.length; index += 1) {
|
|
3292
|
+
const currentSignature = windows[index].bestParamsSignature;
|
|
3293
|
+
const adjacent = [];
|
|
3294
|
+
if (index > 0) {
|
|
3295
|
+
adjacent.push(windows[index - 1].bestParamsSignature === currentSignature ? 1 : 0);
|
|
3296
|
+
}
|
|
3297
|
+
if (index + 1 < windows.length) {
|
|
3298
|
+
adjacent.push(windows[index + 1].bestParamsSignature === currentSignature ? 1 : 0);
|
|
3299
|
+
}
|
|
3300
|
+
windows[index].stabilityScore = adjacent.length ? adjacent.reduce((total, value) => total + value, 0) / adjacent.length : 1;
|
|
3301
|
+
delete windows[index].bestParamsSignature;
|
|
3302
|
+
}
|
|
1786
3303
|
const metrics = buildMetrics({
|
|
1787
3304
|
closed: allTrades,
|
|
1788
3305
|
equityStart: backtestOptions.equity ?? 1e4,
|
|
@@ -1791,14 +3308,20 @@ function walkForwardOptimize({
|
|
|
1791
3308
|
estBarMs: estimateBarMs(candles),
|
|
1792
3309
|
eqSeries
|
|
1793
3310
|
});
|
|
3311
|
+
const bestParamsSummary = summarizeBestParams(windows);
|
|
1794
3312
|
return {
|
|
1795
3313
|
windows,
|
|
1796
3314
|
trades: allTrades,
|
|
1797
3315
|
positions: allPositions,
|
|
3316
|
+
openPositions: [],
|
|
1798
3317
|
metrics,
|
|
1799
3318
|
eqSeries,
|
|
1800
3319
|
replay: { frames: [], events: [] },
|
|
1801
|
-
bestParams:
|
|
3320
|
+
bestParams: Object.assign(
|
|
3321
|
+
windows.map((window) => window.bestParams),
|
|
3322
|
+
bestParamsSummary
|
|
3323
|
+
),
|
|
3324
|
+
bestParamsSummary: bestParamsSummary.stability
|
|
1802
3325
|
};
|
|
1803
3326
|
}
|
|
1804
3327
|
|
|
@@ -1808,7 +3331,6 @@ var import_path2 = __toESM(require("path"), 1);
|
|
|
1808
3331
|
// src/data/yahoo.js
|
|
1809
3332
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1810
3333
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1811
|
-
var DAY_SEC = 24 * 60 * 60;
|
|
1812
3334
|
var requestQueue = {
|
|
1813
3335
|
lastRequestAt: 0,
|
|
1814
3336
|
minDelayMs: 400
|
|
@@ -1963,13 +3485,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
|
|
|
1963
3485
|
}
|
|
1964
3486
|
}
|
|
1965
3487
|
throw new Error(
|
|
1966
|
-
formatYahooFailureMessage(
|
|
1967
|
-
symbol,
|
|
1968
|
-
params.interval,
|
|
1969
|
-
period,
|
|
1970
|
-
lastError,
|
|
1971
|
-
maxRetries
|
|
1972
|
-
)
|
|
3488
|
+
formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
|
|
1973
3489
|
);
|
|
1974
3490
|
}
|
|
1975
3491
|
async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
|
|
@@ -2009,9 +3525,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
|
|
|
2009
3525
|
period
|
|
2010
3526
|
);
|
|
2011
3527
|
chunks.push(...candles);
|
|
2012
|
-
chunkEndMs = chunkStartMs - 1e3;
|
|
2013
3528
|
remainingMs -= takeMs;
|
|
2014
|
-
|
|
3529
|
+
chunkEndMs = chunkStartMs - 1e3;
|
|
3530
|
+
if (chunkEndMs <= 0 || chunks.length > 2e6) break;
|
|
2015
3531
|
}
|
|
2016
3532
|
return sanitizeBars(chunks);
|
|
2017
3533
|
}
|
|
@@ -2087,11 +3603,7 @@ async function getHistoricalCandles(options = {}) {
|
|
|
2087
3603
|
}
|
|
2088
3604
|
return candles;
|
|
2089
3605
|
}
|
|
2090
|
-
async function backtestHistorical({
|
|
2091
|
-
backtestOptions = {},
|
|
2092
|
-
data,
|
|
2093
|
-
...legacy
|
|
2094
|
-
} = {}) {
|
|
3606
|
+
async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
|
|
2095
3607
|
const candles = await getHistoricalCandles(data || legacy);
|
|
2096
3608
|
return backtest({
|
|
2097
3609
|
candles,
|
|
@@ -2119,7 +3631,8 @@ function candidateRoots() {
|
|
|
2119
3631
|
return [...new Set(roots)];
|
|
2120
3632
|
}
|
|
2121
3633
|
function readTemplate(relativePath) {
|
|
2122
|
-
|
|
3634
|
+
const roots = candidateRoots();
|
|
3635
|
+
for (const root of roots) {
|
|
2123
3636
|
const absolutePath = import_path3.default.join(root, relativePath);
|
|
2124
3637
|
if (!import_fs2.default.existsSync(absolutePath)) continue;
|
|
2125
3638
|
if (!templateCache.has(absolutePath)) {
|
|
@@ -2127,7 +3640,9 @@ function readTemplate(relativePath) {
|
|
|
2127
3640
|
}
|
|
2128
3641
|
return templateCache.get(absolutePath);
|
|
2129
3642
|
}
|
|
2130
|
-
throw new Error(
|
|
3643
|
+
throw new Error(
|
|
3644
|
+
`Could not locate template asset: ${relativePath} (searched ${roots.length} roots starting from ${roots[0]})`
|
|
3645
|
+
);
|
|
2131
3646
|
}
|
|
2132
3647
|
function fmt(value, digits = 2) {
|
|
2133
3648
|
if (value === void 0 || value === null || Number.isNaN(value)) return "\u2014";
|
|
@@ -2208,9 +3723,7 @@ function renderPositionRows(positions) {
|
|
|
2208
3723
|
<td>${escapeHtml(fmt(exit.price, 4))}</td>
|
|
2209
3724
|
<td>${escapeHtml(exit.reason ?? "\u2014")}</td>
|
|
2210
3725
|
<td>${escapeHtml(fmt(exit.pnl, 2))}</td>
|
|
2211
|
-
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(
|
|
2212
|
-
fmt(trade.maeR ?? 0, 2)
|
|
2213
|
-
)}</td>
|
|
3726
|
+
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(fmt(trade.maeR ?? 0, 2))}</td>
|
|
2214
3727
|
</tr>
|
|
2215
3728
|
`;
|
|
2216
3729
|
}).join("");
|
|
@@ -2315,10 +3828,7 @@ function renderHtmlReport({
|
|
|
2315
3828
|
["R p50 / p90", `${fmt(metrics.rDist?.p50 ?? 0, 2)} / ${fmt(metrics.rDist?.p90 ?? 0, 2)}`],
|
|
2316
3829
|
[
|
|
2317
3830
|
"Hold p50 / p90",
|
|
2318
|
-
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(
|
|
2319
|
-
metrics.holdDistMin?.p90 ?? 0,
|
|
2320
|
-
1
|
|
2321
|
-
)} min`
|
|
3831
|
+
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(metrics.holdDistMin?.p90 ?? 0, 1)} min`
|
|
2322
3832
|
]
|
|
2323
3833
|
]);
|
|
2324
3834
|
return renderTemplate(template, {
|
|
@@ -2359,10 +3869,7 @@ function exportHtmlReport({
|
|
|
2359
3869
|
const safeSymbol = String(symbol).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
2360
3870
|
const safeInterval = String(interval).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
2361
3871
|
const safeRange = String(range).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
2362
|
-
const outputPath = import_path3.default.join(
|
|
2363
|
-
outDir,
|
|
2364
|
-
`report-${safeSymbol}-${safeInterval}-${safeRange}.html`
|
|
2365
|
-
);
|
|
3872
|
+
const outputPath = import_path3.default.join(outDir, `report-${safeSymbol}-${safeInterval}-${safeRange}.html`);
|
|
2366
3873
|
const html = renderHtmlReport({
|
|
2367
3874
|
symbol,
|
|
2368
3875
|
interval,
|
|
@@ -2522,6 +4029,7 @@ function exportBacktestArtifacts({
|
|
|
2522
4029
|
backtest,
|
|
2523
4030
|
backtestHistorical,
|
|
2524
4031
|
backtestPortfolio,
|
|
4032
|
+
backtestTicks,
|
|
2525
4033
|
bpsOf,
|
|
2526
4034
|
buildMetrics,
|
|
2527
4035
|
cachedCandlesPath,
|