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/data.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;
|
|
@@ -87,6 +88,14 @@ function atr(bars, period = 14) {
|
|
|
87
88
|
function roundStep(value, step) {
|
|
88
89
|
return Math.floor(value / step) * step;
|
|
89
90
|
}
|
|
91
|
+
var warnedNonPositiveEquity = false;
|
|
92
|
+
function warnNonPositiveEquity(equity) {
|
|
93
|
+
if (warnedNonPositiveEquity) return;
|
|
94
|
+
warnedNonPositiveEquity = true;
|
|
95
|
+
console.warn(
|
|
96
|
+
`[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
90
99
|
function calculatePositionSize({
|
|
91
100
|
equity,
|
|
92
101
|
entry,
|
|
@@ -96,6 +105,10 @@ function calculatePositionSize({
|
|
|
96
105
|
minQty = 1e-3,
|
|
97
106
|
maxLeverage = 2
|
|
98
107
|
}) {
|
|
108
|
+
if (!Number.isFinite(equity) || equity <= 0) {
|
|
109
|
+
warnNonPositiveEquity(equity);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
99
112
|
const riskPerUnit = Math.abs(entry - stop);
|
|
100
113
|
if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
|
|
101
114
|
const maxRiskDollars = Math.max(0, equity * riskFraction);
|
|
@@ -207,64 +220,95 @@ function percentile(values, percentileRank) {
|
|
|
207
220
|
const index = Math.floor((sorted.length - 1) * percentileRank);
|
|
208
221
|
return sorted[index];
|
|
209
222
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}) {
|
|
218
|
-
const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
219
|
-
const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
|
|
220
|
-
const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
|
|
221
|
-
const tradeRs = completedTrades.map(tradeRMultiple);
|
|
222
|
-
const totalR = sum(tradeRs);
|
|
223
|
-
const avgR = mean(tradeRs);
|
|
224
|
-
const labels = completedTrades.map(
|
|
225
|
-
(trade) => trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
|
|
226
|
-
);
|
|
227
|
-
const { maxWin, maxLoss } = streaks(labels);
|
|
228
|
-
const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
|
|
229
|
-
const expectancy = mean(tradePnls);
|
|
230
|
-
const tradeReturns = completedTrades.map(
|
|
231
|
-
(trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
|
|
232
|
-
);
|
|
233
|
-
const tradeReturnStd = stddev(tradeReturns);
|
|
234
|
-
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
235
|
-
const sortinoPerTrade = sortino(tradeReturns);
|
|
236
|
-
const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
|
|
237
|
-
const grossLossPositions = Math.abs(
|
|
238
|
-
sum(losingTrades.map((trade) => trade.exit.pnl))
|
|
239
|
-
);
|
|
240
|
-
const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
|
|
223
|
+
var PROFIT_FACTOR_CAP = 1e6;
|
|
224
|
+
function finiteProfitFactor(grossProfit, grossLoss) {
|
|
225
|
+
if (grossLoss === 0) {
|
|
226
|
+
return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
|
|
227
|
+
}
|
|
228
|
+
return grossProfit / grossLoss;
|
|
229
|
+
}
|
|
230
|
+
function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
|
|
241
231
|
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
const
|
|
232
|
+
const completedTrades = [];
|
|
233
|
+
const tradeRs = [];
|
|
234
|
+
const tradePnls = [];
|
|
235
|
+
const tradeReturns = [];
|
|
236
|
+
const holdDurationsMinutes = [];
|
|
237
|
+
const labels = [];
|
|
238
|
+
const longRs = [];
|
|
239
|
+
const shortRs = [];
|
|
240
|
+
let totalR = 0;
|
|
241
|
+
let realizedPnL = 0;
|
|
242
|
+
let winningTradeCount = 0;
|
|
243
|
+
let grossProfitPositions = 0;
|
|
244
|
+
let grossLossPositions = 0;
|
|
245
|
+
let grossProfitLegs = 0;
|
|
246
|
+
let grossLossLegs = 0;
|
|
247
|
+
let winningLegCount = 0;
|
|
248
|
+
let openBars = 0;
|
|
249
|
+
let longTradesCount = 0;
|
|
250
|
+
let longTradeWins = 0;
|
|
251
|
+
let longPnLSum = 0;
|
|
252
|
+
let shortTradesCount = 0;
|
|
253
|
+
let shortTradeWins = 0;
|
|
254
|
+
let shortPnLSum = 0;
|
|
247
255
|
let peakEquity = equityStart;
|
|
248
256
|
let currentEquity = equityStart;
|
|
249
257
|
let maxDrawdown = 0;
|
|
250
|
-
for (const
|
|
251
|
-
|
|
258
|
+
for (const trade of legs) {
|
|
259
|
+
const pnl = trade.exit.pnl;
|
|
260
|
+
realizedPnL += pnl;
|
|
261
|
+
if (pnl > 0) {
|
|
262
|
+
grossProfitLegs += pnl;
|
|
263
|
+
winningLegCount += 1;
|
|
264
|
+
} else if (pnl < 0) {
|
|
265
|
+
grossLossLegs += Math.abs(pnl);
|
|
266
|
+
}
|
|
267
|
+
currentEquity += pnl;
|
|
252
268
|
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
253
269
|
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
254
270
|
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
271
|
+
if (trade.exit.reason === "SCALE") continue;
|
|
272
|
+
completedTrades.push(trade);
|
|
273
|
+
tradePnls.push(pnl);
|
|
274
|
+
tradeReturns.push(pnl / Math.max(1e-12, equityStart));
|
|
275
|
+
const tradeR = tradeRMultiple(trade);
|
|
276
|
+
tradeRs.push(tradeR);
|
|
277
|
+
totalR += tradeR;
|
|
278
|
+
labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
|
|
279
|
+
const holdMinutes = (trade.exit.time - trade.openTime) / (1e3 * 60);
|
|
280
|
+
holdDurationsMinutes.push(holdMinutes);
|
|
281
|
+
openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
282
|
+
if (pnl > 0) {
|
|
283
|
+
winningTradeCount += 1;
|
|
284
|
+
grossProfitPositions += pnl;
|
|
285
|
+
} else if (pnl < 0) {
|
|
286
|
+
grossLossPositions += Math.abs(pnl);
|
|
287
|
+
}
|
|
288
|
+
if (trade.side === "long") {
|
|
289
|
+
longTradesCount += 1;
|
|
290
|
+
longPnLSum += pnl;
|
|
291
|
+
longRs.push(tradeR);
|
|
292
|
+
if (pnl > 0) longTradeWins += 1;
|
|
293
|
+
} else if (trade.side === "short") {
|
|
294
|
+
shortTradesCount += 1;
|
|
295
|
+
shortPnLSum += pnl;
|
|
296
|
+
shortRs.push(tradeR);
|
|
297
|
+
if (pnl > 0) shortTradeWins += 1;
|
|
298
|
+
}
|
|
255
299
|
}
|
|
256
|
-
const
|
|
300
|
+
const avgR = mean(tradeRs);
|
|
301
|
+
const { maxWin, maxLoss } = streaks(labels);
|
|
302
|
+
const expectancy = mean(tradePnls);
|
|
303
|
+
const tradeReturnStd = stddev(tradeReturns);
|
|
304
|
+
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
305
|
+
const sortinoPerTrade = sortino(tradeReturns);
|
|
306
|
+
const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
|
|
307
|
+
const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
|
|
257
308
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
258
309
|
const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
|
|
259
310
|
const totalBars = Math.max(1, candles.length);
|
|
260
|
-
const openBars = completedTrades.reduce((total, trade) => {
|
|
261
|
-
const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
262
|
-
return total + barsHeld;
|
|
263
|
-
}, 0);
|
|
264
311
|
const exposurePct = openBars / totalBars;
|
|
265
|
-
const holdDurationsMinutes = completedTrades.map(
|
|
266
|
-
(trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
|
|
267
|
-
);
|
|
268
312
|
const avgHoldMin = mean(holdDurationsMinutes);
|
|
269
313
|
const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
|
|
270
314
|
const dailyReturnsSeries = dailyReturns(equitySeries);
|
|
@@ -272,12 +316,6 @@ function buildMetrics({
|
|
|
272
316
|
const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
|
|
273
317
|
const sortinoDaily = sortino(dailyReturnsSeries);
|
|
274
318
|
const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
|
|
275
|
-
const longTrades = completedTrades.filter((trade) => trade.side === "long");
|
|
276
|
-
const shortTrades = completedTrades.filter((trade) => trade.side === "short");
|
|
277
|
-
const longRs = longTrades.map(tradeRMultiple);
|
|
278
|
-
const shortRs = shortTrades.map(tradeRMultiple);
|
|
279
|
-
const longPnls = longTrades.map((trade) => trade.exit.pnl);
|
|
280
|
-
const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
|
|
281
319
|
const rDistribution = {
|
|
282
320
|
p10: percentile(tradeRs, 0.1),
|
|
283
321
|
p25: percentile(tradeRs, 0.25),
|
|
@@ -294,21 +332,21 @@ function buildMetrics({
|
|
|
294
332
|
};
|
|
295
333
|
const sideBreakdown = {
|
|
296
334
|
long: {
|
|
297
|
-
trades:
|
|
298
|
-
winRate:
|
|
299
|
-
avgPnL:
|
|
335
|
+
trades: longTradesCount,
|
|
336
|
+
winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
|
|
337
|
+
avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
|
|
300
338
|
avgR: mean(longRs)
|
|
301
339
|
},
|
|
302
340
|
short: {
|
|
303
|
-
trades:
|
|
304
|
-
winRate:
|
|
305
|
-
avgPnL:
|
|
341
|
+
trades: shortTradesCount,
|
|
342
|
+
winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
|
|
343
|
+
avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
|
|
306
344
|
avgR: mean(shortRs)
|
|
307
345
|
}
|
|
308
346
|
};
|
|
309
347
|
return {
|
|
310
348
|
trades: completedTrades.length,
|
|
311
|
-
winRate: completedTrades.length ?
|
|
349
|
+
winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
312
350
|
profitFactor: profitFactorPositions,
|
|
313
351
|
expectancy,
|
|
314
352
|
totalR,
|
|
@@ -330,8 +368,8 @@ function buildMetrics({
|
|
|
330
368
|
startEquity: equityStart,
|
|
331
369
|
profitFactor_pos: profitFactorPositions,
|
|
332
370
|
profitFactor_leg: profitFactorLegs,
|
|
333
|
-
winRate_pos: completedTrades.length ?
|
|
334
|
-
winRate_leg: legs.length ?
|
|
371
|
+
winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
372
|
+
winRate_leg: legs.length ? winningLegCount / legs.length : 0,
|
|
335
373
|
sharpeDaily,
|
|
336
374
|
sortinoDaily,
|
|
337
375
|
sideBreakdown,
|
|
@@ -435,7 +473,7 @@ function normalizeDateBoundary(value, fallback) {
|
|
|
435
473
|
}
|
|
436
474
|
function normalizeCandles(candles) {
|
|
437
475
|
if (!Array.isArray(candles)) return [];
|
|
438
|
-
const
|
|
476
|
+
const parsed = candles.map((bar) => {
|
|
439
477
|
try {
|
|
440
478
|
const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
|
|
441
479
|
const open = Number(bar?.open ?? bar?.o);
|
|
@@ -457,7 +495,16 @@ function normalizeCandles(candles) {
|
|
|
457
495
|
} catch {
|
|
458
496
|
return null;
|
|
459
497
|
}
|
|
460
|
-
}).filter(Boolean)
|
|
498
|
+
}).filter(Boolean);
|
|
499
|
+
let reordered = false;
|
|
500
|
+
let duplicateCount = 0;
|
|
501
|
+
for (let index = 1; index < parsed.length; index += 1) {
|
|
502
|
+
const prev = parsed[index - 1].time;
|
|
503
|
+
const current = parsed[index].time;
|
|
504
|
+
if (current < prev) reordered = true;
|
|
505
|
+
if (current === prev) duplicateCount += 1;
|
|
506
|
+
}
|
|
507
|
+
const normalized = parsed.sort((left, right) => left.time - right.time);
|
|
461
508
|
const deduped = [];
|
|
462
509
|
let lastTime = null;
|
|
463
510
|
for (const candle of normalized) {
|
|
@@ -465,6 +512,12 @@ function normalizeCandles(candles) {
|
|
|
465
512
|
deduped.push(candle);
|
|
466
513
|
lastTime = candle.time;
|
|
467
514
|
}
|
|
515
|
+
const removedDuplicates = normalized.length - deduped.length;
|
|
516
|
+
if (reordered || removedDuplicates > 0 || duplicateCount > 0) {
|
|
517
|
+
console.warn(
|
|
518
|
+
`[tradelab] normalizeCandles() reordered or deduplicated candles (input=${candles.length}, valid=${parsed.length}, output=${deduped.length})`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
468
521
|
return deduped;
|
|
469
522
|
}
|
|
470
523
|
function loadCandlesFromCSV(filePath, options = {}) {
|
|
@@ -507,9 +560,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
|
|
|
507
560
|
const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
|
|
508
561
|
const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
|
|
509
562
|
if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
|
|
510
|
-
throw new Error(
|
|
511
|
-
`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
|
|
512
|
-
);
|
|
563
|
+
throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
|
|
513
564
|
}
|
|
514
565
|
const minTime = normalizeDateBoundary(startDate, -Infinity);
|
|
515
566
|
const maxTime = normalizeDateBoundary(endDate, Infinity);
|
|
@@ -624,18 +675,12 @@ function usDstBoundsUTC(year) {
|
|
|
624
675
|
if (sundaysSeen === 2) break;
|
|
625
676
|
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
626
677
|
}
|
|
627
|
-
const dstStart = new Date(
|
|
628
|
-
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
629
|
-
);
|
|
678
|
+
const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
|
|
630
679
|
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
631
680
|
while (novemberCursor.getUTCDay() !== 0) {
|
|
632
|
-
novemberCursor = new Date(
|
|
633
|
-
novemberCursor.getTime() + 24 * 60 * 60 * 1e3
|
|
634
|
-
);
|
|
681
|
+
novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
635
682
|
}
|
|
636
|
-
const dstEnd = new Date(
|
|
637
|
-
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
638
|
-
);
|
|
683
|
+
const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
|
|
639
684
|
return { dstStart, dstEnd };
|
|
640
685
|
}
|
|
641
686
|
function isUsEasternDST(timeMs) {
|
|
@@ -666,11 +711,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
|
|
|
666
711
|
const model = costs || {};
|
|
667
712
|
const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
|
|
668
713
|
const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
|
|
669
|
-
const effectiveSlippageBps = resolveSlippageBps(
|
|
670
|
-
kind,
|
|
671
|
-
modelSlippageBps,
|
|
672
|
-
model.slippageByKind
|
|
673
|
-
);
|
|
714
|
+
const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
|
|
674
715
|
const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
|
|
675
716
|
const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
|
|
676
717
|
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
@@ -697,14 +738,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
|
|
|
697
738
|
}
|
|
698
739
|
return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
|
|
699
740
|
}
|
|
700
|
-
function ocoExitCheck({
|
|
701
|
-
side,
|
|
702
|
-
stop,
|
|
703
|
-
tp,
|
|
704
|
-
bar,
|
|
705
|
-
mode = "intrabar",
|
|
706
|
-
tieBreak = "pessimistic"
|
|
707
|
-
}) {
|
|
741
|
+
function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
|
|
708
742
|
if (mode === "close") {
|
|
709
743
|
const close = bar.close;
|
|
710
744
|
if (side === "long") {
|
|
@@ -785,13 +819,51 @@ function strictHistoryView(candles, currentIndex) {
|
|
|
785
819
|
get(target, property, receiver) {
|
|
786
820
|
if (isArrayIndexKey(property) && Number(property) >= target.length) {
|
|
787
821
|
throw new Error(
|
|
788
|
-
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
822
|
+
`strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
|
|
789
823
|
);
|
|
790
824
|
}
|
|
791
825
|
return Reflect.get(target, property, receiver);
|
|
792
826
|
}
|
|
793
827
|
});
|
|
794
828
|
}
|
|
829
|
+
function describeValue(value) {
|
|
830
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
831
|
+
if (value === null) return "null";
|
|
832
|
+
return typeof value;
|
|
833
|
+
}
|
|
834
|
+
function formatIsoTime(time) {
|
|
835
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
836
|
+
}
|
|
837
|
+
function callSignalWithContext({ signal, context, index, bar, symbol }) {
|
|
838
|
+
try {
|
|
839
|
+
return signal(context);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
842
|
+
throw new Error(
|
|
843
|
+
`signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function snapshotOpenPosition(open, markPrice) {
|
|
848
|
+
if (!open) return null;
|
|
849
|
+
const entryPrice = open.entryFill ?? open.entry;
|
|
850
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
851
|
+
const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
|
|
852
|
+
return {
|
|
853
|
+
id: open.id,
|
|
854
|
+
symbol: open.symbol,
|
|
855
|
+
side: open.side,
|
|
856
|
+
size: open.size,
|
|
857
|
+
entry: open.entry,
|
|
858
|
+
entryFill: open.entryFill,
|
|
859
|
+
stop: open.stop,
|
|
860
|
+
takeProfit: open.takeProfit,
|
|
861
|
+
openTime: open.openTime,
|
|
862
|
+
markPrice,
|
|
863
|
+
unrealizedPnl,
|
|
864
|
+
_initRisk: open._initRisk
|
|
865
|
+
};
|
|
866
|
+
}
|
|
795
867
|
function mergeOptions(options) {
|
|
796
868
|
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
797
869
|
return {
|
|
@@ -933,10 +1005,10 @@ function backtest(rawOptions) {
|
|
|
933
1005
|
strict
|
|
934
1006
|
} = options;
|
|
935
1007
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
936
|
-
throw new Error(
|
|
1008
|
+
throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
|
|
937
1009
|
}
|
|
938
1010
|
if (typeof signal !== "function") {
|
|
939
|
-
throw new Error(
|
|
1011
|
+
throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
|
|
940
1012
|
}
|
|
941
1013
|
const closed = [];
|
|
942
1014
|
let currentEquity = equity;
|
|
@@ -1085,17 +1157,13 @@ function backtest(rawOptions) {
|
|
|
1085
1157
|
});
|
|
1086
1158
|
const size = roundStep2(rawSize, qtyStep);
|
|
1087
1159
|
if (size < minQty) return false;
|
|
1088
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
qty: size,
|
|
1096
|
-
costs
|
|
1097
|
-
}
|
|
1098
|
-
);
|
|
1160
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
|
|
1161
|
+
slippageBps,
|
|
1162
|
+
feeBps,
|
|
1163
|
+
kind: fillKind,
|
|
1164
|
+
qty: size,
|
|
1165
|
+
costs
|
|
1166
|
+
});
|
|
1099
1167
|
open = {
|
|
1100
1168
|
symbol,
|
|
1101
1169
|
...pending.meta,
|
|
@@ -1146,10 +1214,7 @@ function backtest(rawOptions) {
|
|
|
1146
1214
|
dayEquityStart = currentEquity;
|
|
1147
1215
|
}
|
|
1148
1216
|
if (open && open._maxBarsInTrade > 0) {
|
|
1149
|
-
const barsHeld = Math.max(
|
|
1150
|
-
1,
|
|
1151
|
-
Math.round((bar.time - open.openTime) / estimatedBarMs)
|
|
1152
|
-
);
|
|
1217
|
+
const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
|
|
1153
1218
|
if (barsHeld >= open._maxBarsInTrade) {
|
|
1154
1219
|
forceExit("TIME", bar);
|
|
1155
1220
|
}
|
|
@@ -1203,11 +1268,13 @@ function backtest(rawOptions) {
|
|
|
1203
1268
|
const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
|
|
1204
1269
|
if (cutQty >= minQty && cutQty < open.size) {
|
|
1205
1270
|
const exitSide2 = open.side === "long" ? "short" : "long";
|
|
1206
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1271
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
|
|
1272
|
+
slippageBps,
|
|
1273
|
+
feeBps,
|
|
1274
|
+
kind: "market",
|
|
1275
|
+
qty: cutQty,
|
|
1276
|
+
costs
|
|
1277
|
+
});
|
|
1211
1278
|
closeLeg({
|
|
1212
1279
|
openPos: open,
|
|
1213
1280
|
qty: cutQty,
|
|
@@ -1232,11 +1299,13 @@ function backtest(rawOptions) {
|
|
|
1232
1299
|
const baseSize = open.baseSize || open.initSize;
|
|
1233
1300
|
const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
|
|
1234
1301
|
if (addQty >= minQty) {
|
|
1235
|
-
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1302
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
|
|
1303
|
+
slippageBps,
|
|
1304
|
+
feeBps,
|
|
1305
|
+
kind: "limit",
|
|
1306
|
+
qty: addQty,
|
|
1307
|
+
costs
|
|
1308
|
+
});
|
|
1240
1309
|
const newSize = open.size + addQty;
|
|
1241
1310
|
open.entryFeeTotal += addFeeTotal;
|
|
1242
1311
|
open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
|
|
@@ -1314,15 +1383,10 @@ function backtest(rawOptions) {
|
|
|
1314
1383
|
if (!open && pending) {
|
|
1315
1384
|
if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
1316
1385
|
if (entryChase.enabled && entryChase.convertOnExpiry) {
|
|
1317
|
-
const riskAtEdge = Math.abs(
|
|
1318
|
-
pending.meta._initRisk ?? pending.entry - pending.stop
|
|
1319
|
-
);
|
|
1386
|
+
const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
|
|
1320
1387
|
const priceNow = bar.close;
|
|
1321
1388
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1322
|
-
const slippedR = Math.max(
|
|
1323
|
-
0,
|
|
1324
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1325
|
-
) / Math.max(1e-8, riskAtEdge);
|
|
1389
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
|
|
1326
1390
|
if (slippedR > maxSlipROnFill) {
|
|
1327
1391
|
pending = null;
|
|
1328
1392
|
} else if (!openFromPending(bar, index, priceNow, "market")) {
|
|
@@ -1337,21 +1401,16 @@ function backtest(rawOptions) {
|
|
|
1337
1401
|
}
|
|
1338
1402
|
} else if (entryChase.enabled) {
|
|
1339
1403
|
const elapsedBars = index - (pending.startedAtIndex ?? index);
|
|
1340
|
-
const midpoint = pending.meta?._imb?.mid;
|
|
1341
|
-
if (!pending._chasedCE && midpoint !==
|
|
1404
|
+
const midpoint = asNumber(pending.meta?._imb?.mid);
|
|
1405
|
+
if (!pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, entryChase.afterBars)) {
|
|
1342
1406
|
pending.entry = midpoint;
|
|
1343
1407
|
pending._chasedCE = true;
|
|
1344
1408
|
}
|
|
1345
1409
|
if (pending._chasedCE) {
|
|
1346
|
-
const riskRef = Math.abs(
|
|
1347
|
-
pending.meta?._initRisk ?? pending.entry - pending.stop
|
|
1348
|
-
);
|
|
1410
|
+
const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
|
|
1349
1411
|
const priceNow = bar.close;
|
|
1350
1412
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1351
|
-
const slippedR = Math.max(
|
|
1352
|
-
0,
|
|
1353
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1354
|
-
) / Math.max(1e-8, riskRef);
|
|
1413
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
|
|
1355
1414
|
if (slippedR > maxSlipROnFill) {
|
|
1356
1415
|
pending = null;
|
|
1357
1416
|
} else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
|
|
@@ -1379,13 +1438,19 @@ function backtest(rawOptions) {
|
|
|
1379
1438
|
);
|
|
1380
1439
|
}
|
|
1381
1440
|
const signalCandles = strict ? strictHistoryView(history, index) : history;
|
|
1382
|
-
const rawSignal =
|
|
1383
|
-
|
|
1441
|
+
const rawSignal = callSignalWithContext({
|
|
1442
|
+
signal,
|
|
1443
|
+
context: {
|
|
1444
|
+
candles: signalCandles,
|
|
1445
|
+
index,
|
|
1446
|
+
bar,
|
|
1447
|
+
equity: currentEquity,
|
|
1448
|
+
openPosition: open,
|
|
1449
|
+
pendingOrder: pending
|
|
1450
|
+
},
|
|
1384
1451
|
index,
|
|
1385
1452
|
bar,
|
|
1386
|
-
|
|
1387
|
-
openPosition: open,
|
|
1388
|
-
pendingOrder: pending
|
|
1453
|
+
symbol
|
|
1389
1454
|
});
|
|
1390
1455
|
const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
|
|
1391
1456
|
if (nextSignal) {
|
|
@@ -1401,9 +1466,7 @@ function backtest(rawOptions) {
|
|
|
1401
1466
|
expiresAt: index + Math.max(1, expiryBars),
|
|
1402
1467
|
startedAtIndex: index,
|
|
1403
1468
|
meta: nextSignal,
|
|
1404
|
-
plannedRiskAbs: Math.abs(
|
|
1405
|
-
nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
|
|
1406
|
-
)
|
|
1469
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
1407
1470
|
};
|
|
1408
1471
|
if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
|
|
1409
1472
|
if (!openFromPending(bar, index, pending.entry, "limit")) {
|
|
@@ -1423,12 +1486,15 @@ function backtest(rawOptions) {
|
|
|
1423
1486
|
eqSeries
|
|
1424
1487
|
});
|
|
1425
1488
|
const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
1489
|
+
const lastPrice = asNumber(candles[candles.length - 1]?.close);
|
|
1490
|
+
const openPositions = open ? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)] : [];
|
|
1426
1491
|
return {
|
|
1427
1492
|
symbol: options.symbol,
|
|
1428
1493
|
interval: options.interval,
|
|
1429
1494
|
range: options.range,
|
|
1430
1495
|
trades: closed,
|
|
1431
1496
|
positions,
|
|
1497
|
+
openPositions,
|
|
1432
1498
|
metrics,
|
|
1433
1499
|
eqSeries,
|
|
1434
1500
|
replay: {
|
|
@@ -1441,7 +1507,6 @@ function backtest(rawOptions) {
|
|
|
1441
1507
|
// src/data/yahoo.js
|
|
1442
1508
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1443
1509
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1444
|
-
var DAY_SEC = 24 * 60 * 60;
|
|
1445
1510
|
var requestQueue = {
|
|
1446
1511
|
lastRequestAt: 0,
|
|
1447
1512
|
minDelayMs: 400
|
|
@@ -1596,13 +1661,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
|
|
|
1596
1661
|
}
|
|
1597
1662
|
}
|
|
1598
1663
|
throw new Error(
|
|
1599
|
-
formatYahooFailureMessage(
|
|
1600
|
-
symbol,
|
|
1601
|
-
params.interval,
|
|
1602
|
-
period,
|
|
1603
|
-
lastError,
|
|
1604
|
-
maxRetries
|
|
1605
|
-
)
|
|
1664
|
+
formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
|
|
1606
1665
|
);
|
|
1607
1666
|
}
|
|
1608
1667
|
async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
|
|
@@ -1642,9 +1701,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
|
|
|
1642
1701
|
period
|
|
1643
1702
|
);
|
|
1644
1703
|
chunks.push(...candles);
|
|
1645
|
-
chunkEndMs = chunkStartMs - 1e3;
|
|
1646
1704
|
remainingMs -= takeMs;
|
|
1647
|
-
|
|
1705
|
+
chunkEndMs = chunkStartMs - 1e3;
|
|
1706
|
+
if (chunkEndMs <= 0 || chunks.length > 2e6) break;
|
|
1648
1707
|
}
|
|
1649
1708
|
return sanitizeBars(chunks);
|
|
1650
1709
|
}
|
|
@@ -1720,11 +1779,7 @@ async function getHistoricalCandles(options = {}) {
|
|
|
1720
1779
|
}
|
|
1721
1780
|
return candles;
|
|
1722
1781
|
}
|
|
1723
|
-
async function backtestHistorical({
|
|
1724
|
-
backtestOptions = {},
|
|
1725
|
-
data,
|
|
1726
|
-
...legacy
|
|
1727
|
-
} = {}) {
|
|
1782
|
+
async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
|
|
1728
1783
|
const candles = await getHistoricalCandles(data || legacy);
|
|
1729
1784
|
return backtest({
|
|
1730
1785
|
candles,
|