tradelab 0.4.0 → 0.5.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 +38 -17
- package/bin/tradelab.js +68 -23
- package/dist/cjs/data.cjs +78 -53
- package/dist/cjs/index.cjs +1518 -211
- package/docs/README.md +6 -1
- package/docs/api-reference.md +7 -2
- package/docs/backtest-engine.md +40 -10
- package/docs/data-reporting-cli.md +7 -3
- package/docs/examples.md +281 -0
- package/package.json +1 -1
- package/src/engine/backtestTicks.js +429 -0
- package/src/engine/barSystemRunner.js +963 -0
- package/src/engine/portfolio.js +191 -68
- package/src/engine/walkForward.js +106 -10
- package/src/index.js +1 -0
- package/src/metrics/buildMetrics.js +89 -63
- package/types/index.d.ts +77 -1
package/dist/cjs/index.cjs
CHANGED
|
@@ -33,6 +33,7 @@ __export(index_exports, {
|
|
|
33
33
|
backtest: () => backtest,
|
|
34
34
|
backtestHistorical: () => backtestHistorical,
|
|
35
35
|
backtestPortfolio: () => backtestPortfolio,
|
|
36
|
+
backtestTicks: () => backtestTicks,
|
|
36
37
|
bpsOf: () => bpsOf,
|
|
37
38
|
buildMetrics: () => buildMetrics,
|
|
38
39
|
cachedCandlesPath: () => cachedCandlesPath,
|
|
@@ -316,56 +317,87 @@ function buildMetrics({
|
|
|
316
317
|
estBarMs,
|
|
317
318
|
eqSeries
|
|
318
319
|
}) {
|
|
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;
|
|
342
320
|
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
321
|
+
const completedTrades = [];
|
|
322
|
+
const tradeRs = [];
|
|
323
|
+
const tradePnls = [];
|
|
324
|
+
const tradeReturns = [];
|
|
325
|
+
const holdDurationsMinutes = [];
|
|
326
|
+
const labels = [];
|
|
327
|
+
const longRs = [];
|
|
328
|
+
const shortRs = [];
|
|
329
|
+
let totalR = 0;
|
|
330
|
+
let realizedPnL = 0;
|
|
331
|
+
let winningTradeCount = 0;
|
|
332
|
+
let grossProfitPositions = 0;
|
|
333
|
+
let grossLossPositions = 0;
|
|
334
|
+
let grossProfitLegs = 0;
|
|
335
|
+
let grossLossLegs = 0;
|
|
336
|
+
let winningLegCount = 0;
|
|
337
|
+
let openBars = 0;
|
|
338
|
+
let longTradesCount = 0;
|
|
339
|
+
let longTradeWins = 0;
|
|
340
|
+
let longPnLSum = 0;
|
|
341
|
+
let shortTradesCount = 0;
|
|
342
|
+
let shortTradeWins = 0;
|
|
343
|
+
let shortPnLSum = 0;
|
|
348
344
|
let peakEquity = equityStart;
|
|
349
345
|
let currentEquity = equityStart;
|
|
350
346
|
let maxDrawdown = 0;
|
|
351
|
-
for (const
|
|
352
|
-
|
|
347
|
+
for (const trade of legs) {
|
|
348
|
+
const pnl = trade.exit.pnl;
|
|
349
|
+
realizedPnL += pnl;
|
|
350
|
+
if (pnl > 0) {
|
|
351
|
+
grossProfitLegs += pnl;
|
|
352
|
+
winningLegCount += 1;
|
|
353
|
+
} else if (pnl < 0) {
|
|
354
|
+
grossLossLegs += Math.abs(pnl);
|
|
355
|
+
}
|
|
356
|
+
currentEquity += pnl;
|
|
353
357
|
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
354
358
|
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
355
359
|
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
360
|
+
if (trade.exit.reason === "SCALE") continue;
|
|
361
|
+
completedTrades.push(trade);
|
|
362
|
+
tradePnls.push(pnl);
|
|
363
|
+
tradeReturns.push(pnl / Math.max(1e-12, equityStart));
|
|
364
|
+
const tradeR = tradeRMultiple(trade);
|
|
365
|
+
tradeRs.push(tradeR);
|
|
366
|
+
totalR += tradeR;
|
|
367
|
+
labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
|
|
368
|
+
const holdMinutes = (trade.exit.time - trade.openTime) / (1e3 * 60);
|
|
369
|
+
holdDurationsMinutes.push(holdMinutes);
|
|
370
|
+
openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
371
|
+
if (pnl > 0) {
|
|
372
|
+
winningTradeCount += 1;
|
|
373
|
+
grossProfitPositions += pnl;
|
|
374
|
+
} else if (pnl < 0) {
|
|
375
|
+
grossLossPositions += Math.abs(pnl);
|
|
376
|
+
}
|
|
377
|
+
if (trade.side === "long") {
|
|
378
|
+
longTradesCount += 1;
|
|
379
|
+
longPnLSum += pnl;
|
|
380
|
+
longRs.push(tradeR);
|
|
381
|
+
if (pnl > 0) longTradeWins += 1;
|
|
382
|
+
} else if (trade.side === "short") {
|
|
383
|
+
shortTradesCount += 1;
|
|
384
|
+
shortPnLSum += pnl;
|
|
385
|
+
shortRs.push(tradeR);
|
|
386
|
+
if (pnl > 0) shortTradeWins += 1;
|
|
387
|
+
}
|
|
356
388
|
}
|
|
357
|
-
const
|
|
389
|
+
const avgR = mean(tradeRs);
|
|
390
|
+
const { maxWin, maxLoss } = streaks(labels);
|
|
391
|
+
const expectancy = mean(tradePnls);
|
|
392
|
+
const tradeReturnStd = stddev(tradeReturns);
|
|
393
|
+
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
394
|
+
const sortinoPerTrade = sortino(tradeReturns);
|
|
395
|
+
const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
|
|
396
|
+
const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
|
|
358
397
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
359
398
|
const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
|
|
360
399
|
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
400
|
const exposurePct = openBars / totalBars;
|
|
366
|
-
const holdDurationsMinutes = completedTrades.map(
|
|
367
|
-
(trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
|
|
368
|
-
);
|
|
369
401
|
const avgHoldMin = mean(holdDurationsMinutes);
|
|
370
402
|
const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
|
|
371
403
|
const dailyReturnsSeries = dailyReturns(equitySeries);
|
|
@@ -373,12 +405,6 @@ function buildMetrics({
|
|
|
373
405
|
const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
|
|
374
406
|
const sortinoDaily = sortino(dailyReturnsSeries);
|
|
375
407
|
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
408
|
const rDistribution = {
|
|
383
409
|
p10: percentile(tradeRs, 0.1),
|
|
384
410
|
p25: percentile(tradeRs, 0.25),
|
|
@@ -395,21 +421,21 @@ function buildMetrics({
|
|
|
395
421
|
};
|
|
396
422
|
const sideBreakdown = {
|
|
397
423
|
long: {
|
|
398
|
-
trades:
|
|
399
|
-
winRate:
|
|
400
|
-
avgPnL:
|
|
424
|
+
trades: longTradesCount,
|
|
425
|
+
winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
|
|
426
|
+
avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
|
|
401
427
|
avgR: mean(longRs)
|
|
402
428
|
},
|
|
403
429
|
short: {
|
|
404
|
-
trades:
|
|
405
|
-
winRate:
|
|
406
|
-
avgPnL:
|
|
430
|
+
trades: shortTradesCount,
|
|
431
|
+
winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
|
|
432
|
+
avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
|
|
407
433
|
avgR: mean(shortRs)
|
|
408
434
|
}
|
|
409
435
|
};
|
|
410
436
|
return {
|
|
411
437
|
trades: completedTrades.length,
|
|
412
|
-
winRate: completedTrades.length ?
|
|
438
|
+
winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
413
439
|
profitFactor: profitFactorPositions,
|
|
414
440
|
expectancy,
|
|
415
441
|
totalR,
|
|
@@ -431,8 +457,8 @@ function buildMetrics({
|
|
|
431
457
|
startEquity: equityStart,
|
|
432
458
|
profitFactor_pos: profitFactorPositions,
|
|
433
459
|
profitFactor_leg: profitFactorLegs,
|
|
434
|
-
winRate_pos: completedTrades.length ?
|
|
435
|
-
winRate_leg: legs.length ?
|
|
460
|
+
winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
461
|
+
winRate_leg: legs.length ? winningLegCount / legs.length : 0,
|
|
436
462
|
sharpeDaily,
|
|
437
463
|
sortinoDaily,
|
|
438
464
|
sideBreakdown,
|
|
@@ -1576,193 +1602,1455 @@ function backtest(rawOptions) {
|
|
|
1576
1602
|
};
|
|
1577
1603
|
}
|
|
1578
1604
|
|
|
1579
|
-
// src/engine/
|
|
1580
|
-
function
|
|
1581
|
-
|
|
1605
|
+
// src/engine/backtestTicks.js
|
|
1606
|
+
function asNumber2(value) {
|
|
1607
|
+
const numeric = Number(value);
|
|
1608
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
1582
1609
|
}
|
|
1583
|
-
function
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
timeline.add(point.time);
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
const times = [...timeline].sort((left, right) => left - right);
|
|
1591
|
-
if (!times.length) {
|
|
1592
|
-
return [{ time: 0, timestamp: 0, equity: totalEquity }];
|
|
1593
|
-
}
|
|
1594
|
-
const states = systemRuns.map((run) => ({
|
|
1595
|
-
points: run.result.eqSeries || [],
|
|
1596
|
-
index: 0,
|
|
1597
|
-
lastEquity: run.allocationEquity
|
|
1598
|
-
}));
|
|
1599
|
-
return times.map((time) => {
|
|
1600
|
-
let equity = 0;
|
|
1601
|
-
states.forEach((state) => {
|
|
1602
|
-
while (state.index < state.points.length && state.points[state.index].time <= time) {
|
|
1603
|
-
state.lastEquity = state.points[state.index].equity;
|
|
1604
|
-
state.index += 1;
|
|
1605
|
-
}
|
|
1606
|
-
equity += state.lastEquity;
|
|
1607
|
-
});
|
|
1608
|
-
return { time, timestamp: time, equity };
|
|
1609
|
-
});
|
|
1610
|
+
function normalizeSide2(value) {
|
|
1611
|
+
if (value === "long" || value === "buy") return "long";
|
|
1612
|
+
if (value === "short" || value === "sell") return "short";
|
|
1613
|
+
return null;
|
|
1610
1614
|
}
|
|
1611
|
-
function
|
|
1612
|
-
|
|
1613
|
-
|
|
1615
|
+
function normalizeTick(tick) {
|
|
1616
|
+
const time = Number(tick?.time);
|
|
1617
|
+
const bid = asNumber2(tick?.bid);
|
|
1618
|
+
const ask = asNumber2(tick?.ask);
|
|
1619
|
+
const last = asNumber2(tick?.price ?? tick?.last ?? tick?.close);
|
|
1620
|
+
const mid = bid !== null && ask !== null ? (bid + ask) / 2 : last ?? bid ?? ask;
|
|
1621
|
+
if (!Number.isFinite(time) || !Number.isFinite(mid)) return null;
|
|
1622
|
+
const prices = [asNumber2(tick?.low), asNumber2(tick?.high), bid, ask, last, mid].filter(
|
|
1623
|
+
Number.isFinite
|
|
1624
|
+
);
|
|
1625
|
+
const low = prices.length ? Math.min(...prices) : mid;
|
|
1626
|
+
const high = prices.length ? Math.max(...prices) : mid;
|
|
1627
|
+
return {
|
|
1628
|
+
...tick,
|
|
1629
|
+
time,
|
|
1630
|
+
open: mid,
|
|
1631
|
+
high,
|
|
1632
|
+
low,
|
|
1633
|
+
close: mid,
|
|
1634
|
+
volume: asNumber2(tick?.size ?? tick?.volume) ?? void 0
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
function normalizeSignal2(signal, bar, fallbackR) {
|
|
1638
|
+
if (!signal) return null;
|
|
1639
|
+
const side = normalizeSide2(signal.side ?? signal.direction ?? signal.action);
|
|
1640
|
+
if (!side) return null;
|
|
1641
|
+
const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
|
|
1642
|
+
const entry = asNumber2(signal.entry ?? signal.limit ?? signal.price) ?? asNumber2(bar?.close);
|
|
1643
|
+
const stop = asNumber2(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
1644
|
+
if (entry === null || stop === null) return null;
|
|
1645
|
+
const risk = Math.abs(entry - stop);
|
|
1646
|
+
if (!(risk > 0)) return null;
|
|
1647
|
+
let takeProfit = asNumber2(signal.takeProfit ?? signal.target ?? signal.tp);
|
|
1648
|
+
const rrHint = asNumber2(signal._rr ?? signal.rr);
|
|
1649
|
+
const targetR = rrHint ?? fallbackR;
|
|
1650
|
+
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
1651
|
+
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
1614
1652
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
}));
|
|
1628
|
-
return { frames, events };
|
|
1653
|
+
if (takeProfit === null) return null;
|
|
1654
|
+
return {
|
|
1655
|
+
...signal,
|
|
1656
|
+
side,
|
|
1657
|
+
entry,
|
|
1658
|
+
stop,
|
|
1659
|
+
takeProfit,
|
|
1660
|
+
qty: asNumber2(signal.qty ?? signal.size),
|
|
1661
|
+
riskPct: asNumber2(signal.riskPct),
|
|
1662
|
+
riskFraction: asNumber2(signal.riskFraction),
|
|
1663
|
+
orderType: hasExplicitEntry ? "limit" : "market"
|
|
1664
|
+
};
|
|
1629
1665
|
}
|
|
1630
|
-
function
|
|
1631
|
-
|
|
1666
|
+
function equityPoint2(time, equity) {
|
|
1667
|
+
return { time, timestamp: time, equity };
|
|
1668
|
+
}
|
|
1669
|
+
function deterministicFill(probability, seedParts) {
|
|
1670
|
+
if (probability >= 1) return true;
|
|
1671
|
+
if (probability <= 0) return false;
|
|
1672
|
+
let hash = 2166136261;
|
|
1673
|
+
const seed = seedParts.join("|");
|
|
1674
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
1675
|
+
hash ^= seed.charCodeAt(index);
|
|
1676
|
+
hash = Math.imul(hash, 16777619);
|
|
1677
|
+
}
|
|
1678
|
+
const normalized = (hash >>> 0) / 4294967295;
|
|
1679
|
+
return normalized <= probability;
|
|
1680
|
+
}
|
|
1681
|
+
function backtestTicks({
|
|
1682
|
+
ticks = [],
|
|
1683
|
+
symbol = "UNKNOWN",
|
|
1632
1684
|
equity = 1e4,
|
|
1633
|
-
|
|
1685
|
+
riskPct = 1,
|
|
1686
|
+
signal,
|
|
1687
|
+
interval,
|
|
1688
|
+
range,
|
|
1689
|
+
slippageBps = 1,
|
|
1690
|
+
feeBps = 0,
|
|
1691
|
+
costs = null,
|
|
1692
|
+
finalTP_R = 3,
|
|
1693
|
+
maxDailyLossPct = 0,
|
|
1694
|
+
dailyMaxTrades = 0,
|
|
1695
|
+
qtyStep = 1e-3,
|
|
1696
|
+
minQty = 1e-3,
|
|
1697
|
+
maxLeverage = 2,
|
|
1634
1698
|
collectEqSeries = true,
|
|
1635
|
-
collectReplay =
|
|
1699
|
+
collectReplay = true,
|
|
1700
|
+
queueFillProbability = 1,
|
|
1701
|
+
oco = {}
|
|
1636
1702
|
} = {}) {
|
|
1637
|
-
if (!Array.isArray(
|
|
1638
|
-
throw new Error("
|
|
1703
|
+
if (!Array.isArray(ticks) || ticks.length === 0) {
|
|
1704
|
+
throw new Error("backtestTicks() requires a non-empty ticks array");
|
|
1639
1705
|
}
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1706
|
+
if (typeof signal !== "function") {
|
|
1707
|
+
throw new Error("backtestTicks() requires a signal function");
|
|
1708
|
+
}
|
|
1709
|
+
const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
|
|
1710
|
+
if (!normalizedTicks.length) {
|
|
1711
|
+
throw new Error("backtestTicks() could not normalize any ticks");
|
|
1712
|
+
}
|
|
1713
|
+
const ocoOptions = {
|
|
1714
|
+
mode: "intrabar",
|
|
1715
|
+
tieBreak: "pessimistic",
|
|
1716
|
+
...oco
|
|
1717
|
+
};
|
|
1718
|
+
const trades = [];
|
|
1719
|
+
const eqSeries = collectEqSeries ? [equityPoint2(normalizedTicks[0].time, equity)] : [];
|
|
1720
|
+
const replayFrames = collectReplay ? [] : [];
|
|
1721
|
+
const replayEvents = collectReplay ? [] : [];
|
|
1722
|
+
const history = [];
|
|
1723
|
+
let open = null;
|
|
1724
|
+
let pending = null;
|
|
1725
|
+
let currentEquity = equity;
|
|
1726
|
+
let dayKey = null;
|
|
1727
|
+
let dayStartEquity = equity;
|
|
1728
|
+
let dayPnl = 0;
|
|
1729
|
+
let dayTrades = 0;
|
|
1730
|
+
let tradeIdCounter = 0;
|
|
1731
|
+
function markedEquity(tick) {
|
|
1732
|
+
if (!open) return currentEquity;
|
|
1733
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
1734
|
+
return currentEquity + (tick.close - open.entryFill) * direction * open.size;
|
|
1735
|
+
}
|
|
1736
|
+
function recordFrame(tick) {
|
|
1737
|
+
const equityNow = markedEquity(tick);
|
|
1738
|
+
if (collectEqSeries) {
|
|
1739
|
+
eqSeries.push(equityPoint2(tick.time, equityNow));
|
|
1740
|
+
}
|
|
1741
|
+
if (collectReplay) {
|
|
1742
|
+
replayFrames.push({
|
|
1743
|
+
t: new Date(tick.time).toISOString(),
|
|
1744
|
+
price: tick.close,
|
|
1745
|
+
equity: equityNow,
|
|
1746
|
+
posSide: open?.side ?? null,
|
|
1747
|
+
posSize: open?.size ?? 0
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1644
1750
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
const
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1751
|
+
function closePosition(tick, reason, rawPrice, fillKind) {
|
|
1752
|
+
if (!open) return;
|
|
1753
|
+
const exitSide = open.side === "long" ? "short" : "long";
|
|
1754
|
+
const { price, feeTotal } = applyFill(rawPrice, exitSide, {
|
|
1755
|
+
slippageBps,
|
|
1756
|
+
feeBps,
|
|
1757
|
+
kind: fillKind,
|
|
1758
|
+
qty: open.size,
|
|
1759
|
+
costs
|
|
1652
1760
|
});
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1761
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
1762
|
+
const grossPnl = (price - open.entryFill) * direction * open.size;
|
|
1763
|
+
const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal;
|
|
1764
|
+
currentEquity += pnl;
|
|
1765
|
+
dayPnl += pnl;
|
|
1766
|
+
const trade = {
|
|
1767
|
+
...open,
|
|
1768
|
+
exit: {
|
|
1769
|
+
price,
|
|
1770
|
+
time: tick.time,
|
|
1771
|
+
reason,
|
|
1772
|
+
pnl
|
|
1773
|
+
}
|
|
1658
1774
|
};
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1775
|
+
trades.push(trade);
|
|
1776
|
+
if (collectReplay) {
|
|
1777
|
+
replayEvents.push({
|
|
1778
|
+
t: new Date(tick.time).toISOString(),
|
|
1779
|
+
price,
|
|
1780
|
+
type: reason === "TP" ? "tp" : reason === "SL" ? "sl" : "exit",
|
|
1781
|
+
side: open.side,
|
|
1782
|
+
size: open.size,
|
|
1783
|
+
tradeId: open.id,
|
|
1784
|
+
reason,
|
|
1785
|
+
pnl
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
open = null;
|
|
1789
|
+
}
|
|
1790
|
+
for (let index = 0; index < normalizedTicks.length; index += 1) {
|
|
1791
|
+
const tick = normalizedTicks[index];
|
|
1792
|
+
history.push(tick);
|
|
1793
|
+
const currentDayKey = dayKeyUTC2(tick.time);
|
|
1794
|
+
if (dayKey === null || currentDayKey !== dayKey) {
|
|
1795
|
+
dayKey = currentDayKey;
|
|
1796
|
+
dayStartEquity = currentEquity;
|
|
1797
|
+
dayPnl = 0;
|
|
1798
|
+
dayTrades = 0;
|
|
1799
|
+
}
|
|
1800
|
+
if (open) {
|
|
1801
|
+
const { hit, px } = ocoExitCheck({
|
|
1802
|
+
side: open.side,
|
|
1803
|
+
stop: open.stop,
|
|
1804
|
+
tp: open.takeProfit,
|
|
1805
|
+
bar: tick,
|
|
1806
|
+
mode: "intrabar",
|
|
1807
|
+
tieBreak: ocoOptions.tieBreak
|
|
1808
|
+
});
|
|
1809
|
+
if (hit) {
|
|
1810
|
+
closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
if (!open && pending && index > pending.createdAtIndex) {
|
|
1814
|
+
if (pending.orderType === "market") {
|
|
1815
|
+
const rawSize = pending.fixedQty ?? calculatePositionSize({
|
|
1816
|
+
equity: currentEquity,
|
|
1817
|
+
entry: tick.close,
|
|
1818
|
+
stop: pending.stop,
|
|
1819
|
+
riskFraction: pending.riskFrac,
|
|
1820
|
+
qtyStep,
|
|
1821
|
+
minQty,
|
|
1822
|
+
maxLeverage
|
|
1823
|
+
});
|
|
1824
|
+
const size = roundStep2(rawSize, qtyStep);
|
|
1825
|
+
if (size >= minQty) {
|
|
1826
|
+
const { price, feeTotal } = applyFill(tick.close, pending.side, {
|
|
1827
|
+
slippageBps,
|
|
1828
|
+
feeBps,
|
|
1829
|
+
kind: "market",
|
|
1830
|
+
qty: size,
|
|
1831
|
+
costs
|
|
1832
|
+
});
|
|
1833
|
+
open = {
|
|
1834
|
+
symbol,
|
|
1835
|
+
id: ++tradeIdCounter,
|
|
1836
|
+
side: pending.side,
|
|
1837
|
+
entry: tick.close,
|
|
1838
|
+
stop: pending.stop,
|
|
1839
|
+
takeProfit: pending.takeProfit,
|
|
1840
|
+
size,
|
|
1841
|
+
openTime: tick.time,
|
|
1842
|
+
entryFill: price,
|
|
1843
|
+
entryFeeTotal: feeTotal,
|
|
1844
|
+
_initRisk: Math.abs(tick.close - pending.stop)
|
|
1845
|
+
};
|
|
1846
|
+
dayTrades += 1;
|
|
1847
|
+
if (collectReplay) {
|
|
1848
|
+
replayEvents.push({
|
|
1849
|
+
t: new Date(tick.time).toISOString(),
|
|
1850
|
+
price,
|
|
1851
|
+
type: "entry",
|
|
1852
|
+
side: open.side,
|
|
1853
|
+
size,
|
|
1854
|
+
tradeId: open.id
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
pending = null;
|
|
1859
|
+
} else {
|
|
1860
|
+
const touched = pending.side === "long" ? tick.low <= pending.entry : tick.high >= pending.entry;
|
|
1861
|
+
if (touched && deterministicFill(queueFillProbability, [
|
|
1862
|
+
symbol,
|
|
1863
|
+
tick.time,
|
|
1864
|
+
pending.entry,
|
|
1865
|
+
pending.stop,
|
|
1866
|
+
pending.side
|
|
1867
|
+
])) {
|
|
1868
|
+
const rawSize = pending.fixedQty ?? calculatePositionSize({
|
|
1869
|
+
equity: currentEquity,
|
|
1870
|
+
entry: pending.entry,
|
|
1871
|
+
stop: pending.stop,
|
|
1872
|
+
riskFraction: pending.riskFrac,
|
|
1873
|
+
qtyStep,
|
|
1874
|
+
minQty,
|
|
1875
|
+
maxLeverage
|
|
1876
|
+
});
|
|
1877
|
+
const size = roundStep2(rawSize, qtyStep);
|
|
1878
|
+
if (size >= minQty) {
|
|
1879
|
+
const { price, feeTotal } = applyFill(pending.entry, pending.side, {
|
|
1880
|
+
slippageBps,
|
|
1881
|
+
feeBps,
|
|
1882
|
+
kind: "limit",
|
|
1883
|
+
qty: size,
|
|
1884
|
+
costs
|
|
1885
|
+
});
|
|
1886
|
+
open = {
|
|
1887
|
+
symbol,
|
|
1888
|
+
id: ++tradeIdCounter,
|
|
1889
|
+
side: pending.side,
|
|
1890
|
+
entry: pending.entry,
|
|
1891
|
+
stop: pending.stop,
|
|
1892
|
+
takeProfit: pending.takeProfit,
|
|
1893
|
+
size,
|
|
1894
|
+
openTime: tick.time,
|
|
1895
|
+
entryFill: price,
|
|
1896
|
+
entryFeeTotal: feeTotal,
|
|
1897
|
+
_initRisk: Math.abs(pending.entry - pending.stop)
|
|
1898
|
+
};
|
|
1899
|
+
dayTrades += 1;
|
|
1900
|
+
if (collectReplay) {
|
|
1901
|
+
replayEvents.push({
|
|
1902
|
+
t: new Date(tick.time).toISOString(),
|
|
1903
|
+
price,
|
|
1904
|
+
type: "entry",
|
|
1905
|
+
side: open.side,
|
|
1906
|
+
size,
|
|
1907
|
+
tradeId: open.id
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
pending = null;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
const maxLossDollars = Math.abs(maxDailyLossPct) / 100 * dayStartEquity;
|
|
1916
|
+
const dailyLossHit = maxDailyLossPct > 0 && dayPnl <= -maxLossDollars;
|
|
1917
|
+
const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
|
|
1918
|
+
if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
|
|
1919
|
+
const nextSignal = normalizeSignal2(
|
|
1920
|
+
signal({
|
|
1921
|
+
candles: history,
|
|
1922
|
+
index,
|
|
1923
|
+
bar: tick,
|
|
1924
|
+
equity: markedEquity(tick),
|
|
1925
|
+
openPosition: open,
|
|
1926
|
+
pendingOrder: pending
|
|
1927
|
+
}),
|
|
1928
|
+
tick,
|
|
1929
|
+
finalTP_R
|
|
1930
|
+
);
|
|
1931
|
+
if (nextSignal) {
|
|
1932
|
+
pending = {
|
|
1933
|
+
side: nextSignal.side,
|
|
1934
|
+
entry: nextSignal.entry,
|
|
1935
|
+
stop: nextSignal.stop,
|
|
1936
|
+
takeProfit: nextSignal.takeProfit,
|
|
1937
|
+
fixedQty: nextSignal.qty,
|
|
1938
|
+
riskFrac: Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : riskPct / 100,
|
|
1939
|
+
orderType: nextSignal.orderType,
|
|
1940
|
+
createdAtIndex: index
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
recordFrame(tick);
|
|
1945
|
+
}
|
|
1946
|
+
if (open) {
|
|
1947
|
+
const lastTick = normalizedTicks[normalizedTicks.length - 1];
|
|
1948
|
+
closePosition(lastTick, "EOT", lastTick.close, "market");
|
|
1949
|
+
recordFrame(lastTick);
|
|
1950
|
+
}
|
|
1951
|
+
const positions = trades;
|
|
1676
1952
|
const metrics = buildMetrics({
|
|
1677
1953
|
closed: trades,
|
|
1678
1954
|
equityStart: equity,
|
|
1679
|
-
equityFinal:
|
|
1680
|
-
candles:
|
|
1681
|
-
estBarMs:
|
|
1955
|
+
equityFinal: currentEquity,
|
|
1956
|
+
candles: normalizedTicks,
|
|
1957
|
+
estBarMs: normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
|
|
1682
1958
|
eqSeries
|
|
1683
1959
|
});
|
|
1684
1960
|
return {
|
|
1685
|
-
symbol
|
|
1686
|
-
interval
|
|
1687
|
-
range
|
|
1961
|
+
symbol,
|
|
1962
|
+
interval,
|
|
1963
|
+
range,
|
|
1688
1964
|
trades,
|
|
1689
1965
|
positions,
|
|
1690
1966
|
metrics,
|
|
1691
1967
|
eqSeries,
|
|
1692
|
-
replay
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
equity: run.allocationEquity,
|
|
1697
|
-
result: run.result
|
|
1698
|
-
}))
|
|
1968
|
+
replay: {
|
|
1969
|
+
frames: replayFrames,
|
|
1970
|
+
events: replayEvents
|
|
1971
|
+
}
|
|
1699
1972
|
};
|
|
1700
1973
|
}
|
|
1701
1974
|
|
|
1702
|
-
// src/engine/
|
|
1703
|
-
function
|
|
1704
|
-
const
|
|
1705
|
-
return Number.isFinite(
|
|
1975
|
+
// src/engine/barSystemRunner.js
|
|
1976
|
+
function asNumber3(value) {
|
|
1977
|
+
const numeric = Number(value);
|
|
1978
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
1706
1979
|
}
|
|
1707
|
-
function
|
|
1708
|
-
|
|
1709
|
-
if (!target.length) {
|
|
1710
|
-
target.push(...source);
|
|
1711
|
-
return;
|
|
1712
|
-
}
|
|
1713
|
-
const lastTime = target[target.length - 1].time;
|
|
1714
|
-
const nextPoints = source.filter((point) => point.time > lastTime);
|
|
1715
|
-
target.push(...nextPoints);
|
|
1980
|
+
function equityPoint3(time, equity, extra = {}) {
|
|
1981
|
+
return { time, timestamp: time, equity, ...extra };
|
|
1716
1982
|
}
|
|
1717
|
-
function
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
}
|
|
1730
|
-
if (typeof signalFactory !== "function") {
|
|
1731
|
-
throw new Error("walkForwardOptimize() requires a signalFactory function");
|
|
1732
|
-
}
|
|
1733
|
-
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
1734
|
-
throw new Error("walkForwardOptimize() requires parameterSets");
|
|
1735
|
-
}
|
|
1736
|
-
if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
|
|
1737
|
-
throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
|
|
1738
|
-
}
|
|
1739
|
-
const windows = [];
|
|
1740
|
-
const allTrades = [];
|
|
1741
|
-
const allPositions = [];
|
|
1742
|
-
const eqSeries = [];
|
|
1743
|
-
let rollingEquity = backtestOptions.equity ?? 1e4;
|
|
1744
|
-
for (let start = 0; start + trainBars + testBars <= candles.length; start += stepBars) {
|
|
1745
|
-
const trainSlice = candles.slice(start, start + trainBars);
|
|
1746
|
-
const testSlice = candles.slice(start + trainBars, start + trainBars + testBars);
|
|
1747
|
-
let best = null;
|
|
1748
|
-
for (const params of parameterSets) {
|
|
1749
|
-
const trainResult = backtest({
|
|
1750
|
-
...backtestOptions,
|
|
1751
|
-
candles: trainSlice,
|
|
1752
|
-
equity: rollingEquity,
|
|
1753
|
-
signal: signalFactory(params)
|
|
1754
|
-
});
|
|
1755
|
-
const score = scoreOf(trainResult.metrics, scoreBy);
|
|
1756
|
-
if (!best || score > best.score) {
|
|
1757
|
-
best = { params, score, metrics: trainResult.metrics };
|
|
1983
|
+
function isArrayIndexKey2(property) {
|
|
1984
|
+
if (typeof property !== "string") return false;
|
|
1985
|
+
const numeric = Number(property);
|
|
1986
|
+
return Number.isInteger(numeric) && numeric >= 0;
|
|
1987
|
+
}
|
|
1988
|
+
function strictHistoryView2(candles, currentIndex) {
|
|
1989
|
+
return new Proxy(candles, {
|
|
1990
|
+
get(target, property, receiver) {
|
|
1991
|
+
if (isArrayIndexKey2(property) && Number(property) >= target.length) {
|
|
1992
|
+
throw new Error(
|
|
1993
|
+
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
1994
|
+
);
|
|
1758
1995
|
}
|
|
1996
|
+
return Reflect.get(target, property, receiver);
|
|
1759
1997
|
}
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
function normalizeSide3(value) {
|
|
2001
|
+
if (value === "long" || value === "buy") return "long";
|
|
2002
|
+
if (value === "short" || value === "sell") return "short";
|
|
2003
|
+
return null;
|
|
2004
|
+
}
|
|
2005
|
+
function normalizeSignal3(signal, bar, fallbackR) {
|
|
2006
|
+
if (!signal) return null;
|
|
2007
|
+
const side = normalizeSide3(signal.side ?? signal.direction ?? signal.action);
|
|
2008
|
+
if (!side) return null;
|
|
2009
|
+
const entry = asNumber3(signal.entry ?? signal.limit ?? signal.price) ?? asNumber3(bar?.close);
|
|
2010
|
+
const stop = asNumber3(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
2011
|
+
if (entry === null || stop === null) return null;
|
|
2012
|
+
const risk = Math.abs(entry - stop);
|
|
2013
|
+
if (!(risk > 0)) return null;
|
|
2014
|
+
let takeProfit = asNumber3(signal.takeProfit ?? signal.target ?? signal.tp);
|
|
2015
|
+
const rrHint = asNumber3(signal._rr ?? signal.rr);
|
|
2016
|
+
const targetR = rrHint ?? fallbackR;
|
|
2017
|
+
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
2018
|
+
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
2019
|
+
}
|
|
2020
|
+
if (takeProfit === null) return null;
|
|
2021
|
+
return {
|
|
2022
|
+
...signal,
|
|
2023
|
+
side,
|
|
2024
|
+
entry,
|
|
2025
|
+
stop,
|
|
2026
|
+
takeProfit,
|
|
2027
|
+
qty: asNumber3(signal.qty ?? signal.size),
|
|
2028
|
+
riskPct: asNumber3(signal.riskPct),
|
|
2029
|
+
riskFraction: asNumber3(signal.riskFraction),
|
|
2030
|
+
_rr: rrHint ?? signal._rr,
|
|
2031
|
+
_initRisk: asNumber3(signal._initRisk) ?? signal._initRisk
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
function mergeOptions2(options) {
|
|
2035
|
+
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
2036
|
+
return {
|
|
2037
|
+
candles: normalizeCandles(options.candles ?? []),
|
|
2038
|
+
symbol: options.symbol ?? "UNKNOWN",
|
|
2039
|
+
equity: options.equity ?? 1e4,
|
|
2040
|
+
riskPct: normalizedRiskPct ?? 1,
|
|
2041
|
+
signal: options.signal,
|
|
2042
|
+
interval: options.interval,
|
|
2043
|
+
range: options.range,
|
|
2044
|
+
warmupBars: options.warmupBars ?? 200,
|
|
2045
|
+
slippageBps: options.slippageBps ?? 1,
|
|
2046
|
+
feeBps: options.feeBps ?? 0,
|
|
2047
|
+
costs: options.costs ?? null,
|
|
2048
|
+
scaleOutAtR: options.scaleOutAtR ?? 1,
|
|
2049
|
+
scaleOutFrac: options.scaleOutFrac ?? 0.5,
|
|
2050
|
+
finalTP_R: options.finalTP_R ?? 3,
|
|
2051
|
+
maxDailyLossPct: options.maxDailyLossPct ?? 2,
|
|
2052
|
+
atrTrailMult: options.atrTrailMult ?? 0,
|
|
2053
|
+
atrTrailPeriod: options.atrTrailPeriod ?? 14,
|
|
2054
|
+
oco: {
|
|
2055
|
+
mode: "intrabar",
|
|
2056
|
+
tieBreak: "pessimistic",
|
|
2057
|
+
clampStops: true,
|
|
2058
|
+
clampEpsBps: 0.25,
|
|
2059
|
+
...options.oco || {}
|
|
2060
|
+
},
|
|
2061
|
+
triggerMode: options.triggerMode,
|
|
2062
|
+
flattenAtClose: options.flattenAtClose ?? true,
|
|
2063
|
+
dailyMaxTrades: options.dailyMaxTrades ?? 0,
|
|
2064
|
+
postLossCooldownBars: options.postLossCooldownBars ?? 0,
|
|
2065
|
+
mfeTrail: {
|
|
2066
|
+
enabled: false,
|
|
2067
|
+
armR: 1,
|
|
2068
|
+
givebackR: 0.5,
|
|
2069
|
+
...options.mfeTrail || {}
|
|
2070
|
+
},
|
|
2071
|
+
pyramiding: {
|
|
2072
|
+
enabled: false,
|
|
2073
|
+
addAtR: 1,
|
|
2074
|
+
addFrac: 0.25,
|
|
2075
|
+
maxAdds: 1,
|
|
2076
|
+
onlyAfterBreakEven: true,
|
|
2077
|
+
...options.pyramiding || {}
|
|
2078
|
+
},
|
|
2079
|
+
volScale: {
|
|
2080
|
+
enabled: false,
|
|
2081
|
+
atrPeriod: options.atrTrailPeriod ?? 14,
|
|
2082
|
+
cutIfAtrX: 1.3,
|
|
2083
|
+
cutFrac: 0.33,
|
|
2084
|
+
noCutAboveR: 1.5,
|
|
2085
|
+
...options.volScale || {}
|
|
2086
|
+
},
|
|
2087
|
+
qtyStep: options.qtyStep ?? 1e-3,
|
|
2088
|
+
minQty: options.minQty ?? 1e-3,
|
|
2089
|
+
maxLeverage: options.maxLeverage ?? 2,
|
|
2090
|
+
entryChase: {
|
|
2091
|
+
enabled: true,
|
|
2092
|
+
afterBars: 2,
|
|
2093
|
+
maxSlipR: 0.2,
|
|
2094
|
+
convertOnExpiry: false,
|
|
2095
|
+
...options.entryChase || {}
|
|
2096
|
+
},
|
|
2097
|
+
reanchorStopOnFill: options.reanchorStopOnFill ?? true,
|
|
2098
|
+
maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
|
|
2099
|
+
collectEqSeries: options.collectEqSeries ?? true,
|
|
2100
|
+
collectReplay: options.collectReplay ?? true,
|
|
2101
|
+
strict: options.strict ?? false
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
function capitalForSize(entryPrice, size, maxLeverage) {
|
|
2105
|
+
const leverage = Math.max(1, Number(maxLeverage) || 1);
|
|
2106
|
+
return Math.abs(entryPrice) * Math.max(0, size) / leverage;
|
|
2107
|
+
}
|
|
2108
|
+
var BarSystemRunner = class {
|
|
2109
|
+
constructor(rawOptions = {}) {
|
|
2110
|
+
this.options = mergeOptions2(rawOptions);
|
|
2111
|
+
const { candles, signal } = this.options;
|
|
2112
|
+
if (!Array.isArray(candles) || candles.length === 0) {
|
|
2113
|
+
throw new Error("backtestPortfolio() requires each system to include non-empty candles");
|
|
2114
|
+
}
|
|
2115
|
+
if (typeof signal !== "function") {
|
|
2116
|
+
throw new Error("backtestPortfolio() requires each system to include a signal function");
|
|
2117
|
+
}
|
|
2118
|
+
this.symbol = this.options.symbol;
|
|
2119
|
+
this.candles = candles;
|
|
2120
|
+
this.closed = [];
|
|
2121
|
+
this.currentEquity = this.options.equity;
|
|
2122
|
+
this.open = null;
|
|
2123
|
+
this.cooldown = 0;
|
|
2124
|
+
this.pending = null;
|
|
2125
|
+
this.currentDay = null;
|
|
2126
|
+
this.dayPnl = 0;
|
|
2127
|
+
this.dayTrades = 0;
|
|
2128
|
+
this.dayEquityStart = this.options.equity;
|
|
2129
|
+
this.tradeIdCounter = 0;
|
|
2130
|
+
this.estimatedBarMs = estimateBarMs(candles);
|
|
2131
|
+
const atrSourcePeriod = this.options.volScale.enabled ? this.options.volScale.atrPeriod : this.options.atrTrailPeriod;
|
|
2132
|
+
const needAtr = this.options.atrTrailMult > 0 || this.options.volScale.enabled;
|
|
2133
|
+
this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
|
|
2134
|
+
this.wantEqSeries = Boolean(this.options.collectEqSeries);
|
|
2135
|
+
this.wantReplay = Boolean(this.options.collectReplay);
|
|
2136
|
+
this.eqSeries = this.wantEqSeries ? [equityPoint3(candles[0].time, this.currentEquity)] : [];
|
|
2137
|
+
this.replayFrames = this.wantReplay ? [] : [];
|
|
2138
|
+
this.replayEvents = this.wantReplay ? [] : [];
|
|
2139
|
+
this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
|
|
2140
|
+
this.history = candles.slice(0, this.startIndex);
|
|
2141
|
+
this.index = this.startIndex;
|
|
2142
|
+
this.lastBar = this.history.length ? this.history[this.history.length - 1] : null;
|
|
2143
|
+
}
|
|
2144
|
+
hasNext() {
|
|
2145
|
+
return this.index < this.candles.length;
|
|
2146
|
+
}
|
|
2147
|
+
peekTime() {
|
|
2148
|
+
return this.hasNext() ? this.candles[this.index].time : Infinity;
|
|
2149
|
+
}
|
|
2150
|
+
getLockedCapital() {
|
|
2151
|
+
if (!this.open) return 0;
|
|
2152
|
+
return capitalForSize(this.open.entryFill ?? this.open.entry, this.open.size, this.options.maxLeverage);
|
|
2153
|
+
}
|
|
2154
|
+
getMarkPrice() {
|
|
2155
|
+
return this.lastBar?.close ?? null;
|
|
2156
|
+
}
|
|
2157
|
+
getMarkedEquity() {
|
|
2158
|
+
if (!this.open || !this.lastBar) return this.currentEquity;
|
|
2159
|
+
const direction = this.open.side === "long" ? 1 : -1;
|
|
2160
|
+
const markPnl = (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
|
|
2161
|
+
return this.currentEquity + markPnl;
|
|
2162
|
+
}
|
|
2163
|
+
recordFrame(bar, extraFrame = {}) {
|
|
2164
|
+
if (this.wantEqSeries) {
|
|
2165
|
+
this.eqSeries.push(equityPoint3(bar.time, this.currentEquity));
|
|
2166
|
+
}
|
|
2167
|
+
if (this.wantReplay) {
|
|
2168
|
+
this.replayFrames.push({
|
|
2169
|
+
t: new Date(bar.time).toISOString(),
|
|
2170
|
+
price: bar.close,
|
|
2171
|
+
equity: this.currentEquity,
|
|
2172
|
+
posSide: this.open ? this.open.side : null,
|
|
2173
|
+
posSize: this.open ? this.open.size : 0,
|
|
2174
|
+
...extraFrame
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
|
|
2179
|
+
const direction = openPos.side === "long" ? 1 : -1;
|
|
2180
|
+
const entryFill = openPos.entryFill;
|
|
2181
|
+
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
2182
|
+
const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
2183
|
+
const pnl = grossPnl - entryFeePortion - exitFeeTotal;
|
|
2184
|
+
this.currentEquity += pnl;
|
|
2185
|
+
this.dayPnl += pnl;
|
|
2186
|
+
if (this.wantEqSeries) {
|
|
2187
|
+
this.eqSeries.push(equityPoint3(time, this.currentEquity));
|
|
2188
|
+
}
|
|
2189
|
+
const remaining = openPos.size - qty;
|
|
2190
|
+
const eventType = reason === "SCALE" ? "scale-out" : reason === "TP" ? "tp" : reason === "SL" ? "sl" : reason === "EOD" ? "eod" : remaining <= 0 ? "exit" : "scale-out";
|
|
2191
|
+
if (this.wantReplay) {
|
|
2192
|
+
this.replayEvents.push({
|
|
2193
|
+
t: new Date(time).toISOString(),
|
|
2194
|
+
price: exitPx,
|
|
2195
|
+
type: eventType,
|
|
2196
|
+
side: openPos.side,
|
|
2197
|
+
size: qty,
|
|
2198
|
+
tradeId: openPos.id,
|
|
2199
|
+
reason,
|
|
2200
|
+
pnl,
|
|
2201
|
+
symbol: this.symbol
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
const record = {
|
|
2205
|
+
...openPos,
|
|
2206
|
+
size: qty,
|
|
2207
|
+
exit: {
|
|
2208
|
+
price: exitPx,
|
|
2209
|
+
time,
|
|
2210
|
+
reason,
|
|
2211
|
+
pnl,
|
|
2212
|
+
exitATR: openPos._lastATR ?? void 0
|
|
2213
|
+
},
|
|
2214
|
+
mfeR: openPos._mfeR ?? 0,
|
|
2215
|
+
maeR: openPos._maeR ?? 0,
|
|
2216
|
+
adds: openPos._adds ?? 0
|
|
2217
|
+
};
|
|
2218
|
+
this.closed.push(record);
|
|
2219
|
+
openPos.size -= qty;
|
|
2220
|
+
openPos._realized = (openPos._realized || 0) + pnl;
|
|
2221
|
+
return record;
|
|
2222
|
+
}
|
|
2223
|
+
tightenStopToNetBreakeven(openPos, lastClose) {
|
|
2224
|
+
if (!openPos || openPos.size <= 0) return;
|
|
2225
|
+
const realized = openPos._realized || 0;
|
|
2226
|
+
if (realized <= 0) return;
|
|
2227
|
+
const direction = openPos.side === "long" ? 1 : -1;
|
|
2228
|
+
const breakevenDelta = Math.abs(realized / openPos.size);
|
|
2229
|
+
const breakevenPrice = direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
|
|
2230
|
+
const tightened = direction === 1 ? Math.max(openPos.stop, breakevenPrice) : Math.min(openPos.stop, breakevenPrice);
|
|
2231
|
+
openPos.stop = this.options.oco.clampStops ? clampStop(lastClose, tightened, openPos.side, this.options.oco) : tightened;
|
|
2232
|
+
}
|
|
2233
|
+
forceExit(reason, bar, overridePrice = null) {
|
|
2234
|
+
if (!this.open || !bar) return;
|
|
2235
|
+
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
2236
|
+
const exitBasePrice = overridePrice ?? bar.close;
|
|
2237
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(exitBasePrice, exitSide, {
|
|
2238
|
+
slippageBps: this.options.slippageBps,
|
|
2239
|
+
feeBps: this.options.feeBps,
|
|
2240
|
+
kind: "market",
|
|
2241
|
+
qty: this.open.size,
|
|
2242
|
+
costs: this.options.costs
|
|
2243
|
+
});
|
|
2244
|
+
this.closeLeg({
|
|
2245
|
+
openPos: this.open,
|
|
2246
|
+
qty: this.open.size,
|
|
2247
|
+
exitPx: filled,
|
|
2248
|
+
exitFeeTotal,
|
|
2249
|
+
time: bar.time,
|
|
2250
|
+
reason
|
|
2251
|
+
});
|
|
2252
|
+
this.cooldown = this.open?._cooldownBars || 0;
|
|
2253
|
+
this.open = null;
|
|
2254
|
+
}
|
|
2255
|
+
cancelPending() {
|
|
2256
|
+
this.pending = null;
|
|
2257
|
+
}
|
|
2258
|
+
openFromPending(bar, signalEquity, entryPrice, fillKind = "limit", resolveEntrySize) {
|
|
2259
|
+
if (!this.pending) return false;
|
|
2260
|
+
const plannedRisk = Math.max(
|
|
2261
|
+
1e-8,
|
|
2262
|
+
this.pending.plannedRiskAbs ?? Math.abs(this.pending.entry - this.pending.stop)
|
|
2263
|
+
);
|
|
2264
|
+
const slipR = Math.abs(entryPrice - this.pending.entry) / plannedRisk;
|
|
2265
|
+
if (slipR > this.options.maxSlipROnFill) return false;
|
|
2266
|
+
let stopPrice = this.pending.stop;
|
|
2267
|
+
if (this.options.reanchorStopOnFill) {
|
|
2268
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2269
|
+
stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
|
|
2270
|
+
}
|
|
2271
|
+
let takeProfit = this.pending.tp;
|
|
2272
|
+
const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
|
|
2273
|
+
const rrHint = this.pending.meta?._rr;
|
|
2274
|
+
if (this.options.reanchorStopOnFill && Number.isFinite(rrHint)) {
|
|
2275
|
+
const plannedTarget = this.pending.side === "long" ? this.pending.entry + rrHint * plannedRisk : this.pending.entry - rrHint * plannedRisk;
|
|
2276
|
+
const closeEnough = Math.abs((this.pending.tp ?? plannedTarget) - plannedTarget) <= Math.max(1e-8, plannedRisk * 1e-6);
|
|
2277
|
+
if (closeEnough) {
|
|
2278
|
+
takeProfit = this.pending.side === "long" ? entryPrice + rrHint * immediateRisk : entryPrice - rrHint * immediateRisk;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
const desiredSize = this.pending.fixedQty ?? calculatePositionSize({
|
|
2282
|
+
equity: signalEquity,
|
|
2283
|
+
entry: entryPrice,
|
|
2284
|
+
stop: stopPrice,
|
|
2285
|
+
riskFraction: this.pending.riskFrac,
|
|
2286
|
+
qtyStep: this.options.qtyStep,
|
|
2287
|
+
minQty: this.options.minQty,
|
|
2288
|
+
maxLeverage: this.options.maxLeverage
|
|
2289
|
+
});
|
|
2290
|
+
const approvedSize = typeof resolveEntrySize === "function" ? resolveEntrySize({
|
|
2291
|
+
runner: this,
|
|
2292
|
+
desiredSize,
|
|
2293
|
+
entryPrice,
|
|
2294
|
+
stopPrice,
|
|
2295
|
+
pending: this.pending,
|
|
2296
|
+
fillKind
|
|
2297
|
+
}) : desiredSize;
|
|
2298
|
+
const size = roundStep2(approvedSize, this.options.qtyStep);
|
|
2299
|
+
if (size < this.options.minQty) return false;
|
|
2300
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
2301
|
+
entryPrice,
|
|
2302
|
+
this.pending.side,
|
|
2303
|
+
{
|
|
2304
|
+
slippageBps: this.options.slippageBps,
|
|
2305
|
+
feeBps: this.options.feeBps,
|
|
2306
|
+
kind: fillKind,
|
|
2307
|
+
qty: size,
|
|
2308
|
+
costs: this.options.costs
|
|
2309
|
+
}
|
|
2310
|
+
);
|
|
2311
|
+
this.open = {
|
|
2312
|
+
symbol: this.symbol,
|
|
2313
|
+
...this.pending.meta,
|
|
2314
|
+
id: ++this.tradeIdCounter,
|
|
2315
|
+
side: this.pending.side,
|
|
2316
|
+
entry: entryPrice,
|
|
2317
|
+
stop: stopPrice,
|
|
2318
|
+
takeProfit,
|
|
2319
|
+
size,
|
|
2320
|
+
openTime: bar.time,
|
|
2321
|
+
entryFill,
|
|
2322
|
+
entryFeeTotal,
|
|
2323
|
+
initSize: size,
|
|
2324
|
+
baseSize: size,
|
|
2325
|
+
_mfeR: 0,
|
|
2326
|
+
_maeR: 0,
|
|
2327
|
+
_adds: 0,
|
|
2328
|
+
_initRisk: Math.abs(entryPrice - stopPrice) || 1e-8
|
|
2329
|
+
};
|
|
2330
|
+
if (this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2331
|
+
this.open.entryATR = this.atrValues[this.index];
|
|
2332
|
+
this.open._lastATR = this.atrValues[this.index];
|
|
2333
|
+
}
|
|
2334
|
+
this.dayTrades += 1;
|
|
2335
|
+
this.pending = null;
|
|
2336
|
+
if (this.wantReplay) {
|
|
2337
|
+
this.replayEvents.push({
|
|
2338
|
+
t: new Date(bar.time).toISOString(),
|
|
2339
|
+
price: entryFill,
|
|
2340
|
+
type: "entry",
|
|
2341
|
+
side: this.open.side,
|
|
2342
|
+
size,
|
|
2343
|
+
tradeId: this.open.id,
|
|
2344
|
+
symbol: this.symbol
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
return true;
|
|
2348
|
+
}
|
|
2349
|
+
buildSignalContext(index, bar, signalEquity) {
|
|
2350
|
+
if (this.options.strict && this.history.length !== index + 1) {
|
|
2351
|
+
throw new Error(
|
|
2352
|
+
`strict mode: signal() received ${this.history.length} candles at index ${index}`
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
return {
|
|
2356
|
+
candles: this.options.strict ? strictHistoryView2(this.history, index) : this.history,
|
|
2357
|
+
index,
|
|
2358
|
+
bar,
|
|
2359
|
+
equity: signalEquity,
|
|
2360
|
+
openPosition: this.open,
|
|
2361
|
+
pendingOrder: this.pending
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
step({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
|
|
2365
|
+
if (!this.hasNext()) return null;
|
|
2366
|
+
const bar = this.candles[this.index];
|
|
2367
|
+
this.history.push(bar);
|
|
2368
|
+
this.lastBar = bar;
|
|
2369
|
+
const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
|
|
2370
|
+
const dayKey = this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC2(bar.time);
|
|
2371
|
+
if (this.currentDay === null || dayKey !== this.currentDay) {
|
|
2372
|
+
this.currentDay = dayKey;
|
|
2373
|
+
this.dayPnl = 0;
|
|
2374
|
+
this.dayTrades = 0;
|
|
2375
|
+
this.dayEquityStart = this.currentEquity;
|
|
2376
|
+
}
|
|
2377
|
+
if (this.open && this.open._maxBarsInTrade > 0) {
|
|
2378
|
+
const barsHeld = Math.max(
|
|
2379
|
+
1,
|
|
2380
|
+
Math.round((bar.time - this.open.openTime) / this.estimatedBarMs)
|
|
2381
|
+
);
|
|
2382
|
+
if (barsHeld >= this.open._maxBarsInTrade) {
|
|
2383
|
+
this.forceExit("TIME", bar);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
if (this.open && Number.isFinite(this.open._maxHoldMin) && this.open._maxHoldMin > 0) {
|
|
2387
|
+
const heldMinutes = (bar.time - this.open.openTime) / 6e4;
|
|
2388
|
+
if (heldMinutes >= this.open._maxHoldMin) {
|
|
2389
|
+
this.forceExit("TIME", bar);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
if (this.options.flattenAtClose && this.open && isEODBar(bar.time)) {
|
|
2393
|
+
this.forceExit("EOD", bar);
|
|
2394
|
+
}
|
|
2395
|
+
if (this.open) {
|
|
2396
|
+
const risk = this.open._initRisk || 1e-8;
|
|
2397
|
+
const highR = this.open.side === "long" ? (bar.high - this.open.entry) / risk : (this.open.entry - bar.low) / risk;
|
|
2398
|
+
const lowR = this.open.side === "long" ? (bar.low - this.open.entry) / risk : (this.open.entry - bar.high) / risk;
|
|
2399
|
+
const markR = this.open.side === "long" ? (bar.close - this.open.entry) / risk : (this.open.entry - bar.close) / risk;
|
|
2400
|
+
if (this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2401
|
+
this.open._lastATR = this.atrValues[this.index];
|
|
2402
|
+
}
|
|
2403
|
+
this.open._mfeR = Math.max(this.open._mfeR ?? -Infinity, highR);
|
|
2404
|
+
this.open._maeR = Math.min(this.open._maeR ?? Infinity, lowR);
|
|
2405
|
+
if (this.open._breakevenAtR > 0 && highR >= this.open._breakevenAtR && !this.open._beArmed) {
|
|
2406
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, this.open.entry) : Math.min(this.open.stop, this.open.entry);
|
|
2407
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2408
|
+
this.open._beArmed = true;
|
|
2409
|
+
}
|
|
2410
|
+
if (this.open._trailAfterR > 0 && highR >= this.open._trailAfterR) {
|
|
2411
|
+
const candidate = this.open.side === "long" ? bar.close - risk : bar.close + risk;
|
|
2412
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
|
|
2413
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2414
|
+
}
|
|
2415
|
+
if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
|
|
2416
|
+
const targetR = Math.max(
|
|
2417
|
+
0,
|
|
2418
|
+
this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR)
|
|
2419
|
+
);
|
|
2420
|
+
const candidate = this.open.side === "long" ? this.open.entry + targetR * risk : this.open.entry - targetR * risk;
|
|
2421
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
|
|
2422
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2423
|
+
}
|
|
2424
|
+
if (this.options.atrTrailMult > 0 && this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2425
|
+
const trailDistance = this.atrValues[this.index] * this.options.atrTrailMult;
|
|
2426
|
+
const candidate = this.open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
|
|
2427
|
+
const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
|
|
2428
|
+
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2429
|
+
}
|
|
2430
|
+
if (this.options.volScale.enabled && this.open.entryATR && this.open.size > this.options.minQty && this.atrValues && this.atrValues[this.index] !== void 0) {
|
|
2431
|
+
const ratio = this.atrValues[this.index] / Math.max(1e-12, this.open.entryATR);
|
|
2432
|
+
const shouldCut = ratio >= this.options.volScale.cutIfAtrX && markR < this.options.volScale.noCutAboveR && !this.open._volCutDone;
|
|
2433
|
+
if (shouldCut) {
|
|
2434
|
+
const cutQty = roundStep2(this.open.size * this.options.volScale.cutFrac, this.options.qtyStep);
|
|
2435
|
+
if (cutQty >= this.options.minQty && cutQty < this.open.size) {
|
|
2436
|
+
const exitSide2 = this.open.side === "long" ? "short" : "long";
|
|
2437
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
|
|
2438
|
+
slippageBps: this.options.slippageBps,
|
|
2439
|
+
feeBps: this.options.feeBps,
|
|
2440
|
+
kind: "market",
|
|
2441
|
+
qty: cutQty,
|
|
2442
|
+
costs: this.options.costs
|
|
2443
|
+
});
|
|
2444
|
+
this.closeLeg({
|
|
2445
|
+
openPos: this.open,
|
|
2446
|
+
qty: cutQty,
|
|
2447
|
+
exitPx: filled,
|
|
2448
|
+
exitFeeTotal,
|
|
2449
|
+
time: bar.time,
|
|
2450
|
+
reason: "SCALE"
|
|
2451
|
+
});
|
|
2452
|
+
this.tightenStopToNetBreakeven(this.open, bar.close);
|
|
2453
|
+
this.open._volCutDone = true;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
let addedThisBar = false;
|
|
2458
|
+
if (this.options.pyramiding.enabled && (this.open._adds ?? 0) < this.options.pyramiding.maxAdds) {
|
|
2459
|
+
const addNumber = (this.open._adds || 0) + 1;
|
|
2460
|
+
const triggerR = this.options.pyramiding.addAtR * addNumber;
|
|
2461
|
+
const triggerPrice = this.open.side === "long" ? this.open.entry + triggerR * risk : this.open.entry - triggerR * risk;
|
|
2462
|
+
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;
|
|
2463
|
+
const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
2464
|
+
if (breakEvenSatisfied && touched) {
|
|
2465
|
+
const baseSize = this.open.baseSize || this.open.initSize;
|
|
2466
|
+
const requestedQty = roundStep2(baseSize * this.options.pyramiding.addFrac, this.options.qtyStep);
|
|
2467
|
+
const addQty = typeof resolveEntrySize === "function" ? roundStep2(
|
|
2468
|
+
resolveEntrySize({
|
|
2469
|
+
runner: this,
|
|
2470
|
+
desiredSize: requestedQty,
|
|
2471
|
+
entryPrice: triggerPrice,
|
|
2472
|
+
stopPrice: this.open.stop,
|
|
2473
|
+
pending: {
|
|
2474
|
+
side: this.open.side,
|
|
2475
|
+
meta: this.open,
|
|
2476
|
+
riskFrac: this.options.riskPct / 100
|
|
2477
|
+
},
|
|
2478
|
+
fillKind: "limit"
|
|
2479
|
+
}),
|
|
2480
|
+
this.options.qtyStep
|
|
2481
|
+
) : requestedQty;
|
|
2482
|
+
if (addQty >= this.options.minQty) {
|
|
2483
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, this.open.side, {
|
|
2484
|
+
slippageBps: this.options.slippageBps,
|
|
2485
|
+
feeBps: this.options.feeBps,
|
|
2486
|
+
kind: "limit",
|
|
2487
|
+
qty: addQty,
|
|
2488
|
+
costs: this.options.costs
|
|
2489
|
+
});
|
|
2490
|
+
const newSize = this.open.size + addQty;
|
|
2491
|
+
this.open.entryFeeTotal += addFeeTotal;
|
|
2492
|
+
this.open.entryFill = (this.open.entryFill * this.open.size + addFill * addQty) / newSize;
|
|
2493
|
+
this.open.size = newSize;
|
|
2494
|
+
this.open.initSize += addQty;
|
|
2495
|
+
if (!this.open.baseSize) this.open.baseSize = baseSize;
|
|
2496
|
+
this.open._adds = addNumber;
|
|
2497
|
+
addedThisBar = true;
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
if (!addedThisBar && !this.open._scaled && this.options.scaleOutAtR > 0) {
|
|
2502
|
+
const triggerPrice = this.open.side === "long" ? this.open.entry + this.options.scaleOutAtR * risk : this.open.entry - this.options.scaleOutAtR * risk;
|
|
2503
|
+
const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
2504
|
+
if (touched) {
|
|
2505
|
+
const exitSide2 = this.open.side === "long" ? "short" : "long";
|
|
2506
|
+
const qty = roundStep2(this.open.size * this.options.scaleOutFrac, this.options.qtyStep);
|
|
2507
|
+
if (qty >= this.options.minQty && qty < this.open.size) {
|
|
2508
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
|
|
2509
|
+
slippageBps: this.options.slippageBps,
|
|
2510
|
+
feeBps: this.options.feeBps,
|
|
2511
|
+
kind: "limit",
|
|
2512
|
+
qty,
|
|
2513
|
+
costs: this.options.costs
|
|
2514
|
+
});
|
|
2515
|
+
this.closeLeg({
|
|
2516
|
+
openPos: this.open,
|
|
2517
|
+
qty,
|
|
2518
|
+
exitPx: filled,
|
|
2519
|
+
exitFeeTotal,
|
|
2520
|
+
time: bar.time,
|
|
2521
|
+
reason: "SCALE"
|
|
2522
|
+
});
|
|
2523
|
+
this.open._scaled = true;
|
|
2524
|
+
this.open.takeProfit = this.open.side === "long" ? this.open.entry + this.options.finalTP_R * risk : this.open.entry - this.options.finalTP_R * risk;
|
|
2525
|
+
this.tightenStopToNetBreakeven(this.open, bar.close);
|
|
2526
|
+
this.open._beArmed = true;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
2531
|
+
const { hit, px } = ocoExitCheck({
|
|
2532
|
+
side: this.open.side,
|
|
2533
|
+
stop: this.open.stop,
|
|
2534
|
+
tp: this.open.takeProfit,
|
|
2535
|
+
bar,
|
|
2536
|
+
mode: this.options.oco.mode,
|
|
2537
|
+
tieBreak: this.options.oco.tieBreak
|
|
2538
|
+
});
|
|
2539
|
+
if (hit) {
|
|
2540
|
+
const exitKind = hit === "TP" ? "limit" : "stop";
|
|
2541
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
|
|
2542
|
+
slippageBps: this.options.slippageBps,
|
|
2543
|
+
feeBps: this.options.feeBps,
|
|
2544
|
+
kind: exitKind,
|
|
2545
|
+
qty: this.open.size,
|
|
2546
|
+
costs: this.options.costs
|
|
2547
|
+
});
|
|
2548
|
+
const localCooldown = this.open._cooldownBars || 0;
|
|
2549
|
+
this.closeLeg({
|
|
2550
|
+
openPos: this.open,
|
|
2551
|
+
qty: this.open.size,
|
|
2552
|
+
exitPx: filled,
|
|
2553
|
+
exitFeeTotal,
|
|
2554
|
+
time: bar.time,
|
|
2555
|
+
reason: hit
|
|
2556
|
+
});
|
|
2557
|
+
this.cooldown = (hit === "SL" ? Math.max(this.cooldown, this.options.postLossCooldownBars) : this.cooldown) || localCooldown;
|
|
2558
|
+
this.open = null;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
const maxLossDollars = this.options.maxDailyLossPct / 100 * this.dayEquityStart;
|
|
2562
|
+
const dailyLossHit = this.dayPnl <= -Math.abs(maxLossDollars);
|
|
2563
|
+
const dailyTradeCapHit = this.options.dailyMaxTrades > 0 && this.dayTrades >= this.options.dailyMaxTrades;
|
|
2564
|
+
if (!this.open && this.pending) {
|
|
2565
|
+
if (!canTrade) {
|
|
2566
|
+
this.pending = null;
|
|
2567
|
+
} else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
2568
|
+
if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
|
|
2569
|
+
const riskAtEdge = Math.abs(
|
|
2570
|
+
this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
|
|
2571
|
+
);
|
|
2572
|
+
const priceNow = bar.close;
|
|
2573
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2574
|
+
const slippedR = Math.max(
|
|
2575
|
+
0,
|
|
2576
|
+
direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
|
|
2577
|
+
) / Math.max(1e-8, riskAtEdge);
|
|
2578
|
+
if (slippedR > this.options.maxSlipROnFill) {
|
|
2579
|
+
this.pending = null;
|
|
2580
|
+
} else if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
|
|
2581
|
+
this.pending = null;
|
|
2582
|
+
}
|
|
2583
|
+
} else {
|
|
2584
|
+
this.pending = null;
|
|
2585
|
+
}
|
|
2586
|
+
} else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
2587
|
+
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
2588
|
+
this.pending = null;
|
|
2589
|
+
}
|
|
2590
|
+
} else if (this.options.entryChase.enabled) {
|
|
2591
|
+
const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
|
|
2592
|
+
const midpoint = this.pending.meta?._imb?.mid;
|
|
2593
|
+
if (!this.pending._chasedCE && midpoint !== void 0 && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
|
|
2594
|
+
this.pending.entry = midpoint;
|
|
2595
|
+
this.pending._chasedCE = true;
|
|
2596
|
+
}
|
|
2597
|
+
if (this.pending._chasedCE) {
|
|
2598
|
+
const riskRef = Math.abs(
|
|
2599
|
+
this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
|
|
2600
|
+
);
|
|
2601
|
+
const priceNow = bar.close;
|
|
2602
|
+
const direction = this.pending.side === "long" ? 1 : -1;
|
|
2603
|
+
const slippedR = Math.max(
|
|
2604
|
+
0,
|
|
2605
|
+
direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
|
|
2606
|
+
) / Math.max(1e-8, riskRef);
|
|
2607
|
+
if (slippedR > this.options.maxSlipROnFill) {
|
|
2608
|
+
this.pending = null;
|
|
2609
|
+
} else if (slippedR > 0 && slippedR <= this.options.entryChase.maxSlipR) {
|
|
2610
|
+
if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
|
|
2611
|
+
this.pending = null;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
if (this.open || this.cooldown > 0) {
|
|
2618
|
+
if (this.cooldown > 0) this.cooldown -= 1;
|
|
2619
|
+
this.recordFrame(bar);
|
|
2620
|
+
this.index += 1;
|
|
2621
|
+
return bar;
|
|
2622
|
+
}
|
|
2623
|
+
if (!canTrade || dailyLossHit || dailyTradeCapHit) {
|
|
2624
|
+
this.pending = null;
|
|
2625
|
+
this.recordFrame(bar);
|
|
2626
|
+
this.index += 1;
|
|
2627
|
+
return bar;
|
|
2628
|
+
}
|
|
2629
|
+
if (!this.pending) {
|
|
2630
|
+
const rawSignal = this.options.signal(this.buildSignalContext(this.index, bar, signalEquity));
|
|
2631
|
+
const nextSignal = normalizeSignal3(rawSignal, bar, this.options.finalTP_R);
|
|
2632
|
+
if (nextSignal) {
|
|
2633
|
+
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
|
|
2634
|
+
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
2635
|
+
this.pending = {
|
|
2636
|
+
side: nextSignal.side,
|
|
2637
|
+
entry: nextSignal.entry,
|
|
2638
|
+
stop: nextSignal.stop,
|
|
2639
|
+
tp: nextSignal.takeProfit,
|
|
2640
|
+
riskFrac: signalRiskFraction,
|
|
2641
|
+
fixedQty: nextSignal.qty,
|
|
2642
|
+
expiresAt: this.index + Math.max(1, expiryBars),
|
|
2643
|
+
startedAtIndex: this.index,
|
|
2644
|
+
meta: nextSignal,
|
|
2645
|
+
plannedRiskAbs: Math.abs(
|
|
2646
|
+
nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
|
|
2647
|
+
)
|
|
2648
|
+
};
|
|
2649
|
+
if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
2650
|
+
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
2651
|
+
this.pending = null;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
this.recordFrame(bar);
|
|
2657
|
+
this.index += 1;
|
|
2658
|
+
return bar;
|
|
2659
|
+
}
|
|
2660
|
+
buildResult() {
|
|
2661
|
+
const metrics = buildMetrics({
|
|
2662
|
+
closed: this.closed,
|
|
2663
|
+
equityStart: this.options.equity,
|
|
2664
|
+
equityFinal: this.currentEquity,
|
|
2665
|
+
candles: this.candles,
|
|
2666
|
+
estBarMs: this.estimatedBarMs,
|
|
2667
|
+
eqSeries: this.eqSeries
|
|
2668
|
+
});
|
|
2669
|
+
const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
2670
|
+
return {
|
|
2671
|
+
symbol: this.options.symbol,
|
|
2672
|
+
interval: this.options.interval,
|
|
2673
|
+
range: this.options.range,
|
|
2674
|
+
trades: this.closed,
|
|
2675
|
+
positions,
|
|
2676
|
+
metrics,
|
|
2677
|
+
eqSeries: this.eqSeries,
|
|
2678
|
+
replay: {
|
|
2679
|
+
frames: this.replayFrames,
|
|
2680
|
+
events: this.replayEvents
|
|
2681
|
+
}
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct) {
|
|
2686
|
+
const limits = [];
|
|
2687
|
+
if (Number.isFinite(capPct) && capPct > 0) limits.push(totalEquity * capPct);
|
|
2688
|
+
if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
|
|
2689
|
+
if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
|
|
2690
|
+
limits.push(totalEquity * maxAllocationPct);
|
|
2691
|
+
}
|
|
2692
|
+
return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// src/engine/portfolio.js
|
|
2696
|
+
function asWeight(value) {
|
|
2697
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
2698
|
+
}
|
|
2699
|
+
function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
|
|
2700
|
+
return {
|
|
2701
|
+
time,
|
|
2702
|
+
timestamp: time,
|
|
2703
|
+
equity,
|
|
2704
|
+
lockedCapital,
|
|
2705
|
+
availableCapital
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function stableSystemOrder(left, right) {
|
|
2709
|
+
return left.index - right.index;
|
|
2710
|
+
}
|
|
2711
|
+
function combineReplay(systemResults, eqSeries, collectReplay) {
|
|
2712
|
+
if (!collectReplay) {
|
|
2713
|
+
return { frames: [], events: [] };
|
|
2714
|
+
}
|
|
2715
|
+
const events = systemResults.flatMap(
|
|
2716
|
+
(entry) => (entry.result.replay?.events || []).map((event) => ({
|
|
2717
|
+
...event,
|
|
2718
|
+
symbol: event.symbol || entry.symbol
|
|
2719
|
+
}))
|
|
2720
|
+
).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
|
|
2721
|
+
const frames = eqSeries.map((point) => ({
|
|
2722
|
+
t: new Date(point.time).toISOString(),
|
|
2723
|
+
price: 0,
|
|
2724
|
+
equity: point.equity,
|
|
2725
|
+
posSide: null,
|
|
2726
|
+
posSize: 0,
|
|
2727
|
+
lockedCapital: point.lockedCapital,
|
|
2728
|
+
availableCapital: point.availableCapital
|
|
2729
|
+
}));
|
|
2730
|
+
return { frames, events };
|
|
2731
|
+
}
|
|
2732
|
+
function portfolioState(runners, initialEquity) {
|
|
2733
|
+
let markedEquity = initialEquity;
|
|
2734
|
+
let lockedCapital = 0;
|
|
2735
|
+
for (const { runner, initialReferenceEquity } of runners) {
|
|
2736
|
+
markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
|
|
2737
|
+
lockedCapital += runner.getLockedCapital();
|
|
2738
|
+
}
|
|
2739
|
+
return {
|
|
2740
|
+
markedEquity,
|
|
2741
|
+
lockedCapital,
|
|
2742
|
+
availableCapital: markedEquity - lockedCapital
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
function findNextTimeAndActive(runners) {
|
|
2746
|
+
let nextTime = Infinity;
|
|
2747
|
+
const active = [];
|
|
2748
|
+
for (const entry of runners) {
|
|
2749
|
+
const time = entry.runner.peekTime();
|
|
2750
|
+
if (time < nextTime) {
|
|
2751
|
+
nextTime = time;
|
|
2752
|
+
active.length = 0;
|
|
2753
|
+
active.push(entry);
|
|
2754
|
+
continue;
|
|
2755
|
+
}
|
|
2756
|
+
if (time === nextTime) {
|
|
2757
|
+
active.push(entry);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
return { nextTime, active };
|
|
2761
|
+
}
|
|
2762
|
+
function initialPortfolioTime(runners) {
|
|
2763
|
+
let time = Infinity;
|
|
2764
|
+
for (const { runner } of runners) {
|
|
2765
|
+
const next = runner.candles[0]?.time ?? Infinity;
|
|
2766
|
+
if (next < time) time = next;
|
|
2767
|
+
}
|
|
2768
|
+
return Number.isFinite(time) ? time : 0;
|
|
2769
|
+
}
|
|
2770
|
+
function resolveSystemCap(systemEntry, totalEquity) {
|
|
2771
|
+
return defaultSystemCap(
|
|
2772
|
+
Math.max(0, totalEquity),
|
|
2773
|
+
systemEntry.defaultCapPct,
|
|
2774
|
+
systemEntry.system.maxAllocation,
|
|
2775
|
+
systemEntry.system.maxAllocationPct
|
|
2776
|
+
);
|
|
2777
|
+
}
|
|
2778
|
+
function forceExitAll(runners, time) {
|
|
2779
|
+
for (const { runner } of runners) {
|
|
2780
|
+
if (!runner.open) continue;
|
|
2781
|
+
const price = runner.getMarkPrice();
|
|
2782
|
+
if (!Number.isFinite(price)) continue;
|
|
2783
|
+
runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
function backtestPortfolio({
|
|
2787
|
+
systems = [],
|
|
2788
|
+
equity = 1e4,
|
|
2789
|
+
allocation = "equal",
|
|
2790
|
+
collectEqSeries = true,
|
|
2791
|
+
collectReplay = false,
|
|
2792
|
+
maxDailyLossPct = 0
|
|
2793
|
+
} = {}) {
|
|
2794
|
+
if (!Array.isArray(systems) || systems.length === 0) {
|
|
2795
|
+
throw new Error("backtestPortfolio() requires a non-empty systems array");
|
|
2796
|
+
}
|
|
2797
|
+
const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
|
|
2798
|
+
const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
|
|
2799
|
+
if (!(totalWeight > 0)) {
|
|
2800
|
+
throw new Error("backtestPortfolio() requires positive allocation weights");
|
|
2801
|
+
}
|
|
2802
|
+
const runners = systems.map((system, index) => {
|
|
2803
|
+
const defaultCapPct = weights[index] / totalWeight;
|
|
2804
|
+
const initialReferenceEquity = equity * defaultCapPct;
|
|
2805
|
+
return {
|
|
2806
|
+
index,
|
|
2807
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
2808
|
+
system,
|
|
2809
|
+
defaultCapPct,
|
|
2810
|
+
initialReferenceEquity,
|
|
2811
|
+
runner: new BarSystemRunner({
|
|
2812
|
+
...system,
|
|
2813
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
2814
|
+
equity: initialReferenceEquity,
|
|
2815
|
+
collectEqSeries,
|
|
2816
|
+
collectReplay
|
|
2817
|
+
})
|
|
2818
|
+
};
|
|
2819
|
+
});
|
|
2820
|
+
const eqSeries = collectEqSeries ? [] : [];
|
|
2821
|
+
let state = portfolioState(runners, equity);
|
|
2822
|
+
if (collectEqSeries) {
|
|
2823
|
+
eqSeries.push(
|
|
2824
|
+
buildPortfolioPoint(
|
|
2825
|
+
initialPortfolioTime(runners),
|
|
2826
|
+
state.markedEquity,
|
|
2827
|
+
state.lockedCapital,
|
|
2828
|
+
state.availableCapital
|
|
2829
|
+
)
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
let currentDay = null;
|
|
2833
|
+
let dayStartEquity = equity;
|
|
2834
|
+
let portfolioHalted = false;
|
|
2835
|
+
while (true) {
|
|
2836
|
+
const { nextTime, active } = findNextTimeAndActive(runners);
|
|
2837
|
+
if (!Number.isFinite(nextTime)) break;
|
|
2838
|
+
active.sort(stableSystemOrder);
|
|
2839
|
+
const dayKey = dayKeyET(nextTime);
|
|
2840
|
+
if (currentDay === null || dayKey !== currentDay) {
|
|
2841
|
+
currentDay = dayKey;
|
|
2842
|
+
state = portfolioState(runners, equity);
|
|
2843
|
+
dayStartEquity = state.markedEquity;
|
|
2844
|
+
portfolioHalted = false;
|
|
2845
|
+
}
|
|
2846
|
+
for (const systemEntry of active) {
|
|
2847
|
+
state = portfolioState(runners, equity);
|
|
2848
|
+
const totalEquity = state.markedEquity;
|
|
2849
|
+
const availableCapital = Math.max(0, state.availableCapital);
|
|
2850
|
+
const systemLocked = systemEntry.runner.getLockedCapital();
|
|
2851
|
+
const systemCap = resolveSystemCap(systemEntry, totalEquity);
|
|
2852
|
+
const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
|
|
2853
|
+
systemEntry.runner.step({
|
|
2854
|
+
signalEquity: totalEquity,
|
|
2855
|
+
canTrade: !portfolioHalted,
|
|
2856
|
+
resolveEntrySize({ desiredSize, entryPrice }) {
|
|
2857
|
+
const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
|
|
2858
|
+
const byAvailable = availableCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
|
|
2859
|
+
const bySystemCap = systemRemainingCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
|
|
2860
|
+
return Math.min(desiredSize, byAvailable, bySystemCap);
|
|
2861
|
+
}
|
|
2862
|
+
});
|
|
2863
|
+
state = portfolioState(runners, equity);
|
|
2864
|
+
if (!portfolioHalted && maxDailyLossPct > 0 && state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)) {
|
|
2865
|
+
portfolioHalted = true;
|
|
2866
|
+
for (const { runner } of runners) runner.cancelPending();
|
|
2867
|
+
forceExitAll(runners, nextTime);
|
|
2868
|
+
state = portfolioState(runners, equity);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
if (collectEqSeries) {
|
|
2872
|
+
eqSeries.push(
|
|
2873
|
+
buildPortfolioPoint(
|
|
2874
|
+
nextTime,
|
|
2875
|
+
state.markedEquity,
|
|
2876
|
+
state.lockedCapital,
|
|
2877
|
+
state.availableCapital
|
|
2878
|
+
)
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
const systemResults = runners.map((entry) => ({
|
|
2883
|
+
symbol: entry.symbol,
|
|
2884
|
+
weight: entry.defaultCapPct,
|
|
2885
|
+
equity: entry.initialReferenceEquity,
|
|
2886
|
+
allocationCapPct: entry.defaultCapPct,
|
|
2887
|
+
allocationCap: resolveSystemCap(entry, equity),
|
|
2888
|
+
result: entry.runner.buildResult()
|
|
2889
|
+
}));
|
|
2890
|
+
const trades = systemResults.flatMap(
|
|
2891
|
+
(run) => run.result.trades.map((trade) => ({
|
|
2892
|
+
...trade,
|
|
2893
|
+
symbol: trade.symbol || run.symbol
|
|
2894
|
+
}))
|
|
2895
|
+
).sort((left, right) => left.exit.time - right.exit.time);
|
|
2896
|
+
const positions = systemResults.flatMap(
|
|
2897
|
+
(run) => run.result.positions.map((trade) => ({
|
|
2898
|
+
...trade,
|
|
2899
|
+
symbol: trade.symbol || run.symbol
|
|
2900
|
+
}))
|
|
2901
|
+
).sort((left, right) => left.exit.time - right.exit.time);
|
|
2902
|
+
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
2903
|
+
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
2904
|
+
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
2905
|
+
const metrics = buildMetrics({
|
|
2906
|
+
closed: trades,
|
|
2907
|
+
equityStart: equity,
|
|
2908
|
+
equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
|
|
2909
|
+
candles: orderedCandles,
|
|
2910
|
+
estBarMs: estimateBarMs(orderedCandles),
|
|
2911
|
+
eqSeries
|
|
2912
|
+
});
|
|
2913
|
+
return {
|
|
2914
|
+
symbol: "PORTFOLIO",
|
|
2915
|
+
interval: void 0,
|
|
2916
|
+
range: void 0,
|
|
2917
|
+
trades,
|
|
2918
|
+
positions,
|
|
2919
|
+
metrics,
|
|
2920
|
+
eqSeries,
|
|
2921
|
+
replay,
|
|
2922
|
+
systems: systemResults
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
// src/engine/walkForward.js
|
|
2927
|
+
function scoreOf(metrics, scoreBy) {
|
|
2928
|
+
const value = metrics?.[scoreBy];
|
|
2929
|
+
return Number.isFinite(value) ? value : -Infinity;
|
|
2930
|
+
}
|
|
2931
|
+
function stitchEquitySeries(target, source) {
|
|
2932
|
+
if (!source?.length) return;
|
|
2933
|
+
if (!target.length) {
|
|
2934
|
+
target.push(...source);
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
const lastTime = target[target.length - 1].time;
|
|
2938
|
+
const nextPoints = source.filter((point) => point.time > lastTime);
|
|
2939
|
+
target.push(...nextPoints);
|
|
2940
|
+
}
|
|
2941
|
+
function canonicalParams(params) {
|
|
2942
|
+
const entries = Object.entries(params || {}).sort(
|
|
2943
|
+
([left], [right]) => left.localeCompare(right)
|
|
2944
|
+
);
|
|
2945
|
+
return JSON.stringify(Object.fromEntries(entries));
|
|
2946
|
+
}
|
|
2947
|
+
function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
|
|
2948
|
+
const ranges = [];
|
|
2949
|
+
for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
|
|
2950
|
+
const trainStart = mode === "anchored" ? 0 : start;
|
|
2951
|
+
const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
|
|
2952
|
+
const testStart = trainEnd;
|
|
2953
|
+
const testEnd = testStart + testBars;
|
|
2954
|
+
if (testEnd > length) break;
|
|
2955
|
+
ranges.push({ trainStart, trainEnd, testStart, testEnd });
|
|
2956
|
+
}
|
|
2957
|
+
return ranges;
|
|
2958
|
+
}
|
|
2959
|
+
function summarizeBestParams(windows) {
|
|
2960
|
+
const summaryBySignature = /* @__PURE__ */ new Map();
|
|
2961
|
+
let adjacentRepeats = 0;
|
|
2962
|
+
windows.forEach((window, index) => {
|
|
2963
|
+
const signature = window.bestParamsSignature ?? canonicalParams(window.bestParams);
|
|
2964
|
+
const current = summaryBySignature.get(signature) || {
|
|
2965
|
+
params: window.bestParams,
|
|
2966
|
+
wins: 0,
|
|
2967
|
+
profitableWindows: 0,
|
|
2968
|
+
oosTrades: 0
|
|
2969
|
+
};
|
|
2970
|
+
current.wins += 1;
|
|
2971
|
+
current.profitableWindows += window.profitable ? 1 : 0;
|
|
2972
|
+
current.oosTrades += window.oosTrades;
|
|
2973
|
+
summaryBySignature.set(signature, current);
|
|
2974
|
+
if (index > 0 && (windows[index - 1].bestParamsSignature ?? canonicalParams(windows[index - 1].bestParams)) === signature) {
|
|
2975
|
+
adjacentRepeats += 1;
|
|
2976
|
+
}
|
|
2977
|
+
});
|
|
2978
|
+
const byFrequency = [...summaryBySignature.values()].sort((left, right) => {
|
|
2979
|
+
if (right.wins !== left.wins) return right.wins - left.wins;
|
|
2980
|
+
return right.profitableWindows - left.profitableWindows;
|
|
2981
|
+
});
|
|
2982
|
+
const adjacentPairs = Math.max(0, windows.length - 1);
|
|
2983
|
+
return {
|
|
2984
|
+
winners: windows.map((window) => window.bestParams),
|
|
2985
|
+
stability: {
|
|
2986
|
+
adjacentRepeatRate: adjacentPairs ? adjacentRepeats / adjacentPairs : 0,
|
|
2987
|
+
uniqueWinnerCount: summaryBySignature.size,
|
|
2988
|
+
dominant: byFrequency[0] || null,
|
|
2989
|
+
leaderboard: byFrequency
|
|
2990
|
+
}
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
function walkForwardOptimize({
|
|
2994
|
+
candles = [],
|
|
2995
|
+
signalFactory,
|
|
2996
|
+
parameterSets = [],
|
|
2997
|
+
trainBars,
|
|
2998
|
+
testBars,
|
|
2999
|
+
stepBars = testBars,
|
|
3000
|
+
mode = "rolling",
|
|
3001
|
+
scoreBy = "profitFactor",
|
|
3002
|
+
backtestOptions = {}
|
|
3003
|
+
} = {}) {
|
|
3004
|
+
if (!Array.isArray(candles) || candles.length === 0) {
|
|
3005
|
+
throw new Error("walkForwardOptimize() requires a non-empty candles array");
|
|
3006
|
+
}
|
|
3007
|
+
if (typeof signalFactory !== "function") {
|
|
3008
|
+
throw new Error("walkForwardOptimize() requires a signalFactory function");
|
|
3009
|
+
}
|
|
3010
|
+
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
3011
|
+
throw new Error("walkForwardOptimize() requires parameterSets");
|
|
3012
|
+
}
|
|
3013
|
+
if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
|
|
3014
|
+
throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
|
|
3015
|
+
}
|
|
3016
|
+
if (mode !== "rolling" && mode !== "anchored") {
|
|
3017
|
+
throw new Error('walkForwardOptimize() mode must be "rolling" or "anchored"');
|
|
3018
|
+
}
|
|
3019
|
+
const windows = [];
|
|
3020
|
+
const allTrades = [];
|
|
3021
|
+
const allPositions = [];
|
|
3022
|
+
const eqSeries = [];
|
|
3023
|
+
let rollingEquity = backtestOptions.equity ?? 1e4;
|
|
3024
|
+
const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
|
|
3025
|
+
const trainBacktestOptions = {
|
|
3026
|
+
...backtestOptions,
|
|
3027
|
+
collectEqSeries: false,
|
|
3028
|
+
collectReplay: false
|
|
3029
|
+
};
|
|
3030
|
+
const testBacktestOptions = { ...backtestOptions };
|
|
3031
|
+
for (const range of ranges) {
|
|
3032
|
+
const trainSlice = candles.slice(range.trainStart, range.trainEnd);
|
|
3033
|
+
const testSlice = candles.slice(range.testStart, range.testEnd);
|
|
3034
|
+
let best = null;
|
|
3035
|
+
for (const params of parameterSets) {
|
|
3036
|
+
const trainResult = backtest({
|
|
3037
|
+
...trainBacktestOptions,
|
|
3038
|
+
candles: trainSlice,
|
|
3039
|
+
equity: rollingEquity,
|
|
3040
|
+
signal: signalFactory(params)
|
|
3041
|
+
});
|
|
3042
|
+
const score = scoreOf(trainResult.metrics, scoreBy);
|
|
3043
|
+
if (!best || score > best.score) {
|
|
3044
|
+
best = { params, score, metrics: trainResult.metrics };
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
const testResult = backtest({
|
|
3048
|
+
...testBacktestOptions,
|
|
3049
|
+
candles: testSlice,
|
|
3050
|
+
equity: rollingEquity,
|
|
1764
3051
|
signal: signalFactory(best.params)
|
|
1765
3052
|
});
|
|
3053
|
+
const bestParamsSignature = canonicalParams(best.params);
|
|
1766
3054
|
rollingEquity = testResult.metrics.finalEquity;
|
|
1767
3055
|
allTrades.push(...testResult.trades);
|
|
1768
3056
|
allPositions.push(...testResult.positions);
|
|
@@ -1780,9 +3068,25 @@ function walkForwardOptimize({
|
|
|
1780
3068
|
trainScore: best.score,
|
|
1781
3069
|
trainMetrics: best.metrics,
|
|
1782
3070
|
testMetrics: testResult.metrics,
|
|
3071
|
+
oosTrades: testResult.metrics.trades,
|
|
3072
|
+
profitable: testResult.metrics.totalPnL > 0,
|
|
3073
|
+
stabilityScore: 0,
|
|
3074
|
+
bestParamsSignature,
|
|
1783
3075
|
result: testResult
|
|
1784
3076
|
});
|
|
1785
3077
|
}
|
|
3078
|
+
for (let index = 0; index < windows.length; index += 1) {
|
|
3079
|
+
const currentSignature = windows[index].bestParamsSignature;
|
|
3080
|
+
const adjacent = [];
|
|
3081
|
+
if (index > 0) {
|
|
3082
|
+
adjacent.push(windows[index - 1].bestParamsSignature === currentSignature ? 1 : 0);
|
|
3083
|
+
}
|
|
3084
|
+
if (index + 1 < windows.length) {
|
|
3085
|
+
adjacent.push(windows[index + 1].bestParamsSignature === currentSignature ? 1 : 0);
|
|
3086
|
+
}
|
|
3087
|
+
windows[index].stabilityScore = adjacent.length ? adjacent.reduce((total, value) => total + value, 0) / adjacent.length : 1;
|
|
3088
|
+
delete windows[index].bestParamsSignature;
|
|
3089
|
+
}
|
|
1786
3090
|
const metrics = buildMetrics({
|
|
1787
3091
|
closed: allTrades,
|
|
1788
3092
|
equityStart: backtestOptions.equity ?? 1e4,
|
|
@@ -1791,6 +3095,7 @@ function walkForwardOptimize({
|
|
|
1791
3095
|
estBarMs: estimateBarMs(candles),
|
|
1792
3096
|
eqSeries
|
|
1793
3097
|
});
|
|
3098
|
+
const bestParamsSummary = summarizeBestParams(windows);
|
|
1794
3099
|
return {
|
|
1795
3100
|
windows,
|
|
1796
3101
|
trades: allTrades,
|
|
@@ -1798,7 +3103,8 @@ function walkForwardOptimize({
|
|
|
1798
3103
|
metrics,
|
|
1799
3104
|
eqSeries,
|
|
1800
3105
|
replay: { frames: [], events: [] },
|
|
1801
|
-
bestParams: windows.map((window) => window.bestParams)
|
|
3106
|
+
bestParams: Object.assign(windows.map((window) => window.bestParams), bestParamsSummary),
|
|
3107
|
+
bestParamsSummary: bestParamsSummary.stability
|
|
1802
3108
|
};
|
|
1803
3109
|
}
|
|
1804
3110
|
|
|
@@ -2522,6 +3828,7 @@ function exportBacktestArtifacts({
|
|
|
2522
3828
|
backtest,
|
|
2523
3829
|
backtestHistorical,
|
|
2524
3830
|
backtestPortfolio,
|
|
3831
|
+
backtestTicks,
|
|
2525
3832
|
bpsOf,
|
|
2526
3833
|
buildMetrics,
|
|
2527
3834
|
cachedCandlesPath,
|