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/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,14 +220,14 @@ 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
|
-
}) {
|
|
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 }) {
|
|
218
231
|
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
219
232
|
const completedTrades = [];
|
|
220
233
|
const tradeRs = [];
|
|
@@ -290,8 +303,8 @@ function buildMetrics({
|
|
|
290
303
|
const tradeReturnStd = stddev(tradeReturns);
|
|
291
304
|
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
292
305
|
const sortinoPerTrade = sortino(tradeReturns);
|
|
293
|
-
const profitFactorPositions =
|
|
294
|
-
const profitFactorLegs =
|
|
306
|
+
const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
|
|
307
|
+
const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
|
|
295
308
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
296
309
|
const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
|
|
297
310
|
const totalBars = Math.max(1, candles.length);
|
|
@@ -460,7 +473,7 @@ function normalizeDateBoundary(value, fallback) {
|
|
|
460
473
|
}
|
|
461
474
|
function normalizeCandles(candles) {
|
|
462
475
|
if (!Array.isArray(candles)) return [];
|
|
463
|
-
const
|
|
476
|
+
const parsed = candles.map((bar) => {
|
|
464
477
|
try {
|
|
465
478
|
const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
|
|
466
479
|
const open = Number(bar?.open ?? bar?.o);
|
|
@@ -482,7 +495,16 @@ function normalizeCandles(candles) {
|
|
|
482
495
|
} catch {
|
|
483
496
|
return null;
|
|
484
497
|
}
|
|
485
|
-
}).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);
|
|
486
508
|
const deduped = [];
|
|
487
509
|
let lastTime = null;
|
|
488
510
|
for (const candle of normalized) {
|
|
@@ -490,6 +512,12 @@ function normalizeCandles(candles) {
|
|
|
490
512
|
deduped.push(candle);
|
|
491
513
|
lastTime = candle.time;
|
|
492
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
|
+
}
|
|
493
521
|
return deduped;
|
|
494
522
|
}
|
|
495
523
|
function loadCandlesFromCSV(filePath, options = {}) {
|
|
@@ -532,9 +560,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
|
|
|
532
560
|
const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
|
|
533
561
|
const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
|
|
534
562
|
if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
|
|
535
|
-
throw new Error(
|
|
536
|
-
`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
|
|
537
|
-
);
|
|
563
|
+
throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
|
|
538
564
|
}
|
|
539
565
|
const minTime = normalizeDateBoundary(startDate, -Infinity);
|
|
540
566
|
const maxTime = normalizeDateBoundary(endDate, Infinity);
|
|
@@ -649,18 +675,12 @@ function usDstBoundsUTC(year) {
|
|
|
649
675
|
if (sundaysSeen === 2) break;
|
|
650
676
|
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
651
677
|
}
|
|
652
|
-
const dstStart = new Date(
|
|
653
|
-
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
654
|
-
);
|
|
678
|
+
const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
|
|
655
679
|
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
656
680
|
while (novemberCursor.getUTCDay() !== 0) {
|
|
657
|
-
novemberCursor = new Date(
|
|
658
|
-
novemberCursor.getTime() + 24 * 60 * 60 * 1e3
|
|
659
|
-
);
|
|
681
|
+
novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
660
682
|
}
|
|
661
|
-
const dstEnd = new Date(
|
|
662
|
-
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
663
|
-
);
|
|
683
|
+
const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
|
|
664
684
|
return { dstStart, dstEnd };
|
|
665
685
|
}
|
|
666
686
|
function isUsEasternDST(timeMs) {
|
|
@@ -691,11 +711,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
|
|
|
691
711
|
const model = costs || {};
|
|
692
712
|
const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
|
|
693
713
|
const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
|
|
694
|
-
const effectiveSlippageBps = resolveSlippageBps(
|
|
695
|
-
kind,
|
|
696
|
-
modelSlippageBps,
|
|
697
|
-
model.slippageByKind
|
|
698
|
-
);
|
|
714
|
+
const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
|
|
699
715
|
const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
|
|
700
716
|
const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
|
|
701
717
|
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
@@ -722,14 +738,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
|
|
|
722
738
|
}
|
|
723
739
|
return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
|
|
724
740
|
}
|
|
725
|
-
function ocoExitCheck({
|
|
726
|
-
side,
|
|
727
|
-
stop,
|
|
728
|
-
tp,
|
|
729
|
-
bar,
|
|
730
|
-
mode = "intrabar",
|
|
731
|
-
tieBreak = "pessimistic"
|
|
732
|
-
}) {
|
|
741
|
+
function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
|
|
733
742
|
if (mode === "close") {
|
|
734
743
|
const close = bar.close;
|
|
735
744
|
if (side === "long") {
|
|
@@ -810,13 +819,51 @@ function strictHistoryView(candles, currentIndex) {
|
|
|
810
819
|
get(target, property, receiver) {
|
|
811
820
|
if (isArrayIndexKey(property) && Number(property) >= target.length) {
|
|
812
821
|
throw new Error(
|
|
813
|
-
`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}`
|
|
814
823
|
);
|
|
815
824
|
}
|
|
816
825
|
return Reflect.get(target, property, receiver);
|
|
817
826
|
}
|
|
818
827
|
});
|
|
819
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
|
+
}
|
|
820
867
|
function mergeOptions(options) {
|
|
821
868
|
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
822
869
|
return {
|
|
@@ -958,10 +1005,10 @@ function backtest(rawOptions) {
|
|
|
958
1005
|
strict
|
|
959
1006
|
} = options;
|
|
960
1007
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
961
|
-
throw new Error(
|
|
1008
|
+
throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
|
|
962
1009
|
}
|
|
963
1010
|
if (typeof signal !== "function") {
|
|
964
|
-
throw new Error(
|
|
1011
|
+
throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
|
|
965
1012
|
}
|
|
966
1013
|
const closed = [];
|
|
967
1014
|
let currentEquity = equity;
|
|
@@ -1110,17 +1157,13 @@ function backtest(rawOptions) {
|
|
|
1110
1157
|
});
|
|
1111
1158
|
const size = roundStep2(rawSize, qtyStep);
|
|
1112
1159
|
if (size < minQty) return false;
|
|
1113
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
qty: size,
|
|
1121
|
-
costs
|
|
1122
|
-
}
|
|
1123
|
-
);
|
|
1160
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
|
|
1161
|
+
slippageBps,
|
|
1162
|
+
feeBps,
|
|
1163
|
+
kind: fillKind,
|
|
1164
|
+
qty: size,
|
|
1165
|
+
costs
|
|
1166
|
+
});
|
|
1124
1167
|
open = {
|
|
1125
1168
|
symbol,
|
|
1126
1169
|
...pending.meta,
|
|
@@ -1171,10 +1214,7 @@ function backtest(rawOptions) {
|
|
|
1171
1214
|
dayEquityStart = currentEquity;
|
|
1172
1215
|
}
|
|
1173
1216
|
if (open && open._maxBarsInTrade > 0) {
|
|
1174
|
-
const barsHeld = Math.max(
|
|
1175
|
-
1,
|
|
1176
|
-
Math.round((bar.time - open.openTime) / estimatedBarMs)
|
|
1177
|
-
);
|
|
1217
|
+
const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
|
|
1178
1218
|
if (barsHeld >= open._maxBarsInTrade) {
|
|
1179
1219
|
forceExit("TIME", bar);
|
|
1180
1220
|
}
|
|
@@ -1228,11 +1268,13 @@ function backtest(rawOptions) {
|
|
|
1228
1268
|
const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
|
|
1229
1269
|
if (cutQty >= minQty && cutQty < open.size) {
|
|
1230
1270
|
const exitSide2 = open.side === "long" ? "short" : "long";
|
|
1231
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1271
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
|
|
1272
|
+
slippageBps,
|
|
1273
|
+
feeBps,
|
|
1274
|
+
kind: "market",
|
|
1275
|
+
qty: cutQty,
|
|
1276
|
+
costs
|
|
1277
|
+
});
|
|
1236
1278
|
closeLeg({
|
|
1237
1279
|
openPos: open,
|
|
1238
1280
|
qty: cutQty,
|
|
@@ -1257,11 +1299,13 @@ function backtest(rawOptions) {
|
|
|
1257
1299
|
const baseSize = open.baseSize || open.initSize;
|
|
1258
1300
|
const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
|
|
1259
1301
|
if (addQty >= minQty) {
|
|
1260
|
-
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1302
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
|
|
1303
|
+
slippageBps,
|
|
1304
|
+
feeBps,
|
|
1305
|
+
kind: "limit",
|
|
1306
|
+
qty: addQty,
|
|
1307
|
+
costs
|
|
1308
|
+
});
|
|
1265
1309
|
const newSize = open.size + addQty;
|
|
1266
1310
|
open.entryFeeTotal += addFeeTotal;
|
|
1267
1311
|
open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
|
|
@@ -1339,15 +1383,10 @@ function backtest(rawOptions) {
|
|
|
1339
1383
|
if (!open && pending) {
|
|
1340
1384
|
if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
1341
1385
|
if (entryChase.enabled && entryChase.convertOnExpiry) {
|
|
1342
|
-
const riskAtEdge = Math.abs(
|
|
1343
|
-
pending.meta._initRisk ?? pending.entry - pending.stop
|
|
1344
|
-
);
|
|
1386
|
+
const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
|
|
1345
1387
|
const priceNow = bar.close;
|
|
1346
1388
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1347
|
-
const slippedR = Math.max(
|
|
1348
|
-
0,
|
|
1349
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1350
|
-
) / Math.max(1e-8, riskAtEdge);
|
|
1389
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
|
|
1351
1390
|
if (slippedR > maxSlipROnFill) {
|
|
1352
1391
|
pending = null;
|
|
1353
1392
|
} else if (!openFromPending(bar, index, priceNow, "market")) {
|
|
@@ -1362,21 +1401,16 @@ function backtest(rawOptions) {
|
|
|
1362
1401
|
}
|
|
1363
1402
|
} else if (entryChase.enabled) {
|
|
1364
1403
|
const elapsedBars = index - (pending.startedAtIndex ?? index);
|
|
1365
|
-
const midpoint = pending.meta?._imb?.mid;
|
|
1366
|
-
if (!pending._chasedCE && midpoint !==
|
|
1404
|
+
const midpoint = asNumber(pending.meta?._imb?.mid);
|
|
1405
|
+
if (!pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, entryChase.afterBars)) {
|
|
1367
1406
|
pending.entry = midpoint;
|
|
1368
1407
|
pending._chasedCE = true;
|
|
1369
1408
|
}
|
|
1370
1409
|
if (pending._chasedCE) {
|
|
1371
|
-
const riskRef = Math.abs(
|
|
1372
|
-
pending.meta?._initRisk ?? pending.entry - pending.stop
|
|
1373
|
-
);
|
|
1410
|
+
const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
|
|
1374
1411
|
const priceNow = bar.close;
|
|
1375
1412
|
const direction = pending.side === "long" ? 1 : -1;
|
|
1376
|
-
const slippedR = Math.max(
|
|
1377
|
-
0,
|
|
1378
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1379
|
-
) / Math.max(1e-8, riskRef);
|
|
1413
|
+
const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
|
|
1380
1414
|
if (slippedR > maxSlipROnFill) {
|
|
1381
1415
|
pending = null;
|
|
1382
1416
|
} else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
|
|
@@ -1404,13 +1438,19 @@ function backtest(rawOptions) {
|
|
|
1404
1438
|
);
|
|
1405
1439
|
}
|
|
1406
1440
|
const signalCandles = strict ? strictHistoryView(history, index) : history;
|
|
1407
|
-
const rawSignal =
|
|
1408
|
-
|
|
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
|
+
},
|
|
1409
1451
|
index,
|
|
1410
1452
|
bar,
|
|
1411
|
-
|
|
1412
|
-
openPosition: open,
|
|
1413
|
-
pendingOrder: pending
|
|
1453
|
+
symbol
|
|
1414
1454
|
});
|
|
1415
1455
|
const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
|
|
1416
1456
|
if (nextSignal) {
|
|
@@ -1426,9 +1466,7 @@ function backtest(rawOptions) {
|
|
|
1426
1466
|
expiresAt: index + Math.max(1, expiryBars),
|
|
1427
1467
|
startedAtIndex: index,
|
|
1428
1468
|
meta: nextSignal,
|
|
1429
|
-
plannedRiskAbs: Math.abs(
|
|
1430
|
-
nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
|
|
1431
|
-
)
|
|
1469
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
|
|
1432
1470
|
};
|
|
1433
1471
|
if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
|
|
1434
1472
|
if (!openFromPending(bar, index, pending.entry, "limit")) {
|
|
@@ -1448,12 +1486,15 @@ function backtest(rawOptions) {
|
|
|
1448
1486
|
eqSeries
|
|
1449
1487
|
});
|
|
1450
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)] : [];
|
|
1451
1491
|
return {
|
|
1452
1492
|
symbol: options.symbol,
|
|
1453
1493
|
interval: options.interval,
|
|
1454
1494
|
range: options.range,
|
|
1455
1495
|
trades: closed,
|
|
1456
1496
|
positions,
|
|
1497
|
+
openPositions,
|
|
1457
1498
|
metrics,
|
|
1458
1499
|
eqSeries,
|
|
1459
1500
|
replay: {
|
|
@@ -1466,7 +1507,6 @@ function backtest(rawOptions) {
|
|
|
1466
1507
|
// src/data/yahoo.js
|
|
1467
1508
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1468
1509
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1469
|
-
var DAY_SEC = 24 * 60 * 60;
|
|
1470
1510
|
var requestQueue = {
|
|
1471
1511
|
lastRequestAt: 0,
|
|
1472
1512
|
minDelayMs: 400
|
|
@@ -1621,13 +1661,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
|
|
|
1621
1661
|
}
|
|
1622
1662
|
}
|
|
1623
1663
|
throw new Error(
|
|
1624
|
-
formatYahooFailureMessage(
|
|
1625
|
-
symbol,
|
|
1626
|
-
params.interval,
|
|
1627
|
-
period,
|
|
1628
|
-
lastError,
|
|
1629
|
-
maxRetries
|
|
1630
|
-
)
|
|
1664
|
+
formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
|
|
1631
1665
|
);
|
|
1632
1666
|
}
|
|
1633
1667
|
async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
|
|
@@ -1667,9 +1701,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
|
|
|
1667
1701
|
period
|
|
1668
1702
|
);
|
|
1669
1703
|
chunks.push(...candles);
|
|
1670
|
-
chunkEndMs = chunkStartMs - 1e3;
|
|
1671
1704
|
remainingMs -= takeMs;
|
|
1672
|
-
|
|
1705
|
+
chunkEndMs = chunkStartMs - 1e3;
|
|
1706
|
+
if (chunkEndMs <= 0 || chunks.length > 2e6) break;
|
|
1673
1707
|
}
|
|
1674
1708
|
return sanitizeBars(chunks);
|
|
1675
1709
|
}
|
|
@@ -1745,11 +1779,7 @@ async function getHistoricalCandles(options = {}) {
|
|
|
1745
1779
|
}
|
|
1746
1780
|
return candles;
|
|
1747
1781
|
}
|
|
1748
|
-
async function backtestHistorical({
|
|
1749
|
-
backtestOptions = {},
|
|
1750
|
-
data,
|
|
1751
|
-
...legacy
|
|
1752
|
-
} = {}) {
|
|
1782
|
+
async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
|
|
1753
1783
|
const candles = await getHistoricalCandles(data || legacy);
|
|
1754
1784
|
return backtest({
|
|
1755
1785
|
candles,
|