tradelab 0.5.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 +89 -41
- package/bin/tradelab.js +276 -30
- package/dist/cjs/data.cjs +134 -104
- package/dist/cjs/index.cjs +378 -177
- package/dist/cjs/live.cjs +3350 -0
- package/docs/README.md +21 -9
- package/docs/api-reference.md +87 -29
- package/docs/backtest-engine.md +37 -53
- package/docs/data-reporting-cli.md +60 -34
- package/docs/examples.md +6 -12
- 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 +89 -37
- package/src/engine/barSystemRunner.js +182 -118
- package/src/engine/execution.js +11 -39
- package/src/engine/portfolio.js +54 -6
- package/src/engine/walkForward.js +37 -14
- package/src/index.js +2 -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 +18 -41
- 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 +21 -3
- package/types/live.d.ts +382 -0
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
@@ -189,6 +190,14 @@ var pct = (a, b) => (a - b) / b;
|
|
|
189
190
|
function roundStep(value, step) {
|
|
190
191
|
return Math.floor(value / step) * step;
|
|
191
192
|
}
|
|
193
|
+
var warnedNonPositiveEquity = false;
|
|
194
|
+
function warnNonPositiveEquity(equity) {
|
|
195
|
+
if (warnedNonPositiveEquity) return;
|
|
196
|
+
warnedNonPositiveEquity = true;
|
|
197
|
+
console.warn(
|
|
198
|
+
`[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
192
201
|
function calculatePositionSize({
|
|
193
202
|
equity,
|
|
194
203
|
entry,
|
|
@@ -198,6 +207,10 @@ function calculatePositionSize({
|
|
|
198
207
|
minQty = 1e-3,
|
|
199
208
|
maxLeverage = 2
|
|
200
209
|
}) {
|
|
210
|
+
if (!Number.isFinite(equity) || equity <= 0) {
|
|
211
|
+
warnNonPositiveEquity(equity);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
201
214
|
const riskPerUnit = Math.abs(entry - stop);
|
|
202
215
|
if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
|
|
203
216
|
const maxRiskDollars = Math.max(0, equity * riskFraction);
|
|
@@ -309,14 +322,14 @@ function percentile(values, percentileRank) {
|
|
|
309
322
|
const index = Math.floor((sorted.length - 1) * percentileRank);
|
|
310
323
|
return sorted[index];
|
|
311
324
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}) {
|
|
325
|
+
var PROFIT_FACTOR_CAP = 1e6;
|
|
326
|
+
function finiteProfitFactor(grossProfit, grossLoss) {
|
|
327
|
+
if (grossLoss === 0) {
|
|
328
|
+
return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
|
|
329
|
+
}
|
|
330
|
+
return grossProfit / grossLoss;
|
|
331
|
+
}
|
|
332
|
+
function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
|
|
320
333
|
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
321
334
|
const completedTrades = [];
|
|
322
335
|
const tradeRs = [];
|
|
@@ -392,8 +405,8 @@ function buildMetrics({
|
|
|
392
405
|
const tradeReturnStd = stddev(tradeReturns);
|
|
393
406
|
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
394
407
|
const sortinoPerTrade = sortino(tradeReturns);
|
|
395
|
-
const profitFactorPositions =
|
|
396
|
-
const profitFactorLegs =
|
|
408
|
+
const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
|
|
409
|
+
const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
|
|
397
410
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
398
411
|
const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
|
|
399
412
|
const totalBars = Math.max(1, candles.length);
|
|
@@ -562,7 +575,7 @@ function normalizeDateBoundary(value, fallback) {
|
|
|
562
575
|
}
|
|
563
576
|
function normalizeCandles(candles) {
|
|
564
577
|
if (!Array.isArray(candles)) return [];
|
|
565
|
-
const
|
|
578
|
+
const parsed = candles.map((bar) => {
|
|
566
579
|
try {
|
|
567
580
|
const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
|
|
568
581
|
const open = Number(bar?.open ?? bar?.o);
|
|
@@ -584,7 +597,16 @@ function normalizeCandles(candles) {
|
|
|
584
597
|
} catch {
|
|
585
598
|
return null;
|
|
586
599
|
}
|
|
587
|
-
}).filter(Boolean)
|
|
600
|
+
}).filter(Boolean);
|
|
601
|
+
let reordered = false;
|
|
602
|
+
let duplicateCount = 0;
|
|
603
|
+
for (let index = 1; index < parsed.length; index += 1) {
|
|
604
|
+
const prev = parsed[index - 1].time;
|
|
605
|
+
const current = parsed[index].time;
|
|
606
|
+
if (current < prev) reordered = true;
|
|
607
|
+
if (current === prev) duplicateCount += 1;
|
|
608
|
+
}
|
|
609
|
+
const normalized = parsed.sort((left, right) => left.time - right.time);
|
|
588
610
|
const deduped = [];
|
|
589
611
|
let lastTime = null;
|
|
590
612
|
for (const candle of normalized) {
|
|
@@ -592,6 +614,12 @@ function normalizeCandles(candles) {
|
|
|
592
614
|
deduped.push(candle);
|
|
593
615
|
lastTime = candle.time;
|
|
594
616
|
}
|
|
617
|
+
const removedDuplicates = normalized.length - deduped.length;
|
|
618
|
+
if (reordered || removedDuplicates > 0 || duplicateCount > 0) {
|
|
619
|
+
console.warn(
|
|
620
|
+
`[tradelab] normalizeCandles() reordered or deduplicated candles (input=${candles.length}, valid=${parsed.length}, output=${deduped.length})`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
595
623
|
return deduped;
|
|
596
624
|
}
|
|
597
625
|
function loadCandlesFromCSV(filePath, options = {}) {
|
|
@@ -634,9 +662,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
|
|
|
634
662
|
const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
|
|
635
663
|
const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
|
|
636
664
|
if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
|
|
637
|
-
throw new Error(
|
|
638
|
-
`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
|
|
639
|
-
);
|
|
665
|
+
throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
|
|
640
666
|
}
|
|
641
667
|
const minTime = normalizeDateBoundary(startDate, -Infinity);
|
|
642
668
|
const maxTime = normalizeDateBoundary(endDate, Infinity);
|
|
@@ -751,18 +777,12 @@ function usDstBoundsUTC(year) {
|
|
|
751
777
|
if (sundaysSeen === 2) break;
|
|
752
778
|
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
753
779
|
}
|
|
754
|
-
const dstStart = new Date(
|
|
755
|
-
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
756
|
-
);
|
|
780
|
+
const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
|
|
757
781
|
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
758
782
|
while (novemberCursor.getUTCDay() !== 0) {
|
|
759
|
-
novemberCursor = new Date(
|
|
760
|
-
novemberCursor.getTime() + 24 * 60 * 60 * 1e3
|
|
761
|
-
);
|
|
783
|
+
novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
762
784
|
}
|
|
763
|
-
const dstEnd = new Date(
|
|
764
|
-
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
765
|
-
);
|
|
785
|
+
const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
|
|
766
786
|
return { dstStart, dstEnd };
|
|
767
787
|
}
|
|
768
788
|
function isUsEasternDST(timeMs) {
|
|
@@ -830,11 +850,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
|
|
|
830
850
|
const model = costs || {};
|
|
831
851
|
const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
|
|
832
852
|
const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
|
|
833
|
-
const effectiveSlippageBps = resolveSlippageBps(
|
|
834
|
-
kind,
|
|
835
|
-
modelSlippageBps,
|
|
836
|
-
model.slippageByKind
|
|
837
|
-
);
|
|
853
|
+
const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
|
|
838
854
|
const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
|
|
839
855
|
const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
|
|
840
856
|
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
@@ -861,14 +877,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
|
|
|
861
877
|
}
|
|
862
878
|
return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
|
|
863
879
|
}
|
|
864
|
-
function ocoExitCheck({
|
|
865
|
-
side,
|
|
866
|
-
stop,
|
|
867
|
-
tp,
|
|
868
|
-
bar,
|
|
869
|
-
mode = "intrabar",
|
|
870
|
-
tieBreak = "pessimistic"
|
|
871
|
-
}) {
|
|
880
|
+
function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
|
|
872
881
|
if (mode === "close") {
|
|
873
882
|
const close = bar.close;
|
|
874
883
|
if (side === "long") {
|
|
@@ -949,13 +958,51 @@ function strictHistoryView(candles, currentIndex) {
|
|
|
949
958
|
get(target, property, receiver) {
|
|
950
959
|
if (isArrayIndexKey(property) && Number(property) >= target.length) {
|
|
951
960
|
throw new Error(
|
|
952
|
-
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
961
|
+
`strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
|
|
953
962
|
);
|
|
954
963
|
}
|
|
955
964
|
return Reflect.get(target, property, receiver);
|
|
956
965
|
}
|
|
957
966
|
});
|
|
958
967
|
}
|
|
968
|
+
function describeValue(value) {
|
|
969
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
970
|
+
if (value === null) return "null";
|
|
971
|
+
return typeof value;
|
|
972
|
+
}
|
|
973
|
+
function formatIsoTime(time) {
|
|
974
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
975
|
+
}
|
|
976
|
+
function callSignalWithContext({ signal, context, index, bar, symbol }) {
|
|
977
|
+
try {
|
|
978
|
+
return signal(context);
|
|
979
|
+
} catch (error) {
|
|
980
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
981
|
+
throw new Error(
|
|
982
|
+
`signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function snapshotOpenPosition(open, markPrice) {
|
|
987
|
+
if (!open) return null;
|
|
988
|
+
const entryPrice = open.entryFill ?? open.entry;
|
|
989
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
990
|
+
const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
|
|
991
|
+
return {
|
|
992
|
+
id: open.id,
|
|
993
|
+
symbol: open.symbol,
|
|
994
|
+
side: open.side,
|
|
995
|
+
size: open.size,
|
|
996
|
+
entry: open.entry,
|
|
997
|
+
entryFill: open.entryFill,
|
|
998
|
+
stop: open.stop,
|
|
999
|
+
takeProfit: open.takeProfit,
|
|
1000
|
+
openTime: open.openTime,
|
|
1001
|
+
markPrice,
|
|
1002
|
+
unrealizedPnl,
|
|
1003
|
+
_initRisk: open._initRisk
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
959
1006
|
function mergeOptions(options) {
|
|
960
1007
|
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
961
1008
|
return {
|
|
@@ -1097,10 +1144,10 @@ function backtest(rawOptions) {
|
|
|
1097
1144
|
strict
|
|
1098
1145
|
} = options;
|
|
1099
1146
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
1100
|
-
throw new Error(
|
|
1147
|
+
throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
|
|
1101
1148
|
}
|
|
1102
1149
|
if (typeof signal !== "function") {
|
|
1103
|
-
throw new Error(
|
|
1150
|
+
throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
|
|
1104
1151
|
}
|
|
1105
1152
|
const closed = [];
|
|
1106
1153
|
let currentEquity = equity;
|
|
@@ -1249,17 +1296,13 @@ function backtest(rawOptions) {
|
|
|
1249
1296
|
});
|
|
1250
1297
|
const size = roundStep2(rawSize, qtyStep);
|
|
1251
1298
|
if (size < minQty) return false;
|
|
1252
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
qty: size,
|
|
1260
|
-
costs
|
|
1261
|
-
}
|
|
1262
|
-
);
|
|
1299
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
|
|
1300
|
+
slippageBps,
|
|
1301
|
+
feeBps,
|
|
1302
|
+
kind: fillKind,
|
|
1303
|
+
qty: size,
|
|
1304
|
+
costs
|
|
1305
|
+
});
|
|
1263
1306
|
open = {
|
|
1264
1307
|
symbol,
|
|
1265
1308
|
...pending.meta,
|
|
@@ -1310,10 +1353,7 @@ function backtest(rawOptions) {
|
|
|
1310
1353
|
dayEquityStart = currentEquity;
|
|
1311
1354
|
}
|
|
1312
1355
|
if (open && open._maxBarsInTrade > 0) {
|
|
1313
|
-
const barsHeld = Math.max(
|
|
1314
|
-
1,
|
|
1315
|
-
Math.round((bar.time - open.openTime) / estimatedBarMs)
|
|
1316
|
-
);
|
|
1356
|
+
const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
|
|
1317
1357
|
if (barsHeld >= open._maxBarsInTrade) {
|
|
1318
1358
|
forceExit("TIME", bar);
|
|
1319
1359
|
}
|
|
@@ -1367,11 +1407,13 @@ function backtest(rawOptions) {
|
|
|
1367
1407
|
const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
|
|
1368
1408
|
if (cutQty >= minQty && cutQty < open.size) {
|
|
1369
1409
|
const exitSide2 = open.side === "long" ? "short" : "long";
|
|
1370
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1410
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
|
|
1411
|
+
slippageBps,
|
|
1412
|
+
feeBps,
|
|
1413
|
+
kind: "market",
|
|
1414
|
+
qty: cutQty,
|
|
1415
|
+
costs
|
|
1416
|
+
});
|
|
1375
1417
|
closeLeg({
|
|
1376
1418
|
openPos: open,
|
|
1377
1419
|
qty: cutQty,
|
|
@@ -1396,11 +1438,13 @@ function backtest(rawOptions) {
|
|
|
1396
1438
|
const baseSize = open.baseSize || open.initSize;
|
|
1397
1439
|
const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
|
|
1398
1440
|
if (addQty >= minQty) {
|
|
1399
|
-
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1441
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
|
|
1442
|
+
slippageBps,
|
|
1443
|
+
feeBps,
|
|
1444
|
+
kind: "limit",
|
|
1445
|
+
qty: addQty,
|
|
1446
|
+
costs
|
|
1447
|
+
});
|
|
1404
1448
|
const newSize = open.size + addQty;
|
|
1405
1449
|
open.entryFeeTotal += addFeeTotal;
|
|
1406
1450
|
open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
|
|
@@ -1478,15 +1522,10 @@ function backtest(rawOptions) {
|
|
|
1478
1522
|
if (!open && pending) {
|
|
1479
1523
|
if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
1480
1524
|
if (entryChase.enabled && entryChase.convertOnExpiry) {
|
|
1481
|
-
const riskAtEdge = Math.abs(
|
|
1482
|
-
pending.meta._initRisk ?? pending.entry - pending.stop
|
|
1483
|
-
);
|
|
1525
|
+
const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
|
|
1484
1526
|
const priceNow = bar.close;
|
|
1485
1527
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1486
|
-
const slippedR = Math.max(
|
|
1487
|
-
0,
|
|
1488
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1489
|
-
) / Math.max(1e-8, riskAtEdge);
|
|
1528
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
|
|
1490
1529
|
if (slippedR > maxSlipROnFill) {
|
|
1491
1530
|
pending = null;
|
|
1492
1531
|
} else if (!openFromPending(bar, index, priceNow, "market")) {
|
|
@@ -1501,21 +1540,16 @@ function backtest(rawOptions) {
|
|
|
1501
1540
|
}
|
|
1502
1541
|
} else if (entryChase.enabled) {
|
|
1503
1542
|
const elapsedBars = index - (pending.startedAtIndex ?? index);
|
|
1504
|
-
const midpoint = pending.meta?._imb?.mid;
|
|
1505
|
-
if (!pending._chasedCE && midpoint !==
|
|
1543
|
+
const midpoint = asNumber(pending.meta?._imb?.mid);
|
|
1544
|
+
if (!pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, entryChase.afterBars)) {
|
|
1506
1545
|
pending.entry = midpoint;
|
|
1507
1546
|
pending._chasedCE = true;
|
|
1508
1547
|
}
|
|
1509
1548
|
if (pending._chasedCE) {
|
|
1510
|
-
const riskRef = Math.abs(
|
|
1511
|
-
pending.meta?._initRisk ?? pending.entry - pending.stop
|
|
1512
|
-
);
|
|
1549
|
+
const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
|
|
1513
1550
|
const priceNow = bar.close;
|
|
1514
1551
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1515
|
-
const slippedR = Math.max(
|
|
1516
|
-
0,
|
|
1517
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1518
|
-
) / Math.max(1e-8, riskRef);
|
|
1552
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
|
|
1519
1553
|
if (slippedR > maxSlipROnFill) {
|
|
1520
1554
|
pending = null;
|
|
1521
1555
|
} else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
|
|
@@ -1543,13 +1577,19 @@ function backtest(rawOptions) {
|
|
|
1543
1577
|
);
|
|
1544
1578
|
}
|
|
1545
1579
|
const signalCandles = strict ? strictHistoryView(history, index) : history;
|
|
1546
|
-
const rawSignal =
|
|
1547
|
-
|
|
1580
|
+
const rawSignal = callSignalWithContext({
|
|
1581
|
+
signal,
|
|
1582
|
+
context: {
|
|
1583
|
+
candles: signalCandles,
|
|
1584
|
+
index,
|
|
1585
|
+
bar,
|
|
1586
|
+
equity: currentEquity,
|
|
1587
|
+
openPosition: open,
|
|
1588
|
+
pendingOrder: pending
|
|
1589
|
+
},
|
|
1548
1590
|
index,
|
|
1549
1591
|
bar,
|
|
1550
|
-
|
|
1551
|
-
openPosition: open,
|
|
1552
|
-
pendingOrder: pending
|
|
1592
|
+
symbol
|
|
1553
1593
|
});
|
|
1554
1594
|
const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
|
|
1555
1595
|
if (nextSignal) {
|
|
@@ -1565,9 +1605,7 @@ function backtest(rawOptions) {
|
|
|
1565
1605
|
expiresAt: index + Math.max(1, expiryBars),
|
|
1566
1606
|
startedAtIndex: index,
|
|
1567
1607
|
meta: nextSignal,
|
|
1568
|
-
plannedRiskAbs: Math.abs(
|
|
1569
|
-
nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
|
|
1570
|
-
)
|
|
1608
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
1571
1609
|
};
|
|
1572
1610
|
if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
|
|
1573
1611
|
if (!openFromPending(bar, index, pending.entry, "limit")) {
|
|
@@ -1587,12 +1625,15 @@ function backtest(rawOptions) {
|
|
|
1587
1625
|
eqSeries
|
|
1588
1626
|
});
|
|
1589
1627
|
const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
1628
|
+
const lastPrice = asNumber(candles[candles.length - 1]?.close);
|
|
1629
|
+
const openPositions = open ? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)] : [];
|
|
1590
1630
|
return {
|
|
1591
1631
|
symbol: options.symbol,
|
|
1592
1632
|
interval: options.interval,
|
|
1593
1633
|
range: options.range,
|
|
1594
1634
|
trades: closed,
|
|
1595
1635
|
positions,
|
|
1636
|
+
openPositions,
|
|
1596
1637
|
metrics,
|
|
1597
1638
|
eqSeries,
|
|
1598
1639
|
replay: {
|
|
@@ -1607,6 +1648,24 @@ function asNumber2(value) {
|
|
|
1607
1648
|
const numeric = Number(value);
|
|
1608
1649
|
return Number.isFinite(numeric) ? numeric : null;
|
|
1609
1650
|
}
|
|
1651
|
+
function describeValue2(value) {
|
|
1652
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
1653
|
+
if (value === null) return "null";
|
|
1654
|
+
return typeof value;
|
|
1655
|
+
}
|
|
1656
|
+
function formatIsoTime2(time) {
|
|
1657
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
1658
|
+
}
|
|
1659
|
+
function callSignalWithContext2({ signal, context, index, bar, symbol }) {
|
|
1660
|
+
try {
|
|
1661
|
+
return signal(context);
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
1664
|
+
throw new Error(
|
|
1665
|
+
`signal() threw at index=${index}, time=${formatIsoTime2(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1610
1669
|
function normalizeSide2(value) {
|
|
1611
1670
|
if (value === "long" || value === "buy") return "long";
|
|
1612
1671
|
if (value === "short" || value === "sell") return "short";
|
|
@@ -1666,16 +1725,36 @@ function normalizeSignal2(signal, bar, fallbackR) {
|
|
|
1666
1725
|
function equityPoint2(time, equity) {
|
|
1667
1726
|
return { time, timestamp: time, equity };
|
|
1668
1727
|
}
|
|
1728
|
+
function xmur3(seed) {
|
|
1729
|
+
let hash = 1779033703 ^ seed.length;
|
|
1730
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
1731
|
+
hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353);
|
|
1732
|
+
hash = hash << 13 | hash >>> 19;
|
|
1733
|
+
}
|
|
1734
|
+
return () => {
|
|
1735
|
+
hash = Math.imul(hash ^ hash >>> 16, 2246822507);
|
|
1736
|
+
hash = Math.imul(hash ^ hash >>> 13, 3266489909);
|
|
1737
|
+
return (hash ^= hash >>> 16) >>> 0;
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
function mulberry32(seed) {
|
|
1741
|
+
let state = seed >>> 0;
|
|
1742
|
+
return () => {
|
|
1743
|
+
state = state + 1831565813 >>> 0;
|
|
1744
|
+
let value = Math.imul(state ^ state >>> 15, state | 1);
|
|
1745
|
+
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
|
|
1746
|
+
return ((value ^ value >>> 14) >>> 0) / 4294967296;
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
function seededUnitInterval(seedParts) {
|
|
1750
|
+
const seed = seedParts.map((part) => String(part)).join("|");
|
|
1751
|
+
const seedFn = xmur3(seed);
|
|
1752
|
+
return mulberry32(seedFn())();
|
|
1753
|
+
}
|
|
1669
1754
|
function deterministicFill(probability, seedParts) {
|
|
1670
1755
|
if (probability >= 1) return true;
|
|
1671
1756
|
if (probability <= 0) return false;
|
|
1672
|
-
|
|
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;
|
|
1757
|
+
const normalized = seededUnitInterval(seedParts);
|
|
1679
1758
|
return normalized <= probability;
|
|
1680
1759
|
}
|
|
1681
1760
|
function backtestTicks({
|
|
@@ -1701,14 +1780,18 @@ function backtestTicks({
|
|
|
1701
1780
|
oco = {}
|
|
1702
1781
|
} = {}) {
|
|
1703
1782
|
if (!Array.isArray(ticks) || ticks.length === 0) {
|
|
1704
|
-
throw new Error(
|
|
1783
|
+
throw new Error(
|
|
1784
|
+
`backtestTicks() requires a non-empty ticks array, got ${describeValue2(ticks)}`
|
|
1785
|
+
);
|
|
1705
1786
|
}
|
|
1706
1787
|
if (typeof signal !== "function") {
|
|
1707
|
-
throw new Error(
|
|
1788
|
+
throw new Error(`backtestTicks() requires a signal function, got ${describeValue2(signal)}`);
|
|
1708
1789
|
}
|
|
1709
1790
|
const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
|
|
1710
1791
|
if (!normalizedTicks.length) {
|
|
1711
|
-
throw new Error(
|
|
1792
|
+
throw new Error(
|
|
1793
|
+
`backtestTicks() could not normalize any ticks from ${ticks.length} input rows`
|
|
1794
|
+
);
|
|
1712
1795
|
}
|
|
1713
1796
|
const ocoOptions = {
|
|
1714
1797
|
mode: "intrabar",
|
|
@@ -1917,13 +2000,19 @@ function backtestTicks({
|
|
|
1917
2000
|
const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
|
|
1918
2001
|
if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
|
|
1919
2002
|
const nextSignal = normalizeSignal2(
|
|
1920
|
-
|
|
1921
|
-
|
|
2003
|
+
callSignalWithContext2({
|
|
2004
|
+
signal,
|
|
2005
|
+
context: {
|
|
2006
|
+
candles: history,
|
|
2007
|
+
index,
|
|
2008
|
+
bar: tick,
|
|
2009
|
+
equity: markedEquity(tick),
|
|
2010
|
+
openPosition: open,
|
|
2011
|
+
pendingOrder: pending
|
|
2012
|
+
},
|
|
1922
2013
|
index,
|
|
1923
2014
|
bar: tick,
|
|
1924
|
-
|
|
1925
|
-
openPosition: open,
|
|
1926
|
-
pendingOrder: pending
|
|
2015
|
+
symbol
|
|
1927
2016
|
}),
|
|
1928
2017
|
tick,
|
|
1929
2018
|
finalTP_R
|
|
@@ -1963,6 +2052,7 @@ function backtestTicks({
|
|
|
1963
2052
|
range,
|
|
1964
2053
|
trades,
|
|
1965
2054
|
positions,
|
|
2055
|
+
openPositions: [],
|
|
1966
2056
|
metrics,
|
|
1967
2057
|
eqSeries,
|
|
1968
2058
|
replay: {
|
|
@@ -1990,13 +2080,51 @@ function strictHistoryView2(candles, currentIndex) {
|
|
|
1990
2080
|
get(target, property, receiver) {
|
|
1991
2081
|
if (isArrayIndexKey2(property) && Number(property) >= target.length) {
|
|
1992
2082
|
throw new Error(
|
|
1993
|
-
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
2083
|
+
`strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
|
|
1994
2084
|
);
|
|
1995
2085
|
}
|
|
1996
2086
|
return Reflect.get(target, property, receiver);
|
|
1997
2087
|
}
|
|
1998
2088
|
});
|
|
1999
2089
|
}
|
|
2090
|
+
function describeValue3(value) {
|
|
2091
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
2092
|
+
if (value === null) return "null";
|
|
2093
|
+
return typeof value;
|
|
2094
|
+
}
|
|
2095
|
+
function formatIsoTime3(time) {
|
|
2096
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
2097
|
+
}
|
|
2098
|
+
function callSignalWithContext3({ signal, context, index, bar, symbol }) {
|
|
2099
|
+
try {
|
|
2100
|
+
return signal(context);
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
2103
|
+
throw new Error(
|
|
2104
|
+
`signal() threw at index=${index}, time=${formatIsoTime3(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
function snapshotOpenPosition2(open, markPrice) {
|
|
2109
|
+
if (!open) return null;
|
|
2110
|
+
const entryPrice = open.entryFill ?? open.entry;
|
|
2111
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
2112
|
+
const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
|
|
2113
|
+
return {
|
|
2114
|
+
id: open.id,
|
|
2115
|
+
symbol: open.symbol,
|
|
2116
|
+
side: open.side,
|
|
2117
|
+
size: open.size,
|
|
2118
|
+
entry: open.entry,
|
|
2119
|
+
entryFill: open.entryFill,
|
|
2120
|
+
stop: open.stop,
|
|
2121
|
+
takeProfit: open.takeProfit,
|
|
2122
|
+
openTime: open.openTime,
|
|
2123
|
+
markPrice,
|
|
2124
|
+
unrealizedPnl,
|
|
2125
|
+
_initRisk: open._initRisk
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2000
2128
|
function normalizeSide3(value) {
|
|
2001
2129
|
if (value === "long" || value === "buy") return "long";
|
|
2002
2130
|
if (value === "short" || value === "sell") return "short";
|
|
@@ -2110,10 +2238,18 @@ var BarSystemRunner = class {
|
|
|
2110
2238
|
this.options = mergeOptions2(rawOptions);
|
|
2111
2239
|
const { candles, signal } = this.options;
|
|
2112
2240
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
2113
|
-
throw new Error(
|
|
2241
|
+
throw new Error(
|
|
2242
|
+
`backtestPortfolio() requires each system to include non-empty candles, got ${describeValue3(
|
|
2243
|
+
candles
|
|
2244
|
+
)} for ${this.options.symbol}`
|
|
2245
|
+
);
|
|
2114
2246
|
}
|
|
2115
2247
|
if (typeof signal !== "function") {
|
|
2116
|
-
throw new Error(
|
|
2248
|
+
throw new Error(
|
|
2249
|
+
`backtestPortfolio() requires each system to include a signal function, got ${describeValue3(
|
|
2250
|
+
signal
|
|
2251
|
+
)} for ${this.options.symbol}`
|
|
2252
|
+
);
|
|
2117
2253
|
}
|
|
2118
2254
|
this.symbol = this.options.symbol;
|
|
2119
2255
|
this.candles = candles;
|
|
@@ -2149,7 +2285,11 @@ var BarSystemRunner = class {
|
|
|
2149
2285
|
}
|
|
2150
2286
|
getLockedCapital() {
|
|
2151
2287
|
if (!this.open) return 0;
|
|
2152
|
-
return capitalForSize(
|
|
2288
|
+
return capitalForSize(
|
|
2289
|
+
this.open.entryFill ?? this.open.entry,
|
|
2290
|
+
this.open.size,
|
|
2291
|
+
this.options.maxLeverage
|
|
2292
|
+
);
|
|
2153
2293
|
}
|
|
2154
2294
|
getMarkPrice() {
|
|
2155
2295
|
return this.lastBar?.close ?? null;
|
|
@@ -2297,17 +2437,13 @@ var BarSystemRunner = class {
|
|
|
2297
2437
|
}) : desiredSize;
|
|
2298
2438
|
const size = roundStep2(approvedSize, this.options.qtyStep);
|
|
2299
2439
|
if (size < this.options.minQty) return false;
|
|
2300
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
2301
|
-
|
|
2302
|
-
this.
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
qty: size,
|
|
2308
|
-
costs: this.options.costs
|
|
2309
|
-
}
|
|
2310
|
-
);
|
|
2440
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, this.pending.side, {
|
|
2441
|
+
slippageBps: this.options.slippageBps,
|
|
2442
|
+
feeBps: this.options.feeBps,
|
|
2443
|
+
kind: fillKind,
|
|
2444
|
+
qty: size,
|
|
2445
|
+
costs: this.options.costs
|
|
2446
|
+
});
|
|
2311
2447
|
this.open = {
|
|
2312
2448
|
symbol: this.symbol,
|
|
2313
2449
|
...this.pending.meta,
|
|
@@ -2413,10 +2549,7 @@ var BarSystemRunner = class {
|
|
|
2413
2549
|
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
2414
2550
|
}
|
|
2415
2551
|
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
|
-
);
|
|
2552
|
+
const targetR = Math.max(0, this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR));
|
|
2420
2553
|
const candidate = this.open.side === "long" ? this.open.entry + targetR * risk : this.open.entry - targetR * risk;
|
|
2421
2554
|
const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
|
|
2422
2555
|
this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
|
|
@@ -2431,7 +2564,10 @@ var BarSystemRunner = class {
|
|
|
2431
2564
|
const ratio = this.atrValues[this.index] / Math.max(1e-12, this.open.entryATR);
|
|
2432
2565
|
const shouldCut = ratio >= this.options.volScale.cutIfAtrX && markR < this.options.volScale.noCutAboveR && !this.open._volCutDone;
|
|
2433
2566
|
if (shouldCut) {
|
|
2434
|
-
const cutQty = roundStep2(
|
|
2567
|
+
const cutQty = roundStep2(
|
|
2568
|
+
this.open.size * this.options.volScale.cutFrac,
|
|
2569
|
+
this.options.qtyStep
|
|
2570
|
+
);
|
|
2435
2571
|
if (cutQty >= this.options.minQty && cutQty < this.open.size) {
|
|
2436
2572
|
const exitSide2 = this.open.side === "long" ? "short" : "long";
|
|
2437
2573
|
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
|
|
@@ -2463,7 +2599,10 @@ var BarSystemRunner = class {
|
|
|
2463
2599
|
const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
2464
2600
|
if (breakEvenSatisfied && touched) {
|
|
2465
2601
|
const baseSize = this.open.baseSize || this.open.initSize;
|
|
2466
|
-
const requestedQty = roundStep2(
|
|
2602
|
+
const requestedQty = roundStep2(
|
|
2603
|
+
baseSize * this.options.pyramiding.addFrac,
|
|
2604
|
+
this.options.qtyStep
|
|
2605
|
+
);
|
|
2467
2606
|
const addQty = typeof resolveEntrySize === "function" ? roundStep2(
|
|
2468
2607
|
resolveEntrySize({
|
|
2469
2608
|
runner: this,
|
|
@@ -2480,13 +2619,17 @@ var BarSystemRunner = class {
|
|
|
2480
2619
|
this.options.qtyStep
|
|
2481
2620
|
) : requestedQty;
|
|
2482
2621
|
if (addQty >= this.options.minQty) {
|
|
2483
|
-
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2622
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
2623
|
+
triggerPrice,
|
|
2624
|
+
this.open.side,
|
|
2625
|
+
{
|
|
2626
|
+
slippageBps: this.options.slippageBps,
|
|
2627
|
+
feeBps: this.options.feeBps,
|
|
2628
|
+
kind: "limit",
|
|
2629
|
+
qty: addQty,
|
|
2630
|
+
costs: this.options.costs
|
|
2631
|
+
}
|
|
2632
|
+
);
|
|
2490
2633
|
const newSize = this.open.size + addQty;
|
|
2491
2634
|
this.open.entryFeeTotal += addFeeTotal;
|
|
2492
2635
|
this.open.entryFill = (this.open.entryFill * this.open.size + addFill * addQty) / newSize;
|
|
@@ -2589,8 +2732,8 @@ var BarSystemRunner = class {
|
|
|
2589
2732
|
}
|
|
2590
2733
|
} else if (this.options.entryChase.enabled) {
|
|
2591
2734
|
const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
|
|
2592
|
-
const midpoint = this.pending.meta?._imb?.mid;
|
|
2593
|
-
if (!this.pending._chasedCE && midpoint !==
|
|
2735
|
+
const midpoint = asNumber3(this.pending.meta?._imb?.mid);
|
|
2736
|
+
if (!this.pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
|
|
2594
2737
|
this.pending.entry = midpoint;
|
|
2595
2738
|
this.pending._chasedCE = true;
|
|
2596
2739
|
}
|
|
@@ -2627,7 +2770,13 @@ var BarSystemRunner = class {
|
|
|
2627
2770
|
return bar;
|
|
2628
2771
|
}
|
|
2629
2772
|
if (!this.pending) {
|
|
2630
|
-
const rawSignal =
|
|
2773
|
+
const rawSignal = callSignalWithContext3({
|
|
2774
|
+
signal: this.options.signal,
|
|
2775
|
+
context: this.buildSignalContext(this.index, bar, signalEquity),
|
|
2776
|
+
index: this.index,
|
|
2777
|
+
bar,
|
|
2778
|
+
symbol: this.symbol
|
|
2779
|
+
});
|
|
2631
2780
|
const nextSignal = normalizeSignal3(rawSignal, bar, this.options.finalTP_R);
|
|
2632
2781
|
if (nextSignal) {
|
|
2633
2782
|
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
|
|
@@ -2642,9 +2791,7 @@ var BarSystemRunner = class {
|
|
|
2642
2791
|
expiresAt: this.index + Math.max(1, expiryBars),
|
|
2643
2792
|
startedAtIndex: this.index,
|
|
2644
2793
|
meta: nextSignal,
|
|
2645
|
-
plannedRiskAbs: Math.abs(
|
|
2646
|
-
nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
|
|
2647
|
-
)
|
|
2794
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
2648
2795
|
};
|
|
2649
2796
|
if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
2650
2797
|
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
@@ -2667,12 +2814,15 @@ var BarSystemRunner = class {
|
|
|
2667
2814
|
eqSeries: this.eqSeries
|
|
2668
2815
|
});
|
|
2669
2816
|
const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
2817
|
+
const lastPrice = asNumber3(this.candles[this.candles.length - 1]?.close);
|
|
2818
|
+
const openPositions = this.open ? [snapshotOpenPosition2(this.open, lastPrice ?? this.open.entryFill ?? this.open.entry)] : [];
|
|
2670
2819
|
return {
|
|
2671
2820
|
symbol: this.options.symbol,
|
|
2672
2821
|
interval: this.options.interval,
|
|
2673
2822
|
range: this.options.range,
|
|
2674
2823
|
trades: this.closed,
|
|
2675
2824
|
positions,
|
|
2825
|
+
openPositions,
|
|
2676
2826
|
metrics,
|
|
2677
2827
|
eqSeries: this.eqSeries,
|
|
2678
2828
|
replay: {
|
|
@@ -2696,6 +2846,11 @@ function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct)
|
|
|
2696
2846
|
function asWeight(value) {
|
|
2697
2847
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
2698
2848
|
}
|
|
2849
|
+
function describeValue4(value) {
|
|
2850
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
2851
|
+
if (value === null) return "null";
|
|
2852
|
+
return typeof value;
|
|
2853
|
+
}
|
|
2699
2854
|
function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
|
|
2700
2855
|
return {
|
|
2701
2856
|
time,
|
|
@@ -2708,6 +2863,24 @@ function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
|
|
|
2708
2863
|
function stableSystemOrder(left, right) {
|
|
2709
2864
|
return left.index - right.index;
|
|
2710
2865
|
}
|
|
2866
|
+
function hashedOrderScore(index, time, seed) {
|
|
2867
|
+
let value = (Number(time) ^ Math.imul(index + 1, 2654435761) ^ (seed | 0)) >>> 0;
|
|
2868
|
+
value = Math.imul(value ^ value >>> 16, 2246822507) >>> 0;
|
|
2869
|
+
value = Math.imul(value ^ value >>> 13, 3266489909) >>> 0;
|
|
2870
|
+
return (value ^ value >>> 16) >>> 0;
|
|
2871
|
+
}
|
|
2872
|
+
function orderActiveSystems(active, nextTime, processingOrder, shuffleSeed) {
|
|
2873
|
+
if (processingOrder !== "shuffle") {
|
|
2874
|
+
active.sort(stableSystemOrder);
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
active.sort((left, right) => {
|
|
2878
|
+
const leftScore = hashedOrderScore(left.index, nextTime, shuffleSeed);
|
|
2879
|
+
const rightScore = hashedOrderScore(right.index, nextTime, shuffleSeed);
|
|
2880
|
+
if (leftScore !== rightScore) return leftScore - rightScore;
|
|
2881
|
+
return stableSystemOrder(left, right);
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2711
2884
|
function combineReplay(systemResults, eqSeries, collectReplay) {
|
|
2712
2885
|
if (!collectReplay) {
|
|
2713
2886
|
return { frames: [], events: [] };
|
|
@@ -2789,15 +2962,26 @@ function backtestPortfolio({
|
|
|
2789
2962
|
allocation = "equal",
|
|
2790
2963
|
collectEqSeries = true,
|
|
2791
2964
|
collectReplay = false,
|
|
2792
|
-
maxDailyLossPct = 0
|
|
2965
|
+
maxDailyLossPct = 0,
|
|
2966
|
+
processingOrder = "sequential",
|
|
2967
|
+
shuffleSeed = 0
|
|
2793
2968
|
} = {}) {
|
|
2794
2969
|
if (!Array.isArray(systems) || systems.length === 0) {
|
|
2795
|
-
throw new Error(
|
|
2970
|
+
throw new Error(
|
|
2971
|
+
`backtestPortfolio() requires a non-empty systems array, got ${describeValue4(systems)}`
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
if (processingOrder !== "sequential" && processingOrder !== "shuffle") {
|
|
2975
|
+
throw new Error(
|
|
2976
|
+
`backtestPortfolio() processingOrder must be "sequential" or "shuffle", got ${processingOrder}`
|
|
2977
|
+
);
|
|
2796
2978
|
}
|
|
2797
2979
|
const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
|
|
2798
2980
|
const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
|
|
2799
2981
|
if (!(totalWeight > 0)) {
|
|
2800
|
-
throw new Error(
|
|
2982
|
+
throw new Error(
|
|
2983
|
+
`backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
|
|
2984
|
+
);
|
|
2801
2985
|
}
|
|
2802
2986
|
const runners = systems.map((system, index) => {
|
|
2803
2987
|
const defaultCapPct = weights[index] / totalWeight;
|
|
@@ -2835,7 +3019,7 @@ function backtestPortfolio({
|
|
|
2835
3019
|
while (true) {
|
|
2836
3020
|
const { nextTime, active } = findNextTimeAndActive(runners);
|
|
2837
3021
|
if (!Number.isFinite(nextTime)) break;
|
|
2838
|
-
active
|
|
3022
|
+
orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
|
|
2839
3023
|
const dayKey = dayKeyET(nextTime);
|
|
2840
3024
|
if (currentDay === null || dayKey !== currentDay) {
|
|
2841
3025
|
currentDay = dayKey;
|
|
@@ -2899,6 +3083,12 @@ function backtestPortfolio({
|
|
|
2899
3083
|
symbol: trade.symbol || run.symbol
|
|
2900
3084
|
}))
|
|
2901
3085
|
).sort((left, right) => left.exit.time - right.exit.time);
|
|
3086
|
+
const openPositions = systemResults.flatMap(
|
|
3087
|
+
(run) => (run.result.openPositions || []).map((position) => ({
|
|
3088
|
+
...position,
|
|
3089
|
+
symbol: position.symbol || run.symbol
|
|
3090
|
+
}))
|
|
3091
|
+
);
|
|
2902
3092
|
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
2903
3093
|
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
2904
3094
|
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
@@ -2916,6 +3106,7 @@ function backtestPortfolio({
|
|
|
2916
3106
|
range: void 0,
|
|
2917
3107
|
trades,
|
|
2918
3108
|
positions,
|
|
3109
|
+
openPositions,
|
|
2919
3110
|
metrics,
|
|
2920
3111
|
eqSeries,
|
|
2921
3112
|
replay,
|
|
@@ -2939,11 +3130,14 @@ function stitchEquitySeries(target, source) {
|
|
|
2939
3130
|
target.push(...nextPoints);
|
|
2940
3131
|
}
|
|
2941
3132
|
function canonicalParams(params) {
|
|
2942
|
-
const entries = Object.entries(params || {}).sort(
|
|
2943
|
-
([left], [right]) => left.localeCompare(right)
|
|
2944
|
-
);
|
|
3133
|
+
const entries = Object.entries(params || {}).sort(([left], [right]) => left.localeCompare(right));
|
|
2945
3134
|
return JSON.stringify(Object.fromEntries(entries));
|
|
2946
3135
|
}
|
|
3136
|
+
function describeValue5(value) {
|
|
3137
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
3138
|
+
if (value === null) return "null";
|
|
3139
|
+
return typeof value;
|
|
3140
|
+
}
|
|
2947
3141
|
function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
|
|
2948
3142
|
const ranges = [];
|
|
2949
3143
|
for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
|
|
@@ -3002,13 +3196,19 @@ function walkForwardOptimize({
|
|
|
3002
3196
|
backtestOptions = {}
|
|
3003
3197
|
} = {}) {
|
|
3004
3198
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
3005
|
-
throw new Error(
|
|
3199
|
+
throw new Error(
|
|
3200
|
+
`walkForwardOptimize() requires a non-empty candles array, got ${describeValue5(candles)}`
|
|
3201
|
+
);
|
|
3006
3202
|
}
|
|
3007
3203
|
if (typeof signalFactory !== "function") {
|
|
3008
|
-
throw new Error(
|
|
3204
|
+
throw new Error(
|
|
3205
|
+
`walkForwardOptimize() requires a signalFactory function, got ${describeValue5(signalFactory)}`
|
|
3206
|
+
);
|
|
3009
3207
|
}
|
|
3010
3208
|
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
3011
|
-
throw new Error(
|
|
3209
|
+
throw new Error(
|
|
3210
|
+
`walkForwardOptimize() requires parameterSets, got ${describeValue5(parameterSets)}`
|
|
3211
|
+
);
|
|
3012
3212
|
}
|
|
3013
3213
|
if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
|
|
3014
3214
|
throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
|
|
@@ -3022,6 +3222,12 @@ function walkForwardOptimize({
|
|
|
3022
3222
|
const eqSeries = [];
|
|
3023
3223
|
let rollingEquity = backtestOptions.equity ?? 1e4;
|
|
3024
3224
|
const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
|
|
3225
|
+
if (!ranges.length) {
|
|
3226
|
+
const required = trainBars + testBars;
|
|
3227
|
+
throw new Error(
|
|
3228
|
+
`walkForwardOptimize() produced zero windows: need at least ${required} candles (trainBars=${trainBars} + testBars=${testBars}) but got ${candles.length}. Try reducing trainBars/testBars or adding more historical data.`
|
|
3229
|
+
);
|
|
3230
|
+
}
|
|
3025
3231
|
const trainBacktestOptions = {
|
|
3026
3232
|
...backtestOptions,
|
|
3027
3233
|
collectEqSeries: false,
|
|
@@ -3031,6 +3237,13 @@ function walkForwardOptimize({
|
|
|
3031
3237
|
for (const range of ranges) {
|
|
3032
3238
|
const trainSlice = candles.slice(range.trainStart, range.trainEnd);
|
|
3033
3239
|
const testSlice = candles.slice(range.testStart, range.testEnd);
|
|
3240
|
+
if (!trainSlice.length || !testSlice.length) {
|
|
3241
|
+
throw new Error(
|
|
3242
|
+
`walkForwardOptimize() generated an empty window (train=${trainSlice.length}, test=${testSlice.length}, range=${JSON.stringify(
|
|
3243
|
+
range
|
|
3244
|
+
)})`
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3034
3247
|
let best = null;
|
|
3035
3248
|
for (const params of parameterSets) {
|
|
3036
3249
|
const trainResult = backtest({
|
|
@@ -3100,10 +3313,14 @@ function walkForwardOptimize({
|
|
|
3100
3313
|
windows,
|
|
3101
3314
|
trades: allTrades,
|
|
3102
3315
|
positions: allPositions,
|
|
3316
|
+
openPositions: [],
|
|
3103
3317
|
metrics,
|
|
3104
3318
|
eqSeries,
|
|
3105
3319
|
replay: { frames: [], events: [] },
|
|
3106
|
-
bestParams: Object.assign(
|
|
3320
|
+
bestParams: Object.assign(
|
|
3321
|
+
windows.map((window) => window.bestParams),
|
|
3322
|
+
bestParamsSummary
|
|
3323
|
+
),
|
|
3107
3324
|
bestParamsSummary: bestParamsSummary.stability
|
|
3108
3325
|
};
|
|
3109
3326
|
}
|
|
@@ -3114,7 +3331,6 @@ var import_path2 = __toESM(require("path"), 1);
|
|
|
3114
3331
|
// src/data/yahoo.js
|
|
3115
3332
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3116
3333
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3117
|
-
var DAY_SEC = 24 * 60 * 60;
|
|
3118
3334
|
var requestQueue = {
|
|
3119
3335
|
lastRequestAt: 0,
|
|
3120
3336
|
minDelayMs: 400
|
|
@@ -3269,13 +3485,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
|
|
|
3269
3485
|
}
|
|
3270
3486
|
}
|
|
3271
3487
|
throw new Error(
|
|
3272
|
-
formatYahooFailureMessage(
|
|
3273
|
-
symbol,
|
|
3274
|
-
params.interval,
|
|
3275
|
-
period,
|
|
3276
|
-
lastError,
|
|
3277
|
-
maxRetries
|
|
3278
|
-
)
|
|
3488
|
+
formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
|
|
3279
3489
|
);
|
|
3280
3490
|
}
|
|
3281
3491
|
async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
|
|
@@ -3315,9 +3525,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
|
|
|
3315
3525
|
period
|
|
3316
3526
|
);
|
|
3317
3527
|
chunks.push(...candles);
|
|
3318
|
-
chunkEndMs = chunkStartMs - 1e3;
|
|
3319
3528
|
remainingMs -= takeMs;
|
|
3320
|
-
|
|
3529
|
+
chunkEndMs = chunkStartMs - 1e3;
|
|
3530
|
+
if (chunkEndMs <= 0 || chunks.length > 2e6) break;
|
|
3321
3531
|
}
|
|
3322
3532
|
return sanitizeBars(chunks);
|
|
3323
3533
|
}
|
|
@@ -3393,11 +3603,7 @@ async function getHistoricalCandles(options = {}) {
|
|
|
3393
3603
|
}
|
|
3394
3604
|
return candles;
|
|
3395
3605
|
}
|
|
3396
|
-
async function backtestHistorical({
|
|
3397
|
-
backtestOptions = {},
|
|
3398
|
-
data,
|
|
3399
|
-
...legacy
|
|
3400
|
-
} = {}) {
|
|
3606
|
+
async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
|
|
3401
3607
|
const candles = await getHistoricalCandles(data || legacy);
|
|
3402
3608
|
return backtest({
|
|
3403
3609
|
candles,
|
|
@@ -3425,7 +3631,8 @@ function candidateRoots() {
|
|
|
3425
3631
|
return [...new Set(roots)];
|
|
3426
3632
|
}
|
|
3427
3633
|
function readTemplate(relativePath) {
|
|
3428
|
-
|
|
3634
|
+
const roots = candidateRoots();
|
|
3635
|
+
for (const root of roots) {
|
|
3429
3636
|
const absolutePath = import_path3.default.join(root, relativePath);
|
|
3430
3637
|
if (!import_fs2.default.existsSync(absolutePath)) continue;
|
|
3431
3638
|
if (!templateCache.has(absolutePath)) {
|
|
@@ -3433,7 +3640,9 @@ function readTemplate(relativePath) {
|
|
|
3433
3640
|
}
|
|
3434
3641
|
return templateCache.get(absolutePath);
|
|
3435
3642
|
}
|
|
3436
|
-
throw new Error(
|
|
3643
|
+
throw new Error(
|
|
3644
|
+
`Could not locate template asset: ${relativePath} (searched ${roots.length} roots starting from ${roots[0]})`
|
|
3645
|
+
);
|
|
3437
3646
|
}
|
|
3438
3647
|
function fmt(value, digits = 2) {
|
|
3439
3648
|
if (value === void 0 || value === null || Number.isNaN(value)) return "\u2014";
|
|
@@ -3514,9 +3723,7 @@ function renderPositionRows(positions) {
|
|
|
3514
3723
|
<td>${escapeHtml(fmt(exit.price, 4))}</td>
|
|
3515
3724
|
<td>${escapeHtml(exit.reason ?? "\u2014")}</td>
|
|
3516
3725
|
<td>${escapeHtml(fmt(exit.pnl, 2))}</td>
|
|
3517
|
-
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(
|
|
3518
|
-
fmt(trade.maeR ?? 0, 2)
|
|
3519
|
-
)}</td>
|
|
3726
|
+
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(fmt(trade.maeR ?? 0, 2))}</td>
|
|
3520
3727
|
</tr>
|
|
3521
3728
|
`;
|
|
3522
3729
|
}).join("");
|
|
@@ -3621,10 +3828,7 @@ function renderHtmlReport({
|
|
|
3621
3828
|
["R p50 / p90", `${fmt(metrics.rDist?.p50 ?? 0, 2)} / ${fmt(metrics.rDist?.p90 ?? 0, 2)}`],
|
|
3622
3829
|
[
|
|
3623
3830
|
"Hold p50 / p90",
|
|
3624
|
-
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(
|
|
3625
|
-
metrics.holdDistMin?.p90 ?? 0,
|
|
3626
|
-
1
|
|
3627
|
-
)} min`
|
|
3831
|
+
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(metrics.holdDistMin?.p90 ?? 0, 1)} min`
|
|
3628
3832
|
]
|
|
3629
3833
|
]);
|
|
3630
3834
|
return renderTemplate(template, {
|
|
@@ -3665,10 +3869,7 @@ function exportHtmlReport({
|
|
|
3665
3869
|
const safeSymbol = String(symbol).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
3666
3870
|
const safeInterval = String(interval).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
3667
3871
|
const safeRange = String(range).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
3668
|
-
const outputPath = import_path3.default.join(
|
|
3669
|
-
outDir,
|
|
3670
|
-
`report-${safeSymbol}-${safeInterval}-${safeRange}.html`
|
|
3671
|
-
);
|
|
3872
|
+
const outputPath = import_path3.default.join(outDir, `report-${safeSymbol}-${safeInterval}-${safeRange}.html`);
|
|
3672
3873
|
const html = renderHtmlReport({
|
|
3673
3874
|
symbol,
|
|
3674
3875
|
interval,
|