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
|
@@ -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,20 +42,61 @@ function strictHistoryView(candles, currentIndex) {
|
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function
|
|
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
|
+
export 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
|
+
export 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
|
+
|
|
87
|
+
export function normalizeSide(value) {
|
|
46
88
|
if (value === "long" || value === "buy") return "long";
|
|
47
89
|
if (value === "short" || value === "sell") return "short";
|
|
48
90
|
return null;
|
|
49
91
|
}
|
|
50
92
|
|
|
51
|
-
function normalizeSignal(signal, bar, fallbackR) {
|
|
93
|
+
export function normalizeSignal(signal, bar, fallbackR) {
|
|
52
94
|
if (!signal) return null;
|
|
53
95
|
|
|
54
96
|
const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
|
|
55
97
|
if (!side) return null;
|
|
56
98
|
|
|
57
|
-
const entry =
|
|
58
|
-
asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
|
|
99
|
+
const entry = asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
|
|
59
100
|
const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
60
101
|
if (entry === null || stop === null) return null;
|
|
61
102
|
|
|
@@ -67,8 +108,7 @@ function normalizeSignal(signal, bar, fallbackR) {
|
|
|
67
108
|
const targetR = rrHint ?? fallbackR;
|
|
68
109
|
|
|
69
110
|
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
70
|
-
takeProfit =
|
|
71
|
-
side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
111
|
+
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
72
112
|
}
|
|
73
113
|
if (takeProfit === null) return null;
|
|
74
114
|
|
|
@@ -86,7 +126,7 @@ function normalizeSignal(signal, bar, fallbackR) {
|
|
|
86
126
|
};
|
|
87
127
|
}
|
|
88
128
|
|
|
89
|
-
function mergeOptions(options) {
|
|
129
|
+
export function mergeOptions(options) {
|
|
90
130
|
const normalizedRiskPct = Number.isFinite(options.riskFraction)
|
|
91
131
|
? options.riskFraction * 100
|
|
92
132
|
: options.riskPct;
|
|
@@ -160,7 +200,7 @@ function mergeOptions(options) {
|
|
|
160
200
|
};
|
|
161
201
|
}
|
|
162
202
|
|
|
163
|
-
function capitalForSize(entryPrice, size, maxLeverage) {
|
|
203
|
+
export function capitalForSize(entryPrice, size, maxLeverage) {
|
|
164
204
|
const leverage = Math.max(1, Number(maxLeverage) || 1);
|
|
165
205
|
return (Math.abs(entryPrice) * Math.max(0, size)) / leverage;
|
|
166
206
|
}
|
|
@@ -171,10 +211,18 @@ export class BarSystemRunner {
|
|
|
171
211
|
const { candles, signal } = this.options;
|
|
172
212
|
|
|
173
213
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
174
|
-
throw new Error(
|
|
214
|
+
throw new Error(
|
|
215
|
+
`backtestPortfolio() requires each system to include non-empty candles, got ${describeValue(
|
|
216
|
+
candles
|
|
217
|
+
)} for ${this.options.symbol}`
|
|
218
|
+
);
|
|
175
219
|
}
|
|
176
220
|
if (typeof signal !== "function") {
|
|
177
|
-
throw new Error(
|
|
221
|
+
throw new Error(
|
|
222
|
+
`backtestPortfolio() requires each system to include a signal function, got ${describeValue(
|
|
223
|
+
signal
|
|
224
|
+
)} for ${this.options.symbol}`
|
|
225
|
+
);
|
|
178
226
|
}
|
|
179
227
|
|
|
180
228
|
this.symbol = this.options.symbol;
|
|
@@ -197,9 +245,7 @@ export class BarSystemRunner {
|
|
|
197
245
|
this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
|
|
198
246
|
this.wantEqSeries = Boolean(this.options.collectEqSeries);
|
|
199
247
|
this.wantReplay = Boolean(this.options.collectReplay);
|
|
200
|
-
this.eqSeries = this.wantEqSeries
|
|
201
|
-
? [equityPoint(candles[0].time, this.currentEquity)]
|
|
202
|
-
: [];
|
|
248
|
+
this.eqSeries = this.wantEqSeries ? [equityPoint(candles[0].time, this.currentEquity)] : [];
|
|
203
249
|
this.replayFrames = this.wantReplay ? [] : [];
|
|
204
250
|
this.replayEvents = this.wantReplay ? [] : [];
|
|
205
251
|
this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
|
|
@@ -218,7 +264,11 @@ export class BarSystemRunner {
|
|
|
218
264
|
|
|
219
265
|
getLockedCapital() {
|
|
220
266
|
if (!this.open) return 0;
|
|
221
|
-
return capitalForSize(
|
|
267
|
+
return capitalForSize(
|
|
268
|
+
this.open.entryFill ?? this.open.entry,
|
|
269
|
+
this.open.size,
|
|
270
|
+
this.options.maxLeverage
|
|
271
|
+
);
|
|
222
272
|
}
|
|
223
273
|
|
|
224
274
|
getMarkPrice() {
|
|
@@ -229,9 +279,7 @@ export class BarSystemRunner {
|
|
|
229
279
|
if (!this.open || !this.lastBar) return this.currentEquity;
|
|
230
280
|
const direction = this.open.side === "long" ? 1 : -1;
|
|
231
281
|
const markPnl =
|
|
232
|
-
(this.lastBar.close - (this.open.entryFill ?? this.open.entry)) *
|
|
233
|
-
direction *
|
|
234
|
-
this.open.size;
|
|
282
|
+
(this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
|
|
235
283
|
return this.currentEquity + markPnl;
|
|
236
284
|
}
|
|
237
285
|
|
|
@@ -256,8 +304,7 @@ export class BarSystemRunner {
|
|
|
256
304
|
const direction = openPos.side === "long" ? 1 : -1;
|
|
257
305
|
const entryFill = openPos.entryFill;
|
|
258
306
|
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
259
|
-
const entryFeePortion =
|
|
260
|
-
(openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
307
|
+
const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
261
308
|
const pnl = grossPnl - entryFeePortion - exitFeeTotal;
|
|
262
309
|
|
|
263
310
|
this.currentEquity += pnl;
|
|
@@ -272,14 +319,14 @@ export class BarSystemRunner {
|
|
|
272
319
|
reason === "SCALE"
|
|
273
320
|
? "scale-out"
|
|
274
321
|
: reason === "TP"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
322
|
+
? "tp"
|
|
323
|
+
: reason === "SL"
|
|
324
|
+
? "sl"
|
|
325
|
+
: reason === "EOD"
|
|
326
|
+
? "eod"
|
|
327
|
+
: remaining <= 0
|
|
328
|
+
? "exit"
|
|
329
|
+
: "scale-out";
|
|
283
330
|
|
|
284
331
|
if (this.wantReplay) {
|
|
285
332
|
this.replayEvents.push({
|
|
@@ -324,9 +371,7 @@ export class BarSystemRunner {
|
|
|
324
371
|
const direction = openPos.side === "long" ? 1 : -1;
|
|
325
372
|
const breakevenDelta = Math.abs(realized / openPos.size);
|
|
326
373
|
const breakevenPrice =
|
|
327
|
-
direction === 1
|
|
328
|
-
? openPos.entryFill - breakevenDelta
|
|
329
|
-
: openPos.entryFill + breakevenDelta;
|
|
374
|
+
direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
|
|
330
375
|
|
|
331
376
|
const tightened =
|
|
332
377
|
direction === 1
|
|
@@ -381,10 +426,7 @@ export class BarSystemRunner {
|
|
|
381
426
|
let stopPrice = this.pending.stop;
|
|
382
427
|
if (this.options.reanchorStopOnFill) {
|
|
383
428
|
const direction = this.pending.side === "long" ? 1 : -1;
|
|
384
|
-
stopPrice =
|
|
385
|
-
direction === 1
|
|
386
|
-
? entryPrice - plannedRisk
|
|
387
|
-
: entryPrice + plannedRisk;
|
|
429
|
+
stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
|
|
388
430
|
}
|
|
389
431
|
|
|
390
432
|
let takeProfit = this.pending.tp;
|
|
@@ -420,30 +462,27 @@ export class BarSystemRunner {
|
|
|
420
462
|
maxLeverage: this.options.maxLeverage,
|
|
421
463
|
});
|
|
422
464
|
|
|
423
|
-
const approvedSize =
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
465
|
+
const approvedSize =
|
|
466
|
+
typeof resolveEntrySize === "function"
|
|
467
|
+
? resolveEntrySize({
|
|
468
|
+
runner: this,
|
|
469
|
+
desiredSize,
|
|
470
|
+
entryPrice,
|
|
471
|
+
stopPrice,
|
|
472
|
+
pending: this.pending,
|
|
473
|
+
fillKind,
|
|
474
|
+
})
|
|
475
|
+
: desiredSize;
|
|
433
476
|
const size = roundStep(approvedSize, this.options.qtyStep);
|
|
434
477
|
if (size < this.options.minQty) return false;
|
|
435
478
|
|
|
436
|
-
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
437
|
-
|
|
438
|
-
this.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
qty: size,
|
|
444
|
-
costs: this.options.costs,
|
|
445
|
-
}
|
|
446
|
-
);
|
|
479
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, this.pending.side, {
|
|
480
|
+
slippageBps: this.options.slippageBps,
|
|
481
|
+
feeBps: this.options.feeBps,
|
|
482
|
+
kind: fillKind,
|
|
483
|
+
qty: size,
|
|
484
|
+
costs: this.options.costs,
|
|
485
|
+
});
|
|
447
486
|
|
|
448
487
|
this.open = {
|
|
449
488
|
symbol: this.symbol,
|
|
@@ -514,9 +553,7 @@ export class BarSystemRunner {
|
|
|
514
553
|
|
|
515
554
|
const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
|
|
516
555
|
const dayKey =
|
|
517
|
-
this.options.flattenAtClose || trigger === "close"
|
|
518
|
-
? dayKeyET(bar.time)
|
|
519
|
-
: dayKeyUTC(bar.time);
|
|
556
|
+
this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC(bar.time);
|
|
520
557
|
if (this.currentDay === null || dayKey !== this.currentDay) {
|
|
521
558
|
this.currentDay = dayKey;
|
|
522
559
|
this.dayPnl = 0;
|
|
@@ -567,11 +604,7 @@ export class BarSystemRunner {
|
|
|
567
604
|
this.open._mfeR = Math.max(this.open._mfeR ?? -Infinity, highR);
|
|
568
605
|
this.open._maeR = Math.min(this.open._maeR ?? Infinity, lowR);
|
|
569
606
|
|
|
570
|
-
if (
|
|
571
|
-
this.open._breakevenAtR > 0 &&
|
|
572
|
-
highR >= this.open._breakevenAtR &&
|
|
573
|
-
!this.open._beArmed
|
|
574
|
-
) {
|
|
607
|
+
if (this.open._breakevenAtR > 0 && highR >= this.open._breakevenAtR && !this.open._beArmed) {
|
|
575
608
|
const tightened =
|
|
576
609
|
this.open.side === "long"
|
|
577
610
|
? Math.max(this.open.stop, this.open.entry)
|
|
@@ -583,8 +616,7 @@ export class BarSystemRunner {
|
|
|
583
616
|
}
|
|
584
617
|
|
|
585
618
|
if (this.open._trailAfterR > 0 && highR >= this.open._trailAfterR) {
|
|
586
|
-
const candidate =
|
|
587
|
-
this.open.side === "long" ? bar.close - risk : bar.close + risk;
|
|
619
|
+
const candidate = this.open.side === "long" ? bar.close - risk : bar.close + risk;
|
|
588
620
|
const tightened =
|
|
589
621
|
this.open.side === "long"
|
|
590
622
|
? Math.max(this.open.stop, candidate)
|
|
@@ -595,10 +627,7 @@ export class BarSystemRunner {
|
|
|
595
627
|
}
|
|
596
628
|
|
|
597
629
|
if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
|
|
598
|
-
const targetR = Math.max(
|
|
599
|
-
0,
|
|
600
|
-
this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR)
|
|
601
|
-
);
|
|
630
|
+
const targetR = Math.max(0, this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR));
|
|
602
631
|
const candidate =
|
|
603
632
|
this.open.side === "long"
|
|
604
633
|
? this.open.entry + targetR * risk
|
|
@@ -612,12 +641,14 @@ export class BarSystemRunner {
|
|
|
612
641
|
: tightened;
|
|
613
642
|
}
|
|
614
643
|
|
|
615
|
-
if (
|
|
644
|
+
if (
|
|
645
|
+
this.options.atrTrailMult > 0 &&
|
|
646
|
+
this.atrValues &&
|
|
647
|
+
this.atrValues[this.index] !== undefined
|
|
648
|
+
) {
|
|
616
649
|
const trailDistance = this.atrValues[this.index] * this.options.atrTrailMult;
|
|
617
650
|
const candidate =
|
|
618
|
-
this.open.side === "long"
|
|
619
|
-
? bar.close - trailDistance
|
|
620
|
-
: bar.close + trailDistance;
|
|
651
|
+
this.open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
|
|
621
652
|
const tightened =
|
|
622
653
|
this.open.side === "long"
|
|
623
654
|
? Math.max(this.open.stop, candidate)
|
|
@@ -641,7 +672,10 @@ export class BarSystemRunner {
|
|
|
641
672
|
!this.open._volCutDone;
|
|
642
673
|
|
|
643
674
|
if (shouldCut) {
|
|
644
|
-
const cutQty = roundStep(
|
|
675
|
+
const cutQty = roundStep(
|
|
676
|
+
this.open.size * this.options.volScale.cutFrac,
|
|
677
|
+
this.options.qtyStep
|
|
678
|
+
);
|
|
645
679
|
if (cutQty >= this.options.minQty && cutQty < this.open.size) {
|
|
646
680
|
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
647
681
|
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
|
|
@@ -666,7 +700,10 @@ export class BarSystemRunner {
|
|
|
666
700
|
}
|
|
667
701
|
|
|
668
702
|
let addedThisBar = false;
|
|
669
|
-
if (
|
|
703
|
+
if (
|
|
704
|
+
this.options.pyramiding.enabled &&
|
|
705
|
+
(this.open._adds ?? 0) < this.options.pyramiding.maxAdds
|
|
706
|
+
) {
|
|
670
707
|
const addNumber = (this.open._adds || 0) + 1;
|
|
671
708
|
const triggerR = this.options.pyramiding.addAtR * addNumber;
|
|
672
709
|
const triggerPrice =
|
|
@@ -683,37 +720,45 @@ export class BarSystemRunner {
|
|
|
683
720
|
? bar.high >= triggerPrice
|
|
684
721
|
: bar.close >= triggerPrice
|
|
685
722
|
: trigger === "intrabar"
|
|
686
|
-
|
|
687
|
-
|
|
723
|
+
? bar.low <= triggerPrice
|
|
724
|
+
: bar.close <= triggerPrice;
|
|
688
725
|
|
|
689
726
|
if (breakEvenSatisfied && touched) {
|
|
690
727
|
const baseSize = this.open.baseSize || this.open.initSize;
|
|
691
|
-
const requestedQty = roundStep(
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
728
|
+
const requestedQty = roundStep(
|
|
729
|
+
baseSize * this.options.pyramiding.addFrac,
|
|
730
|
+
this.options.qtyStep
|
|
731
|
+
);
|
|
732
|
+
const addQty =
|
|
733
|
+
typeof resolveEntrySize === "function"
|
|
734
|
+
? roundStep(
|
|
735
|
+
resolveEntrySize({
|
|
736
|
+
runner: this,
|
|
737
|
+
desiredSize: requestedQty,
|
|
738
|
+
entryPrice: triggerPrice,
|
|
739
|
+
stopPrice: this.open.stop,
|
|
740
|
+
pending: {
|
|
741
|
+
side: this.open.side,
|
|
742
|
+
meta: this.open,
|
|
743
|
+
riskFrac: this.options.riskPct / 100,
|
|
744
|
+
},
|
|
745
|
+
fillKind: "limit",
|
|
746
|
+
}),
|
|
747
|
+
this.options.qtyStep
|
|
748
|
+
)
|
|
749
|
+
: requestedQty;
|
|
709
750
|
if (addQty >= this.options.minQty) {
|
|
710
|
-
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
751
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
752
|
+
triggerPrice,
|
|
753
|
+
this.open.side,
|
|
754
|
+
{
|
|
755
|
+
slippageBps: this.options.slippageBps,
|
|
756
|
+
feeBps: this.options.feeBps,
|
|
757
|
+
kind: "limit",
|
|
758
|
+
qty: addQty,
|
|
759
|
+
costs: this.options.costs,
|
|
760
|
+
}
|
|
761
|
+
);
|
|
717
762
|
const newSize = this.open.size + addQty;
|
|
718
763
|
this.open.entryFeeTotal += addFeeTotal;
|
|
719
764
|
this.open.entryFill =
|
|
@@ -738,8 +783,8 @@ export class BarSystemRunner {
|
|
|
738
783
|
? bar.high >= triggerPrice
|
|
739
784
|
: bar.close >= triggerPrice
|
|
740
785
|
: trigger === "intrabar"
|
|
741
|
-
|
|
742
|
-
|
|
786
|
+
? bar.low <= triggerPrice
|
|
787
|
+
: bar.close <= triggerPrice;
|
|
743
788
|
|
|
744
789
|
if (touched) {
|
|
745
790
|
const exitSide = this.open.side === "long" ? "short" : "long";
|
|
@@ -818,7 +863,7 @@ export class BarSystemRunner {
|
|
|
818
863
|
} else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
819
864
|
if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
|
|
820
865
|
const riskAtEdge = Math.abs(
|
|
821
|
-
this.pending.meta._initRisk ??
|
|
866
|
+
this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
|
|
822
867
|
);
|
|
823
868
|
const priceNow = bar.close;
|
|
824
869
|
const direction = this.pending.side === "long" ? 1 : -1;
|
|
@@ -830,28 +875,36 @@ export class BarSystemRunner {
|
|
|
830
875
|
|
|
831
876
|
if (slippedR > this.options.maxSlipROnFill) {
|
|
832
877
|
this.pending = null;
|
|
833
|
-
} else if (
|
|
878
|
+
} else if (
|
|
879
|
+
!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)
|
|
880
|
+
) {
|
|
834
881
|
this.pending = null;
|
|
835
882
|
}
|
|
836
883
|
} else {
|
|
837
884
|
this.pending = null;
|
|
838
885
|
}
|
|
839
886
|
} else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
840
|
-
if (
|
|
887
|
+
if (
|
|
888
|
+
!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
|
|
889
|
+
) {
|
|
841
890
|
this.pending = null;
|
|
842
891
|
}
|
|
843
892
|
} else if (this.options.entryChase.enabled) {
|
|
844
893
|
const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
|
|
845
|
-
const midpoint = this.pending.meta?._imb?.mid;
|
|
894
|
+
const midpoint = asNumber(this.pending.meta?._imb?.mid);
|
|
846
895
|
|
|
847
|
-
if (
|
|
896
|
+
if (
|
|
897
|
+
!this.pending._chasedCE &&
|
|
898
|
+
midpoint !== null &&
|
|
899
|
+
elapsedBars >= Math.max(1, this.options.entryChase.afterBars)
|
|
900
|
+
) {
|
|
848
901
|
this.pending.entry = midpoint;
|
|
849
902
|
this.pending._chasedCE = true;
|
|
850
903
|
}
|
|
851
904
|
|
|
852
905
|
if (this.pending._chasedCE) {
|
|
853
906
|
const riskRef = Math.abs(
|
|
854
|
-
this.pending.meta?._initRisk ??
|
|
907
|
+
this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
|
|
855
908
|
);
|
|
856
909
|
const priceNow = bar.close;
|
|
857
910
|
const direction = this.pending.side === "long" ? 1 : -1;
|
|
@@ -887,15 +940,21 @@ export class BarSystemRunner {
|
|
|
887
940
|
}
|
|
888
941
|
|
|
889
942
|
if (!this.pending) {
|
|
890
|
-
const rawSignal =
|
|
943
|
+
const rawSignal = callSignalWithContext({
|
|
944
|
+
signal: this.options.signal,
|
|
945
|
+
context: this.buildSignalContext(this.index, bar, signalEquity),
|
|
946
|
+
index: this.index,
|
|
947
|
+
bar,
|
|
948
|
+
symbol: this.symbol,
|
|
949
|
+
});
|
|
891
950
|
const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
|
|
892
951
|
|
|
893
952
|
if (nextSignal) {
|
|
894
953
|
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
|
|
895
954
|
? nextSignal.riskFraction
|
|
896
955
|
: Number.isFinite(nextSignal.riskPct)
|
|
897
|
-
|
|
898
|
-
|
|
956
|
+
? nextSignal.riskPct / 100
|
|
957
|
+
: this.options.riskPct / 100;
|
|
899
958
|
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
900
959
|
this.pending = {
|
|
901
960
|
side: nextSignal.side,
|
|
@@ -907,13 +966,13 @@ export class BarSystemRunner {
|
|
|
907
966
|
expiresAt: this.index + Math.max(1, expiryBars),
|
|
908
967
|
startedAtIndex: this.index,
|
|
909
968
|
meta: nextSignal,
|
|
910
|
-
plannedRiskAbs: Math.abs(
|
|
911
|
-
nextSignal._initRisk ?? (nextSignal.entry - nextSignal.stop)
|
|
912
|
-
),
|
|
969
|
+
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
|
|
913
970
|
};
|
|
914
971
|
|
|
915
972
|
if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
916
|
-
if (
|
|
973
|
+
if (
|
|
974
|
+
!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
|
|
975
|
+
) {
|
|
917
976
|
this.pending = null;
|
|
918
977
|
}
|
|
919
978
|
}
|
|
@@ -935,6 +994,10 @@ export class BarSystemRunner {
|
|
|
935
994
|
eqSeries: this.eqSeries,
|
|
936
995
|
});
|
|
937
996
|
const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
997
|
+
const lastPrice = asNumber(this.candles[this.candles.length - 1]?.close);
|
|
998
|
+
const openPositions = this.open
|
|
999
|
+
? [snapshotOpenPosition(this.open, lastPrice ?? this.open.entryFill ?? this.open.entry)]
|
|
1000
|
+
: [];
|
|
938
1001
|
|
|
939
1002
|
return {
|
|
940
1003
|
symbol: this.options.symbol,
|
|
@@ -942,6 +1005,7 @@ export class BarSystemRunner {
|
|
|
942
1005
|
range: this.options.range,
|
|
943
1006
|
trades: this.closed,
|
|
944
1007
|
positions,
|
|
1008
|
+
openPositions,
|
|
945
1009
|
metrics,
|
|
946
1010
|
eqSeries: this.eqSeries,
|
|
947
1011
|
replay: {
|
package/src/engine/execution.js
CHANGED
|
@@ -17,20 +17,10 @@ export function applyFill(
|
|
|
17
17
|
{ slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}
|
|
18
18
|
) {
|
|
19
19
|
const model = costs || {};
|
|
20
|
-
const modelSlippageBps = Number.isFinite(model.slippageBps)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
? model.commissionBps
|
|
25
|
-
: feeBps;
|
|
26
|
-
const effectiveSlippageBps = resolveSlippageBps(
|
|
27
|
-
kind,
|
|
28
|
-
modelSlippageBps,
|
|
29
|
-
model.slippageByKind
|
|
30
|
-
);
|
|
31
|
-
const halfSpreadBps = Number.isFinite(model.spreadBps)
|
|
32
|
-
? model.spreadBps / 2
|
|
33
|
-
: 0;
|
|
20
|
+
const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
|
|
21
|
+
const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
|
|
22
|
+
const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
|
|
23
|
+
const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
|
|
34
24
|
|
|
35
25
|
const slippage = ((effectiveSlippageBps + halfSpreadBps) / 10000) * price;
|
|
36
26
|
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
@@ -38,16 +28,13 @@ export function applyFill(
|
|
|
38
28
|
((modelFeeBps || 0) / 10000) * Math.abs(filledPrice) +
|
|
39
29
|
(Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
|
|
40
30
|
const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
|
|
41
|
-
const fixedFeeTotal = Number.isFinite(model.commissionPerOrder)
|
|
42
|
-
? model.commissionPerOrder
|
|
43
|
-
: 0;
|
|
31
|
+
const fixedFeeTotal = Number.isFinite(model.commissionPerOrder) ? model.commissionPerOrder : 0;
|
|
44
32
|
const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
|
|
45
33
|
const feeTotal = Math.max(
|
|
46
34
|
Number.isFinite(model.minCommission) ? model.minCommission : 0,
|
|
47
35
|
grossFeeTotal
|
|
48
36
|
);
|
|
49
|
-
const feePerUnit =
|
|
50
|
-
qty > 0 ? feeTotal / qty : variableFeePerUnit;
|
|
37
|
+
const feePerUnit = qty > 0 ? feeTotal / qty : variableFeePerUnit;
|
|
51
38
|
|
|
52
39
|
return { price: filledPrice, fee: feePerUnit, feeTotal };
|
|
53
40
|
}
|
|
@@ -63,21 +50,12 @@ export function clampStop(marketPrice, proposedStop, side, oco) {
|
|
|
63
50
|
export function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
|
|
64
51
|
if (!bar || limitPrice === undefined || limitPrice === null) return false;
|
|
65
52
|
if (mode === "close") {
|
|
66
|
-
return side === "long"
|
|
67
|
-
? bar.close <= limitPrice
|
|
68
|
-
: bar.close >= limitPrice;
|
|
53
|
+
return side === "long" ? bar.close <= limitPrice : bar.close >= limitPrice;
|
|
69
54
|
}
|
|
70
55
|
return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
|
|
71
56
|
}
|
|
72
57
|
|
|
73
|
-
export function ocoExitCheck({
|
|
74
|
-
side,
|
|
75
|
-
stop,
|
|
76
|
-
tp,
|
|
77
|
-
bar,
|
|
78
|
-
mode = "intrabar",
|
|
79
|
-
tieBreak = "pessimistic",
|
|
80
|
-
}) {
|
|
58
|
+
export function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
|
|
81
59
|
if (mode === "close") {
|
|
82
60
|
const close = bar.close;
|
|
83
61
|
if (side === "long") {
|
|
@@ -94,9 +72,7 @@ export function ocoExitCheck({
|
|
|
94
72
|
const hitTarget = side === "long" ? bar.high >= tp : bar.low <= tp;
|
|
95
73
|
|
|
96
74
|
if (hitStop && hitTarget) {
|
|
97
|
-
return tieBreak === "optimistic"
|
|
98
|
-
? { hit: "TP", px: tp }
|
|
99
|
-
: { hit: "SL", px: stop };
|
|
75
|
+
return tieBreak === "optimistic" ? { hit: "TP", px: tp } : { hit: "SL", px: stop };
|
|
100
76
|
}
|
|
101
77
|
|
|
102
78
|
if (hitStop) return { hit: "SL", px: stop };
|
|
@@ -123,10 +99,7 @@ export function estimateBarMs(candles) {
|
|
|
123
99
|
if (deltas.length) {
|
|
124
100
|
deltas.sort((a, b) => a - b);
|
|
125
101
|
const middle = Math.floor(deltas.length / 2);
|
|
126
|
-
const median =
|
|
127
|
-
deltas.length % 2
|
|
128
|
-
? deltas[middle]
|
|
129
|
-
: (deltas[middle - 1] + deltas[middle]) / 2;
|
|
102
|
+
const median = deltas.length % 2 ? deltas[middle] : (deltas[middle - 1] + deltas[middle]) / 2;
|
|
130
103
|
return Math.max(60e3, Math.min(median, 60 * 60e3));
|
|
131
104
|
}
|
|
132
105
|
}
|
|
@@ -151,7 +124,6 @@ export function dayKeyET(timeMs) {
|
|
|
151
124
|
const anchor = new Date(
|
|
152
125
|
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)
|
|
153
126
|
);
|
|
154
|
-
const pseudoEtTime =
|
|
155
|
-
anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
|
|
127
|
+
const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
|
|
156
128
|
return dayKeyUTC(pseudoEtTime);
|
|
157
129
|
}
|