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/src/engine/backtest.js
CHANGED
|
@@ -34,7 +34,7 @@ function strictHistoryView(candles, currentIndex) {
|
|
|
34
34
|
get(target, property, receiver) {
|
|
35
35
|
if (isArrayIndexKey(property) && Number(property) >= target.length) {
|
|
36
36
|
throw new Error(
|
|
37
|
-
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
37
|
+
`strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
return Reflect.get(target, property, receiver);
|
|
@@ -42,6 +42,48 @@ function strictHistoryView(candles, currentIndex) {
|
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function describeValue(value) {
|
|
46
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
47
|
+
if (value === null) return "null";
|
|
48
|
+
return typeof value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatIsoTime(time) {
|
|
52
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function callSignalWithContext({ signal, context, index, bar, symbol }) {
|
|
56
|
+
try {
|
|
57
|
+
return signal(context);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(
|
|
61
|
+
`signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function snapshotOpenPosition(open, markPrice) {
|
|
67
|
+
if (!open) return null;
|
|
68
|
+
const entryPrice = open.entryFill ?? open.entry;
|
|
69
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
70
|
+
const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
|
|
71
|
+
return {
|
|
72
|
+
id: open.id,
|
|
73
|
+
symbol: open.symbol,
|
|
74
|
+
side: open.side,
|
|
75
|
+
size: open.size,
|
|
76
|
+
entry: open.entry,
|
|
77
|
+
entryFill: open.entryFill,
|
|
78
|
+
stop: open.stop,
|
|
79
|
+
takeProfit: open.takeProfit,
|
|
80
|
+
openTime: open.openTime,
|
|
81
|
+
markPrice,
|
|
82
|
+
unrealizedPnl,
|
|
83
|
+
_initRisk: open._initRisk,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
45
87
|
function mergeOptions(options) {
|
|
46
88
|
const normalizedRiskPct = Number.isFinite(options.riskFraction)
|
|
47
89
|
? options.riskFraction * 100
|
|
@@ -128,8 +170,7 @@ function normalizeSignal(signal, bar, fallbackR) {
|
|
|
128
170
|
const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
|
|
129
171
|
if (!side) return null;
|
|
130
172
|
|
|
131
|
-
const entry =
|
|
132
|
-
asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
|
|
173
|
+
const entry = asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
|
|
133
174
|
const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
134
175
|
if (entry === null || stop === null) return null;
|
|
135
176
|
|
|
@@ -141,8 +182,7 @@ function normalizeSignal(signal, bar, fallbackR) {
|
|
|
141
182
|
const targetR = rrHint ?? fallbackR;
|
|
142
183
|
|
|
143
184
|
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
144
|
-
takeProfit =
|
|
145
|
-
side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
185
|
+
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
146
186
|
}
|
|
147
187
|
if (takeProfit === null) return null;
|
|
148
188
|
|
|
@@ -208,11 +248,11 @@ export function backtest(rawOptions) {
|
|
|
208
248
|
} = options;
|
|
209
249
|
|
|
210
250
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
211
|
-
throw new Error(
|
|
251
|
+
throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
|
|
212
252
|
}
|
|
213
253
|
|
|
214
254
|
if (typeof signal !== "function") {
|
|
215
|
-
throw new Error(
|
|
255
|
+
throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
|
|
216
256
|
}
|
|
217
257
|
|
|
218
258
|
const closed = [];
|
|
@@ -264,8 +304,7 @@ export function backtest(rawOptions) {
|
|
|
264
304
|
const direction = openPos.side === "long" ? 1 : -1;
|
|
265
305
|
const entryFill = openPos.entryFill;
|
|
266
306
|
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
267
|
-
const entryFeePortion =
|
|
268
|
-
(openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
307
|
+
const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
269
308
|
const pnl = grossPnl - entryFeePortion - exitFeeTotal;
|
|
270
309
|
|
|
271
310
|
currentEquity += pnl;
|
|
@@ -280,14 +319,14 @@ export function backtest(rawOptions) {
|
|
|
280
319
|
reason === "SCALE"
|
|
281
320
|
? "scale-out"
|
|
282
321
|
: reason === "TP"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
322
|
+
? "tp"
|
|
323
|
+
: reason === "SL"
|
|
324
|
+
? "sl"
|
|
325
|
+
: reason === "EOD"
|
|
326
|
+
? "eod"
|
|
327
|
+
: remaining <= 0
|
|
328
|
+
? "exit"
|
|
329
|
+
: "scale-out";
|
|
291
330
|
|
|
292
331
|
if (wantReplay) {
|
|
293
332
|
replayEvents.push({
|
|
@@ -331,18 +370,14 @@ export function backtest(rawOptions) {
|
|
|
331
370
|
const direction = openPos.side === "long" ? 1 : -1;
|
|
332
371
|
const breakevenDelta = Math.abs(realized / openPos.size);
|
|
333
372
|
const breakevenPrice =
|
|
334
|
-
direction === 1
|
|
335
|
-
? openPos.entryFill - breakevenDelta
|
|
336
|
-
: openPos.entryFill + breakevenDelta;
|
|
373
|
+
direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
|
|
337
374
|
|
|
338
375
|
const tightened =
|
|
339
376
|
direction === 1
|
|
340
377
|
? Math.max(openPos.stop, breakevenPrice)
|
|
341
378
|
: Math.min(openPos.stop, breakevenPrice);
|
|
342
379
|
|
|
343
|
-
openPos.stop = oco.clampStops
|
|
344
|
-
? clampStop(lastClose, tightened, openPos.side, oco)
|
|
345
|
-
: tightened;
|
|
380
|
+
openPos.stop = oco.clampStops ? clampStop(lastClose, tightened, openPos.side, oco) : tightened;
|
|
346
381
|
}
|
|
347
382
|
|
|
348
383
|
function forceExit(reason, bar) {
|
|
@@ -383,10 +418,7 @@ export function backtest(rawOptions) {
|
|
|
383
418
|
let stopPrice = pending.stop;
|
|
384
419
|
if (reanchorStopOnFill) {
|
|
385
420
|
const direction = pending.side === "long" ? 1 : -1;
|
|
386
|
-
stopPrice =
|
|
387
|
-
direction === 1
|
|
388
|
-
? entryPrice - plannedRisk
|
|
389
|
-
: entryPrice + plannedRisk;
|
|
421
|
+
stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
|
|
390
422
|
}
|
|
391
423
|
|
|
392
424
|
let takeProfit = pending.tp;
|
|
@@ -424,17 +456,13 @@ export function backtest(rawOptions) {
|
|
|
424
456
|
const size = roundStep(rawSize, qtyStep);
|
|
425
457
|
if (size < minQty) return false;
|
|
426
458
|
|
|
427
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
qty: size,
|
|
435
|
-
costs,
|
|
436
|
-
}
|
|
437
|
-
);
|
|
459
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
|
|
460
|
+
slippageBps,
|
|
461
|
+
feeBps,
|
|
462
|
+
kind: fillKind,
|
|
463
|
+
qty: size,
|
|
464
|
+
costs,
|
|
465
|
+
});
|
|
438
466
|
|
|
439
467
|
open = {
|
|
440
468
|
symbol,
|
|
@@ -485,10 +513,7 @@ export function backtest(rawOptions) {
|
|
|
485
513
|
const bar = candles[index];
|
|
486
514
|
history.push(bar);
|
|
487
515
|
|
|
488
|
-
const dayKey =
|
|
489
|
-
flattenAtClose || trigger === "close"
|
|
490
|
-
? dayKeyET(bar.time)
|
|
491
|
-
: dayKeyUTC(bar.time);
|
|
516
|
+
const dayKey = flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC(bar.time);
|
|
492
517
|
if (currentDay === null || dayKey !== currentDay) {
|
|
493
518
|
currentDay = dayKey;
|
|
494
519
|
dayPnl = 0;
|
|
@@ -497,10 +522,7 @@ export function backtest(rawOptions) {
|
|
|
497
522
|
}
|
|
498
523
|
|
|
499
524
|
if (open && open._maxBarsInTrade > 0) {
|
|
500
|
-
const barsHeld = Math.max(
|
|
501
|
-
1,
|
|
502
|
-
Math.round((bar.time - open.openTime) / estimatedBarMs)
|
|
503
|
-
);
|
|
525
|
+
const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
|
|
504
526
|
if (barsHeld >= open._maxBarsInTrade) {
|
|
505
527
|
forceExit("TIME", bar);
|
|
506
528
|
}
|
|
@@ -521,17 +543,11 @@ export function backtest(rawOptions) {
|
|
|
521
543
|
const direction = open.side === "long" ? 1 : -1;
|
|
522
544
|
const risk = open._initRisk || 1e-8;
|
|
523
545
|
const highR =
|
|
524
|
-
open.side === "long"
|
|
525
|
-
? (bar.high - open.entry) / risk
|
|
526
|
-
: (open.entry - bar.low) / risk;
|
|
546
|
+
open.side === "long" ? (bar.high - open.entry) / risk : (open.entry - bar.low) / risk;
|
|
527
547
|
const lowR =
|
|
528
|
-
open.side === "long"
|
|
529
|
-
? (bar.low - open.entry) / risk
|
|
530
|
-
: (open.entry - bar.high) / risk;
|
|
548
|
+
open.side === "long" ? (bar.low - open.entry) / risk : (open.entry - bar.high) / risk;
|
|
531
549
|
const markR =
|
|
532
|
-
direction === 1
|
|
533
|
-
? (bar.close - open.entry) / risk
|
|
534
|
-
: (open.entry - bar.close) / risk;
|
|
550
|
+
direction === 1 ? (bar.close - open.entry) / risk : (open.entry - bar.close) / risk;
|
|
535
551
|
|
|
536
552
|
if (atrValues && atrValues[index] !== undefined) {
|
|
537
553
|
open._lastATR = atrValues[index];
|
|
@@ -542,55 +558,34 @@ export function backtest(rawOptions) {
|
|
|
542
558
|
|
|
543
559
|
if (open._breakevenAtR > 0 && highR >= open._breakevenAtR && !open._beArmed) {
|
|
544
560
|
const tightened =
|
|
545
|
-
open.side === "long"
|
|
546
|
-
|
|
547
|
-
: Math.min(open.stop, open.entry);
|
|
548
|
-
open.stop = oco.clampStops
|
|
549
|
-
? clampStop(bar.close, tightened, open.side, oco)
|
|
550
|
-
: tightened;
|
|
561
|
+
open.side === "long" ? Math.max(open.stop, open.entry) : Math.min(open.stop, open.entry);
|
|
562
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
551
563
|
open._beArmed = true;
|
|
552
564
|
}
|
|
553
565
|
|
|
554
566
|
if (open._trailAfterR > 0 && highR >= open._trailAfterR) {
|
|
555
|
-
const candidate =
|
|
556
|
-
open.side === "long" ? bar.close - risk : bar.close + risk;
|
|
567
|
+
const candidate = open.side === "long" ? bar.close - risk : bar.close + risk;
|
|
557
568
|
const tightened =
|
|
558
|
-
open.side === "long"
|
|
559
|
-
|
|
560
|
-
: Math.min(open.stop, candidate);
|
|
561
|
-
open.stop = oco.clampStops
|
|
562
|
-
? clampStop(bar.close, tightened, open.side, oco)
|
|
563
|
-
: tightened;
|
|
569
|
+
open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
|
|
570
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
564
571
|
}
|
|
565
572
|
|
|
566
573
|
if (useMfeTrail && open._mfeR >= mfeTrail.armR) {
|
|
567
574
|
const targetR = Math.max(0, open._mfeR - Math.max(0, mfeTrail.givebackR));
|
|
568
575
|
const candidate =
|
|
569
|
-
open.side === "long"
|
|
570
|
-
? open.entry + targetR * risk
|
|
571
|
-
: open.entry - targetR * risk;
|
|
576
|
+
open.side === "long" ? open.entry + targetR * risk : open.entry - targetR * risk;
|
|
572
577
|
const tightened =
|
|
573
|
-
open.side === "long"
|
|
574
|
-
|
|
575
|
-
: Math.min(open.stop, candidate);
|
|
576
|
-
open.stop = oco.clampStops
|
|
577
|
-
? clampStop(bar.close, tightened, open.side, oco)
|
|
578
|
-
: tightened;
|
|
578
|
+
open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
|
|
579
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
579
580
|
}
|
|
580
581
|
|
|
581
582
|
if (useAtrTrail && atrValues && atrValues[index] !== undefined) {
|
|
582
583
|
const trailDistance = atrValues[index] * atrTrailMult;
|
|
583
584
|
const candidate =
|
|
584
|
-
open.side === "long"
|
|
585
|
-
? bar.close - trailDistance
|
|
586
|
-
: bar.close + trailDistance;
|
|
585
|
+
open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
|
|
587
586
|
const tightened =
|
|
588
|
-
open.side === "long"
|
|
589
|
-
|
|
590
|
-
: Math.min(open.stop, candidate);
|
|
591
|
-
open.stop = oco.clampStops
|
|
592
|
-
? clampStop(bar.close, tightened, open.side, oco)
|
|
593
|
-
: tightened;
|
|
587
|
+
open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
|
|
588
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
594
589
|
}
|
|
595
590
|
|
|
596
591
|
if (
|
|
@@ -602,19 +597,19 @@ export function backtest(rawOptions) {
|
|
|
602
597
|
) {
|
|
603
598
|
const ratio = atrValues[index] / Math.max(1e-12, open.entryATR);
|
|
604
599
|
const shouldCut =
|
|
605
|
-
ratio >= volScale.cutIfAtrX &&
|
|
606
|
-
markR < volScale.noCutAboveR &&
|
|
607
|
-
!open._volCutDone;
|
|
600
|
+
ratio >= volScale.cutIfAtrX && markR < volScale.noCutAboveR && !open._volCutDone;
|
|
608
601
|
|
|
609
602
|
if (shouldCut) {
|
|
610
603
|
const cutQty = roundStep(open.size * volScale.cutFrac, qtyStep);
|
|
611
604
|
if (cutQty >= minQty && cutQty < open.size) {
|
|
612
605
|
const exitSide = open.side === "long" ? "short" : "long";
|
|
613
|
-
const { price: filled, feeTotal: exitFeeTotal } = applyFill(
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
606
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
|
|
607
|
+
slippageBps,
|
|
608
|
+
feeBps,
|
|
609
|
+
kind: "market",
|
|
610
|
+
qty: cutQty,
|
|
611
|
+
costs,
|
|
612
|
+
});
|
|
618
613
|
closeLeg({
|
|
619
614
|
openPos: open,
|
|
620
615
|
qty: cutQty,
|
|
@@ -634,9 +629,7 @@ export function backtest(rawOptions) {
|
|
|
634
629
|
const addNumber = (open._adds || 0) + 1;
|
|
635
630
|
const triggerR = pyramiding.addAtR * addNumber;
|
|
636
631
|
const triggerPrice =
|
|
637
|
-
open.side === "long"
|
|
638
|
-
? open.entry + triggerR * risk
|
|
639
|
-
: open.entry - triggerR * risk;
|
|
632
|
+
open.side === "long" ? open.entry + triggerR * risk : open.entry - triggerR * risk;
|
|
640
633
|
const breakEvenSatisfied =
|
|
641
634
|
!pyramiding.onlyAfterBreakEven ||
|
|
642
635
|
(open.side === "long" && open.stop >= open.entry) ||
|
|
@@ -647,22 +640,23 @@ export function backtest(rawOptions) {
|
|
|
647
640
|
? bar.high >= triggerPrice
|
|
648
641
|
: bar.close >= triggerPrice
|
|
649
642
|
: trigger === "intrabar"
|
|
650
|
-
|
|
651
|
-
|
|
643
|
+
? bar.low <= triggerPrice
|
|
644
|
+
: bar.close <= triggerPrice;
|
|
652
645
|
|
|
653
646
|
if (breakEvenSatisfied && touched) {
|
|
654
647
|
const baseSize = open.baseSize || open.initSize;
|
|
655
648
|
const addQty = roundStep(baseSize * pyramiding.addFrac, qtyStep);
|
|
656
649
|
if (addQty >= minQty) {
|
|
657
|
-
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
650
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
|
|
651
|
+
slippageBps,
|
|
652
|
+
feeBps,
|
|
653
|
+
kind: "limit",
|
|
654
|
+
qty: addQty,
|
|
655
|
+
costs,
|
|
656
|
+
});
|
|
662
657
|
const newSize = open.size + addQty;
|
|
663
658
|
open.entryFeeTotal += addFeeTotal;
|
|
664
|
-
open.entryFill =
|
|
665
|
-
(open.entryFill * open.size + addFill * addQty) / newSize;
|
|
659
|
+
open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
|
|
666
660
|
open.size = newSize;
|
|
667
661
|
open.initSize += addQty;
|
|
668
662
|
if (!open.baseSize) open.baseSize = baseSize;
|
|
@@ -674,17 +668,15 @@ export function backtest(rawOptions) {
|
|
|
674
668
|
|
|
675
669
|
if (!addedThisBar && !open._scaled && scaleOutAtR > 0) {
|
|
676
670
|
const triggerPrice =
|
|
677
|
-
open.side === "long"
|
|
678
|
-
? open.entry + scaleOutAtR * risk
|
|
679
|
-
: open.entry - scaleOutAtR * risk;
|
|
671
|
+
open.side === "long" ? open.entry + scaleOutAtR * risk : open.entry - scaleOutAtR * risk;
|
|
680
672
|
const touched =
|
|
681
673
|
open.side === "long"
|
|
682
674
|
? trigger === "intrabar"
|
|
683
675
|
? bar.high >= triggerPrice
|
|
684
676
|
: bar.close >= triggerPrice
|
|
685
677
|
: trigger === "intrabar"
|
|
686
|
-
|
|
687
|
-
|
|
678
|
+
? bar.low <= triggerPrice
|
|
679
|
+
: bar.close <= triggerPrice;
|
|
688
680
|
|
|
689
681
|
if (touched) {
|
|
690
682
|
const exitSide = open.side === "long" ? "short" : "long";
|
|
@@ -707,9 +699,7 @@ export function backtest(rawOptions) {
|
|
|
707
699
|
});
|
|
708
700
|
open._scaled = true;
|
|
709
701
|
open.takeProfit =
|
|
710
|
-
open.side === "long"
|
|
711
|
-
? open.entry + finalTP_R * risk
|
|
712
|
-
: open.entry - finalTP_R * risk;
|
|
702
|
+
open.side === "long" ? open.entry + finalTP_R * risk : open.entry - finalTP_R * risk;
|
|
713
703
|
tightenStopToNetBreakeven(open, bar.close);
|
|
714
704
|
open._beArmed = true;
|
|
715
705
|
}
|
|
@@ -745,9 +735,7 @@ export function backtest(rawOptions) {
|
|
|
745
735
|
reason: hit,
|
|
746
736
|
});
|
|
747
737
|
cooldown =
|
|
748
|
-
(hit === "SL"
|
|
749
|
-
? Math.max(cooldown, postLossCooldownBars)
|
|
750
|
-
: cooldown) || localCooldown;
|
|
738
|
+
(hit === "SL" ? Math.max(cooldown, postLossCooldownBars) : cooldown) || localCooldown;
|
|
751
739
|
open = null;
|
|
752
740
|
}
|
|
753
741
|
}
|
|
@@ -759,16 +747,12 @@ export function backtest(rawOptions) {
|
|
|
759
747
|
if (!open && pending) {
|
|
760
748
|
if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
761
749
|
if (entryChase.enabled && entryChase.convertOnExpiry) {
|
|
762
|
-
const riskAtEdge = Math.abs(
|
|
763
|
-
pending.meta._initRisk ?? (pending.entry - pending.stop)
|
|
764
|
-
);
|
|
750
|
+
const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
|
|
765
751
|
const priceNow = bar.close;
|
|
766
752
|
const direction = pending.side === "long" ? 1 : -1;
|
|
767
753
|
const slippedR =
|
|
768
|
-
Math.max(
|
|
769
|
-
|
|
770
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
771
|
-
) / Math.max(1e-8, riskAtEdge);
|
|
754
|
+
Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) /
|
|
755
|
+
Math.max(1e-8, riskAtEdge);
|
|
772
756
|
|
|
773
757
|
if (slippedR > maxSlipROnFill) {
|
|
774
758
|
pending = null;
|
|
@@ -784,24 +768,24 @@ export function backtest(rawOptions) {
|
|
|
784
768
|
}
|
|
785
769
|
} else if (entryChase.enabled) {
|
|
786
770
|
const elapsedBars = index - (pending.startedAtIndex ?? index);
|
|
787
|
-
const midpoint = pending.meta?._imb?.mid;
|
|
771
|
+
const midpoint = asNumber(pending.meta?._imb?.mid);
|
|
788
772
|
|
|
789
|
-
if (
|
|
773
|
+
if (
|
|
774
|
+
!pending._chasedCE &&
|
|
775
|
+
midpoint !== null &&
|
|
776
|
+
elapsedBars >= Math.max(1, entryChase.afterBars)
|
|
777
|
+
) {
|
|
790
778
|
pending.entry = midpoint;
|
|
791
779
|
pending._chasedCE = true;
|
|
792
780
|
}
|
|
793
781
|
|
|
794
782
|
if (pending._chasedCE) {
|
|
795
|
-
const riskRef = Math.abs(
|
|
796
|
-
pending.meta?._initRisk ?? (pending.entry - pending.stop)
|
|
797
|
-
);
|
|
783
|
+
const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
|
|
798
784
|
const priceNow = bar.close;
|
|
799
785
|
const direction = pending.side === "long" ? 1 : -1;
|
|
800
786
|
const slippedR =
|
|
801
|
-
Math.max(
|
|
802
|
-
|
|
803
|
-
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
804
|
-
) / Math.max(1e-8, riskRef);
|
|
787
|
+
Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) /
|
|
788
|
+
Math.max(1e-8, riskRef);
|
|
805
789
|
|
|
806
790
|
if (slippedR > maxSlipROnFill) {
|
|
807
791
|
pending = null;
|
|
@@ -834,13 +818,19 @@ export function backtest(rawOptions) {
|
|
|
834
818
|
}
|
|
835
819
|
|
|
836
820
|
const signalCandles = strict ? strictHistoryView(history, index) : history;
|
|
837
|
-
const rawSignal =
|
|
838
|
-
|
|
821
|
+
const rawSignal = callSignalWithContext({
|
|
822
|
+
signal,
|
|
823
|
+
context: {
|
|
824
|
+
candles: signalCandles,
|
|
825
|
+
index,
|
|
826
|
+
bar,
|
|
827
|
+
equity: currentEquity,
|
|
828
|
+
openPosition: open,
|
|
829
|
+
pendingOrder: pending,
|
|
830
|
+
},
|
|
839
831
|
index,
|
|
840
832
|
bar,
|
|
841
|
-
|
|
842
|
-
openPosition: open,
|
|
843
|
-
pendingOrder: pending,
|
|
833
|
+
symbol,
|
|
844
834
|
});
|
|
845
835
|
const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
|
|
846
836
|
|
|
@@ -848,8 +838,8 @@ export function backtest(rawOptions) {
|
|
|
848
838
|
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
|
|
849
839
|
? nextSignal.riskFraction
|
|
850
840
|
: Number.isFinite(nextSignal.riskPct)
|
|
851
|
-
|
|
852
|
-
|
|
841
|
+
? nextSignal.riskPct / 100
|
|
842
|
+
: riskPct / 100;
|
|
853
843
|
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
854
844
|
pending = {
|
|
855
845
|
side: nextSignal.side,
|
|
@@ -861,9 +851,7 @@ export function backtest(rawOptions) {
|
|
|
861
851
|
expiresAt: index + Math.max(1, expiryBars),
|
|
862
852
|
startedAtIndex: index,
|
|
863
853
|
meta: nextSignal,
|
|
864
|
-
plannedRiskAbs: Math.abs(
|
|
865
|
-
nextSignal._initRisk ?? (nextSignal.entry - nextSignal.stop)
|
|
866
|
-
),
|
|
854
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
|
|
867
855
|
};
|
|
868
856
|
|
|
869
857
|
if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
|
|
@@ -886,6 +874,10 @@ export function backtest(rawOptions) {
|
|
|
886
874
|
eqSeries,
|
|
887
875
|
});
|
|
888
876
|
const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
877
|
+
const lastPrice = asNumber(candles[candles.length - 1]?.close);
|
|
878
|
+
const openPositions = open
|
|
879
|
+
? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)]
|
|
880
|
+
: [];
|
|
889
881
|
|
|
890
882
|
return {
|
|
891
883
|
symbol: options.symbol,
|
|
@@ -893,6 +885,7 @@ export function backtest(rawOptions) {
|
|
|
893
885
|
range: options.range,
|
|
894
886
|
trades: closed,
|
|
895
887
|
positions,
|
|
888
|
+
openPositions,
|
|
896
889
|
metrics,
|
|
897
890
|
eqSeries,
|
|
898
891
|
replay: {
|