tradelab 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +121 -52
  2. package/bin/tradelab.js +340 -49
  3. package/dist/cjs/data.cjs +210 -155
  4. package/dist/cjs/index.cjs +1782 -274
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +26 -9
  7. package/docs/api-reference.md +89 -26
  8. package/docs/backtest-engine.md +74 -60
  9. package/docs/data-reporting-cli.md +66 -36
  10. package/docs/examples.md +275 -0
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +481 -0
  19. package/src/engine/barSystemRunner.js +1027 -0
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +237 -66
  22. package/src/engine/walkForward.js +132 -13
  23. package/src/index.js +3 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +103 -100
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +98 -4
  54. package/types/live.d.ts +382 -0
@@ -0,0 +1,1027 @@
1
+ import { atr } from "../utils/indicators.js";
2
+ import { calculatePositionSize } from "../utils/positionSizing.js";
3
+ import { buildMetrics } from "../metrics/buildMetrics.js";
4
+ import { normalizeCandles } from "../data/csv.js";
5
+ import {
6
+ applyFill,
7
+ clampStop,
8
+ touchedLimit,
9
+ ocoExitCheck,
10
+ isEODBar,
11
+ roundStep,
12
+ estimateBarMs,
13
+ dayKeyUTC,
14
+ dayKeyET,
15
+ } from "./execution.js";
16
+
17
+ function asNumber(value) {
18
+ const numeric = Number(value);
19
+ return Number.isFinite(numeric) ? numeric : null;
20
+ }
21
+
22
+ function equityPoint(time, equity, extra = {}) {
23
+ return { time, timestamp: time, equity, ...extra };
24
+ }
25
+
26
+ function isArrayIndexKey(property) {
27
+ if (typeof property !== "string") return false;
28
+ const numeric = Number(property);
29
+ return Number.isInteger(numeric) && numeric >= 0;
30
+ }
31
+
32
+ function strictHistoryView(candles, currentIndex) {
33
+ return new Proxy(candles, {
34
+ get(target, property, receiver) {
35
+ if (isArrayIndexKey(property) && Number(property) >= target.length) {
36
+ throw new Error(
37
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
38
+ );
39
+ }
40
+ return Reflect.get(target, property, receiver);
41
+ },
42
+ });
43
+ }
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
+ 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) {
88
+ if (value === "long" || value === "buy") return "long";
89
+ if (value === "short" || value === "sell") return "short";
90
+ return null;
91
+ }
92
+
93
+ export function normalizeSignal(signal, bar, fallbackR) {
94
+ if (!signal) return null;
95
+
96
+ const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
97
+ if (!side) return null;
98
+
99
+ const entry = asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
100
+ const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
101
+ if (entry === null || stop === null) return null;
102
+
103
+ const risk = Math.abs(entry - stop);
104
+ if (!(risk > 0)) return null;
105
+
106
+ let takeProfit = asNumber(signal.takeProfit ?? signal.target ?? signal.tp);
107
+ const rrHint = asNumber(signal._rr ?? signal.rr);
108
+ const targetR = rrHint ?? fallbackR;
109
+
110
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
111
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
112
+ }
113
+ if (takeProfit === null) return null;
114
+
115
+ return {
116
+ ...signal,
117
+ side,
118
+ entry,
119
+ stop,
120
+ takeProfit,
121
+ qty: asNumber(signal.qty ?? signal.size),
122
+ riskPct: asNumber(signal.riskPct),
123
+ riskFraction: asNumber(signal.riskFraction),
124
+ _rr: rrHint ?? signal._rr,
125
+ _initRisk: asNumber(signal._initRisk) ?? signal._initRisk,
126
+ };
127
+ }
128
+
129
+ export function mergeOptions(options) {
130
+ const normalizedRiskPct = Number.isFinite(options.riskFraction)
131
+ ? options.riskFraction * 100
132
+ : options.riskPct;
133
+
134
+ return {
135
+ candles: normalizeCandles(options.candles ?? []),
136
+ symbol: options.symbol ?? "UNKNOWN",
137
+ equity: options.equity ?? 10_000,
138
+ riskPct: normalizedRiskPct ?? 1,
139
+ signal: options.signal,
140
+ interval: options.interval,
141
+ range: options.range,
142
+ warmupBars: options.warmupBars ?? 200,
143
+ slippageBps: options.slippageBps ?? 1,
144
+ feeBps: options.feeBps ?? 0,
145
+ costs: options.costs ?? null,
146
+ scaleOutAtR: options.scaleOutAtR ?? 1,
147
+ scaleOutFrac: options.scaleOutFrac ?? 0.5,
148
+ finalTP_R: options.finalTP_R ?? 3,
149
+ maxDailyLossPct: options.maxDailyLossPct ?? 2,
150
+ atrTrailMult: options.atrTrailMult ?? 0,
151
+ atrTrailPeriod: options.atrTrailPeriod ?? 14,
152
+ oco: {
153
+ mode: "intrabar",
154
+ tieBreak: "pessimistic",
155
+ clampStops: true,
156
+ clampEpsBps: 0.25,
157
+ ...(options.oco || {}),
158
+ },
159
+ triggerMode: options.triggerMode,
160
+ flattenAtClose: options.flattenAtClose ?? true,
161
+ dailyMaxTrades: options.dailyMaxTrades ?? 0,
162
+ postLossCooldownBars: options.postLossCooldownBars ?? 0,
163
+ mfeTrail: {
164
+ enabled: false,
165
+ armR: 1,
166
+ givebackR: 0.5,
167
+ ...(options.mfeTrail || {}),
168
+ },
169
+ pyramiding: {
170
+ enabled: false,
171
+ addAtR: 1,
172
+ addFrac: 0.25,
173
+ maxAdds: 1,
174
+ onlyAfterBreakEven: true,
175
+ ...(options.pyramiding || {}),
176
+ },
177
+ volScale: {
178
+ enabled: false,
179
+ atrPeriod: options.atrTrailPeriod ?? 14,
180
+ cutIfAtrX: 1.3,
181
+ cutFrac: 0.33,
182
+ noCutAboveR: 1.5,
183
+ ...(options.volScale || {}),
184
+ },
185
+ qtyStep: options.qtyStep ?? 0.001,
186
+ minQty: options.minQty ?? 0.001,
187
+ maxLeverage: options.maxLeverage ?? 2,
188
+ entryChase: {
189
+ enabled: true,
190
+ afterBars: 2,
191
+ maxSlipR: 0.2,
192
+ convertOnExpiry: false,
193
+ ...(options.entryChase || {}),
194
+ },
195
+ reanchorStopOnFill: options.reanchorStopOnFill ?? true,
196
+ maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
197
+ collectEqSeries: options.collectEqSeries ?? true,
198
+ collectReplay: options.collectReplay ?? true,
199
+ strict: options.strict ?? false,
200
+ };
201
+ }
202
+
203
+ export function capitalForSize(entryPrice, size, maxLeverage) {
204
+ const leverage = Math.max(1, Number(maxLeverage) || 1);
205
+ return (Math.abs(entryPrice) * Math.max(0, size)) / leverage;
206
+ }
207
+
208
+ export class BarSystemRunner {
209
+ constructor(rawOptions = {}) {
210
+ this.options = mergeOptions(rawOptions);
211
+ const { candles, signal } = this.options;
212
+
213
+ if (!Array.isArray(candles) || candles.length === 0) {
214
+ throw new Error(
215
+ `backtestPortfolio() requires each system to include non-empty candles, got ${describeValue(
216
+ candles
217
+ )} for ${this.options.symbol}`
218
+ );
219
+ }
220
+ if (typeof signal !== "function") {
221
+ throw new Error(
222
+ `backtestPortfolio() requires each system to include a signal function, got ${describeValue(
223
+ signal
224
+ )} for ${this.options.symbol}`
225
+ );
226
+ }
227
+
228
+ this.symbol = this.options.symbol;
229
+ this.candles = candles;
230
+ this.closed = [];
231
+ this.currentEquity = this.options.equity;
232
+ this.open = null;
233
+ this.cooldown = 0;
234
+ this.pending = null;
235
+ this.currentDay = null;
236
+ this.dayPnl = 0;
237
+ this.dayTrades = 0;
238
+ this.dayEquityStart = this.options.equity;
239
+ this.tradeIdCounter = 0;
240
+ this.estimatedBarMs = estimateBarMs(candles);
241
+ const atrSourcePeriod = this.options.volScale.enabled
242
+ ? this.options.volScale.atrPeriod
243
+ : this.options.atrTrailPeriod;
244
+ const needAtr = this.options.atrTrailMult > 0 || this.options.volScale.enabled;
245
+ this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
246
+ this.wantEqSeries = Boolean(this.options.collectEqSeries);
247
+ this.wantReplay = Boolean(this.options.collectReplay);
248
+ this.eqSeries = this.wantEqSeries ? [equityPoint(candles[0].time, this.currentEquity)] : [];
249
+ this.replayFrames = this.wantReplay ? [] : [];
250
+ this.replayEvents = this.wantReplay ? [] : [];
251
+ this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
252
+ this.history = candles.slice(0, this.startIndex);
253
+ this.index = this.startIndex;
254
+ this.lastBar = this.history.length ? this.history[this.history.length - 1] : null;
255
+ }
256
+
257
+ hasNext() {
258
+ return this.index < this.candles.length;
259
+ }
260
+
261
+ peekTime() {
262
+ return this.hasNext() ? this.candles[this.index].time : Infinity;
263
+ }
264
+
265
+ getLockedCapital() {
266
+ if (!this.open) return 0;
267
+ return capitalForSize(
268
+ this.open.entryFill ?? this.open.entry,
269
+ this.open.size,
270
+ this.options.maxLeverage
271
+ );
272
+ }
273
+
274
+ getMarkPrice() {
275
+ return this.lastBar?.close ?? null;
276
+ }
277
+
278
+ getMarkedEquity() {
279
+ if (!this.open || !this.lastBar) return this.currentEquity;
280
+ const direction = this.open.side === "long" ? 1 : -1;
281
+ const markPnl =
282
+ (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
283
+ return this.currentEquity + markPnl;
284
+ }
285
+
286
+ recordFrame(bar, extraFrame = {}) {
287
+ if (this.wantEqSeries) {
288
+ this.eqSeries.push(equityPoint(bar.time, this.currentEquity));
289
+ }
290
+
291
+ if (this.wantReplay) {
292
+ this.replayFrames.push({
293
+ t: new Date(bar.time).toISOString(),
294
+ price: bar.close,
295
+ equity: this.currentEquity,
296
+ posSide: this.open ? this.open.side : null,
297
+ posSize: this.open ? this.open.size : 0,
298
+ ...extraFrame,
299
+ });
300
+ }
301
+ }
302
+
303
+ closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
304
+ const direction = openPos.side === "long" ? 1 : -1;
305
+ const entryFill = openPos.entryFill;
306
+ const grossPnl = (exitPx - entryFill) * direction * qty;
307
+ const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
308
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal;
309
+
310
+ this.currentEquity += pnl;
311
+ this.dayPnl += pnl;
312
+
313
+ if (this.wantEqSeries) {
314
+ this.eqSeries.push(equityPoint(time, this.currentEquity));
315
+ }
316
+
317
+ const remaining = openPos.size - qty;
318
+ const eventType =
319
+ reason === "SCALE"
320
+ ? "scale-out"
321
+ : reason === "TP"
322
+ ? "tp"
323
+ : reason === "SL"
324
+ ? "sl"
325
+ : reason === "EOD"
326
+ ? "eod"
327
+ : remaining <= 0
328
+ ? "exit"
329
+ : "scale-out";
330
+
331
+ if (this.wantReplay) {
332
+ this.replayEvents.push({
333
+ t: new Date(time).toISOString(),
334
+ price: exitPx,
335
+ type: eventType,
336
+ side: openPos.side,
337
+ size: qty,
338
+ tradeId: openPos.id,
339
+ reason,
340
+ pnl,
341
+ symbol: this.symbol,
342
+ });
343
+ }
344
+
345
+ const record = {
346
+ ...openPos,
347
+ size: qty,
348
+ exit: {
349
+ price: exitPx,
350
+ time,
351
+ reason,
352
+ pnl,
353
+ exitATR: openPos._lastATR ?? undefined,
354
+ },
355
+ mfeR: openPos._mfeR ?? 0,
356
+ maeR: openPos._maeR ?? 0,
357
+ adds: openPos._adds ?? 0,
358
+ };
359
+
360
+ this.closed.push(record);
361
+ openPos.size -= qty;
362
+ openPos._realized = (openPos._realized || 0) + pnl;
363
+ return record;
364
+ }
365
+
366
+ tightenStopToNetBreakeven(openPos, lastClose) {
367
+ if (!openPos || openPos.size <= 0) return;
368
+ const realized = openPos._realized || 0;
369
+ if (realized <= 0) return;
370
+
371
+ const direction = openPos.side === "long" ? 1 : -1;
372
+ const breakevenDelta = Math.abs(realized / openPos.size);
373
+ const breakevenPrice =
374
+ direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
375
+
376
+ const tightened =
377
+ direction === 1
378
+ ? Math.max(openPos.stop, breakevenPrice)
379
+ : Math.min(openPos.stop, breakevenPrice);
380
+
381
+ openPos.stop = this.options.oco.clampStops
382
+ ? clampStop(lastClose, tightened, openPos.side, this.options.oco)
383
+ : tightened;
384
+ }
385
+
386
+ forceExit(reason, bar, overridePrice = null) {
387
+ if (!this.open || !bar) return;
388
+
389
+ const exitSide = this.open.side === "long" ? "short" : "long";
390
+ const exitBasePrice = overridePrice ?? bar.close;
391
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(exitBasePrice, exitSide, {
392
+ slippageBps: this.options.slippageBps,
393
+ feeBps: this.options.feeBps,
394
+ kind: "market",
395
+ qty: this.open.size,
396
+ costs: this.options.costs,
397
+ });
398
+
399
+ this.closeLeg({
400
+ openPos: this.open,
401
+ qty: this.open.size,
402
+ exitPx: filled,
403
+ exitFeeTotal,
404
+ time: bar.time,
405
+ reason,
406
+ });
407
+
408
+ this.cooldown = this.open?._cooldownBars || 0;
409
+ this.open = null;
410
+ }
411
+
412
+ cancelPending() {
413
+ this.pending = null;
414
+ }
415
+
416
+ openFromPending(bar, signalEquity, entryPrice, fillKind = "limit", resolveEntrySize) {
417
+ if (!this.pending) return false;
418
+
419
+ const plannedRisk = Math.max(
420
+ 1e-8,
421
+ this.pending.plannedRiskAbs ?? Math.abs(this.pending.entry - this.pending.stop)
422
+ );
423
+ const slipR = Math.abs(entryPrice - this.pending.entry) / plannedRisk;
424
+ if (slipR > this.options.maxSlipROnFill) return false;
425
+
426
+ let stopPrice = this.pending.stop;
427
+ if (this.options.reanchorStopOnFill) {
428
+ const direction = this.pending.side === "long" ? 1 : -1;
429
+ stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
430
+ }
431
+
432
+ let takeProfit = this.pending.tp;
433
+ const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
434
+ const rrHint = this.pending.meta?._rr;
435
+
436
+ if (this.options.reanchorStopOnFill && Number.isFinite(rrHint)) {
437
+ const plannedTarget =
438
+ this.pending.side === "long"
439
+ ? this.pending.entry + rrHint * plannedRisk
440
+ : this.pending.entry - rrHint * plannedRisk;
441
+ const closeEnough =
442
+ Math.abs((this.pending.tp ?? plannedTarget) - plannedTarget) <=
443
+ Math.max(1e-8, plannedRisk * 1e-6);
444
+
445
+ if (closeEnough) {
446
+ takeProfit =
447
+ this.pending.side === "long"
448
+ ? entryPrice + rrHint * immediateRisk
449
+ : entryPrice - rrHint * immediateRisk;
450
+ }
451
+ }
452
+
453
+ const desiredSize =
454
+ this.pending.fixedQty ??
455
+ calculatePositionSize({
456
+ equity: signalEquity,
457
+ entry: entryPrice,
458
+ stop: stopPrice,
459
+ riskFraction: this.pending.riskFrac,
460
+ qtyStep: this.options.qtyStep,
461
+ minQty: this.options.minQty,
462
+ maxLeverage: this.options.maxLeverage,
463
+ });
464
+
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;
476
+ const size = roundStep(approvedSize, this.options.qtyStep);
477
+ if (size < this.options.minQty) return false;
478
+
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
+ });
486
+
487
+ this.open = {
488
+ symbol: this.symbol,
489
+ ...this.pending.meta,
490
+ id: ++this.tradeIdCounter,
491
+ side: this.pending.side,
492
+ entry: entryPrice,
493
+ stop: stopPrice,
494
+ takeProfit,
495
+ size,
496
+ openTime: bar.time,
497
+ entryFill,
498
+ entryFeeTotal,
499
+ initSize: size,
500
+ baseSize: size,
501
+ _mfeR: 0,
502
+ _maeR: 0,
503
+ _adds: 0,
504
+ _initRisk: Math.abs(entryPrice - stopPrice) || 1e-8,
505
+ };
506
+
507
+ if (this.atrValues && this.atrValues[this.index] !== undefined) {
508
+ this.open.entryATR = this.atrValues[this.index];
509
+ this.open._lastATR = this.atrValues[this.index];
510
+ }
511
+
512
+ this.dayTrades += 1;
513
+ this.pending = null;
514
+
515
+ if (this.wantReplay) {
516
+ this.replayEvents.push({
517
+ t: new Date(bar.time).toISOString(),
518
+ price: entryFill,
519
+ type: "entry",
520
+ side: this.open.side,
521
+ size,
522
+ tradeId: this.open.id,
523
+ symbol: this.symbol,
524
+ });
525
+ }
526
+
527
+ return true;
528
+ }
529
+
530
+ buildSignalContext(index, bar, signalEquity) {
531
+ if (this.options.strict && this.history.length !== index + 1) {
532
+ throw new Error(
533
+ `strict mode: signal() received ${this.history.length} candles at index ${index}`
534
+ );
535
+ }
536
+
537
+ return {
538
+ candles: this.options.strict ? strictHistoryView(this.history, index) : this.history,
539
+ index,
540
+ bar,
541
+ equity: signalEquity,
542
+ openPosition: this.open,
543
+ pendingOrder: this.pending,
544
+ };
545
+ }
546
+
547
+ step({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
548
+ if (!this.hasNext()) return null;
549
+
550
+ const bar = this.candles[this.index];
551
+ this.history.push(bar);
552
+ this.lastBar = bar;
553
+
554
+ const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
555
+ const dayKey =
556
+ this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC(bar.time);
557
+ if (this.currentDay === null || dayKey !== this.currentDay) {
558
+ this.currentDay = dayKey;
559
+ this.dayPnl = 0;
560
+ this.dayTrades = 0;
561
+ this.dayEquityStart = this.currentEquity;
562
+ }
563
+
564
+ if (this.open && this.open._maxBarsInTrade > 0) {
565
+ const barsHeld = Math.max(
566
+ 1,
567
+ Math.round((bar.time - this.open.openTime) / this.estimatedBarMs)
568
+ );
569
+ if (barsHeld >= this.open._maxBarsInTrade) {
570
+ this.forceExit("TIME", bar);
571
+ }
572
+ }
573
+
574
+ if (this.open && Number.isFinite(this.open._maxHoldMin) && this.open._maxHoldMin > 0) {
575
+ const heldMinutes = (bar.time - this.open.openTime) / 60000;
576
+ if (heldMinutes >= this.open._maxHoldMin) {
577
+ this.forceExit("TIME", bar);
578
+ }
579
+ }
580
+
581
+ if (this.options.flattenAtClose && this.open && isEODBar(bar.time)) {
582
+ this.forceExit("EOD", bar);
583
+ }
584
+
585
+ if (this.open) {
586
+ const risk = this.open._initRisk || 1e-8;
587
+ const highR =
588
+ this.open.side === "long"
589
+ ? (bar.high - this.open.entry) / risk
590
+ : (this.open.entry - bar.low) / risk;
591
+ const lowR =
592
+ this.open.side === "long"
593
+ ? (bar.low - this.open.entry) / risk
594
+ : (this.open.entry - bar.high) / risk;
595
+ const markR =
596
+ this.open.side === "long"
597
+ ? (bar.close - this.open.entry) / risk
598
+ : (this.open.entry - bar.close) / risk;
599
+
600
+ if (this.atrValues && this.atrValues[this.index] !== undefined) {
601
+ this.open._lastATR = this.atrValues[this.index];
602
+ }
603
+
604
+ this.open._mfeR = Math.max(this.open._mfeR ?? -Infinity, highR);
605
+ this.open._maeR = Math.min(this.open._maeR ?? Infinity, lowR);
606
+
607
+ if (this.open._breakevenAtR > 0 && highR >= this.open._breakevenAtR && !this.open._beArmed) {
608
+ const tightened =
609
+ this.open.side === "long"
610
+ ? Math.max(this.open.stop, this.open.entry)
611
+ : Math.min(this.open.stop, this.open.entry);
612
+ this.open.stop = this.options.oco.clampStops
613
+ ? clampStop(bar.close, tightened, this.open.side, this.options.oco)
614
+ : tightened;
615
+ this.open._beArmed = true;
616
+ }
617
+
618
+ if (this.open._trailAfterR > 0 && highR >= this.open._trailAfterR) {
619
+ const candidate = this.open.side === "long" ? bar.close - risk : bar.close + risk;
620
+ const tightened =
621
+ this.open.side === "long"
622
+ ? Math.max(this.open.stop, candidate)
623
+ : Math.min(this.open.stop, candidate);
624
+ this.open.stop = this.options.oco.clampStops
625
+ ? clampStop(bar.close, tightened, this.open.side, this.options.oco)
626
+ : tightened;
627
+ }
628
+
629
+ if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
630
+ const targetR = Math.max(0, this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR));
631
+ const candidate =
632
+ this.open.side === "long"
633
+ ? this.open.entry + targetR * risk
634
+ : this.open.entry - targetR * risk;
635
+ const tightened =
636
+ this.open.side === "long"
637
+ ? Math.max(this.open.stop, candidate)
638
+ : Math.min(this.open.stop, candidate);
639
+ this.open.stop = this.options.oco.clampStops
640
+ ? clampStop(bar.close, tightened, this.open.side, this.options.oco)
641
+ : tightened;
642
+ }
643
+
644
+ if (
645
+ this.options.atrTrailMult > 0 &&
646
+ this.atrValues &&
647
+ this.atrValues[this.index] !== undefined
648
+ ) {
649
+ const trailDistance = this.atrValues[this.index] * this.options.atrTrailMult;
650
+ const candidate =
651
+ this.open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
652
+ const tightened =
653
+ this.open.side === "long"
654
+ ? Math.max(this.open.stop, candidate)
655
+ : Math.min(this.open.stop, candidate);
656
+ this.open.stop = this.options.oco.clampStops
657
+ ? clampStop(bar.close, tightened, this.open.side, this.options.oco)
658
+ : tightened;
659
+ }
660
+
661
+ if (
662
+ this.options.volScale.enabled &&
663
+ this.open.entryATR &&
664
+ this.open.size > this.options.minQty &&
665
+ this.atrValues &&
666
+ this.atrValues[this.index] !== undefined
667
+ ) {
668
+ const ratio = this.atrValues[this.index] / Math.max(1e-12, this.open.entryATR);
669
+ const shouldCut =
670
+ ratio >= this.options.volScale.cutIfAtrX &&
671
+ markR < this.options.volScale.noCutAboveR &&
672
+ !this.open._volCutDone;
673
+
674
+ if (shouldCut) {
675
+ const cutQty = roundStep(
676
+ this.open.size * this.options.volScale.cutFrac,
677
+ this.options.qtyStep
678
+ );
679
+ if (cutQty >= this.options.minQty && cutQty < this.open.size) {
680
+ const exitSide = this.open.side === "long" ? "short" : "long";
681
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
682
+ slippageBps: this.options.slippageBps,
683
+ feeBps: this.options.feeBps,
684
+ kind: "market",
685
+ qty: cutQty,
686
+ costs: this.options.costs,
687
+ });
688
+ this.closeLeg({
689
+ openPos: this.open,
690
+ qty: cutQty,
691
+ exitPx: filled,
692
+ exitFeeTotal,
693
+ time: bar.time,
694
+ reason: "SCALE",
695
+ });
696
+ this.tightenStopToNetBreakeven(this.open, bar.close);
697
+ this.open._volCutDone = true;
698
+ }
699
+ }
700
+ }
701
+
702
+ let addedThisBar = false;
703
+ if (
704
+ this.options.pyramiding.enabled &&
705
+ (this.open._adds ?? 0) < this.options.pyramiding.maxAdds
706
+ ) {
707
+ const addNumber = (this.open._adds || 0) + 1;
708
+ const triggerR = this.options.pyramiding.addAtR * addNumber;
709
+ const triggerPrice =
710
+ this.open.side === "long"
711
+ ? this.open.entry + triggerR * risk
712
+ : this.open.entry - triggerR * risk;
713
+ const breakEvenSatisfied =
714
+ !this.options.pyramiding.onlyAfterBreakEven ||
715
+ (this.open.side === "long" && this.open.stop >= this.open.entry) ||
716
+ (this.open.side === "short" && this.open.stop <= this.open.entry);
717
+ const touched =
718
+ this.open.side === "long"
719
+ ? trigger === "intrabar"
720
+ ? bar.high >= triggerPrice
721
+ : bar.close >= triggerPrice
722
+ : trigger === "intrabar"
723
+ ? bar.low <= triggerPrice
724
+ : bar.close <= triggerPrice;
725
+
726
+ if (breakEvenSatisfied && touched) {
727
+ const baseSize = this.open.baseSize || this.open.initSize;
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;
750
+ if (addQty >= this.options.minQty) {
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
+ );
762
+ const newSize = this.open.size + addQty;
763
+ this.open.entryFeeTotal += addFeeTotal;
764
+ this.open.entryFill =
765
+ (this.open.entryFill * this.open.size + addFill * addQty) / newSize;
766
+ this.open.size = newSize;
767
+ this.open.initSize += addQty;
768
+ if (!this.open.baseSize) this.open.baseSize = baseSize;
769
+ this.open._adds = addNumber;
770
+ addedThisBar = true;
771
+ }
772
+ }
773
+ }
774
+
775
+ if (!addedThisBar && !this.open._scaled && this.options.scaleOutAtR > 0) {
776
+ const triggerPrice =
777
+ this.open.side === "long"
778
+ ? this.open.entry + this.options.scaleOutAtR * risk
779
+ : this.open.entry - this.options.scaleOutAtR * risk;
780
+ const touched =
781
+ this.open.side === "long"
782
+ ? trigger === "intrabar"
783
+ ? bar.high >= triggerPrice
784
+ : bar.close >= triggerPrice
785
+ : trigger === "intrabar"
786
+ ? bar.low <= triggerPrice
787
+ : bar.close <= triggerPrice;
788
+
789
+ if (touched) {
790
+ const exitSide = this.open.side === "long" ? "short" : "long";
791
+ const qty = roundStep(this.open.size * this.options.scaleOutFrac, this.options.qtyStep);
792
+ if (qty >= this.options.minQty && qty < this.open.size) {
793
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide, {
794
+ slippageBps: this.options.slippageBps,
795
+ feeBps: this.options.feeBps,
796
+ kind: "limit",
797
+ qty,
798
+ costs: this.options.costs,
799
+ });
800
+ this.closeLeg({
801
+ openPos: this.open,
802
+ qty,
803
+ exitPx: filled,
804
+ exitFeeTotal,
805
+ time: bar.time,
806
+ reason: "SCALE",
807
+ });
808
+ this.open._scaled = true;
809
+ this.open.takeProfit =
810
+ this.open.side === "long"
811
+ ? this.open.entry + this.options.finalTP_R * risk
812
+ : this.open.entry - this.options.finalTP_R * risk;
813
+ this.tightenStopToNetBreakeven(this.open, bar.close);
814
+ this.open._beArmed = true;
815
+ }
816
+ }
817
+ }
818
+
819
+ const exitSide = this.open.side === "long" ? "short" : "long";
820
+ const { hit, px } = ocoExitCheck({
821
+ side: this.open.side,
822
+ stop: this.open.stop,
823
+ tp: this.open.takeProfit,
824
+ bar,
825
+ mode: this.options.oco.mode,
826
+ tieBreak: this.options.oco.tieBreak,
827
+ });
828
+
829
+ if (hit) {
830
+ const exitKind = hit === "TP" ? "limit" : "stop";
831
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
832
+ slippageBps: this.options.slippageBps,
833
+ feeBps: this.options.feeBps,
834
+ kind: exitKind,
835
+ qty: this.open.size,
836
+ costs: this.options.costs,
837
+ });
838
+ const localCooldown = this.open._cooldownBars || 0;
839
+ this.closeLeg({
840
+ openPos: this.open,
841
+ qty: this.open.size,
842
+ exitPx: filled,
843
+ exitFeeTotal,
844
+ time: bar.time,
845
+ reason: hit,
846
+ });
847
+ this.cooldown =
848
+ (hit === "SL"
849
+ ? Math.max(this.cooldown, this.options.postLossCooldownBars)
850
+ : this.cooldown) || localCooldown;
851
+ this.open = null;
852
+ }
853
+ }
854
+
855
+ const maxLossDollars = (this.options.maxDailyLossPct / 100) * this.dayEquityStart;
856
+ const dailyLossHit = this.dayPnl <= -Math.abs(maxLossDollars);
857
+ const dailyTradeCapHit =
858
+ this.options.dailyMaxTrades > 0 && this.dayTrades >= this.options.dailyMaxTrades;
859
+
860
+ if (!this.open && this.pending) {
861
+ if (!canTrade) {
862
+ this.pending = null;
863
+ } else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
864
+ if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
865
+ const riskAtEdge = Math.abs(
866
+ this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
867
+ );
868
+ const priceNow = bar.close;
869
+ const direction = this.pending.side === "long" ? 1 : -1;
870
+ const slippedR =
871
+ Math.max(
872
+ 0,
873
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
874
+ ) / Math.max(1e-8, riskAtEdge);
875
+
876
+ if (slippedR > this.options.maxSlipROnFill) {
877
+ this.pending = null;
878
+ } else if (
879
+ !this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)
880
+ ) {
881
+ this.pending = null;
882
+ }
883
+ } else {
884
+ this.pending = null;
885
+ }
886
+ } else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
887
+ if (
888
+ !this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
889
+ ) {
890
+ this.pending = null;
891
+ }
892
+ } else if (this.options.entryChase.enabled) {
893
+ const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
894
+ const midpoint = asNumber(this.pending.meta?._imb?.mid);
895
+
896
+ if (
897
+ !this.pending._chasedCE &&
898
+ midpoint !== null &&
899
+ elapsedBars >= Math.max(1, this.options.entryChase.afterBars)
900
+ ) {
901
+ this.pending.entry = midpoint;
902
+ this.pending._chasedCE = true;
903
+ }
904
+
905
+ if (this.pending._chasedCE) {
906
+ const riskRef = Math.abs(
907
+ this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
908
+ );
909
+ const priceNow = bar.close;
910
+ const direction = this.pending.side === "long" ? 1 : -1;
911
+ const slippedR =
912
+ Math.max(
913
+ 0,
914
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
915
+ ) / Math.max(1e-8, riskRef);
916
+
917
+ if (slippedR > this.options.maxSlipROnFill) {
918
+ this.pending = null;
919
+ } else if (slippedR > 0 && slippedR <= this.options.entryChase.maxSlipR) {
920
+ if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
921
+ this.pending = null;
922
+ }
923
+ }
924
+ }
925
+ }
926
+ }
927
+
928
+ if (this.open || this.cooldown > 0) {
929
+ if (this.cooldown > 0) this.cooldown -= 1;
930
+ this.recordFrame(bar);
931
+ this.index += 1;
932
+ return bar;
933
+ }
934
+
935
+ if (!canTrade || dailyLossHit || dailyTradeCapHit) {
936
+ this.pending = null;
937
+ this.recordFrame(bar);
938
+ this.index += 1;
939
+ return bar;
940
+ }
941
+
942
+ if (!this.pending) {
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
+ });
950
+ const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
951
+
952
+ if (nextSignal) {
953
+ const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
954
+ ? nextSignal.riskFraction
955
+ : Number.isFinite(nextSignal.riskPct)
956
+ ? nextSignal.riskPct / 100
957
+ : this.options.riskPct / 100;
958
+ const expiryBars = nextSignal._entryExpiryBars ?? 5;
959
+ this.pending = {
960
+ side: nextSignal.side,
961
+ entry: nextSignal.entry,
962
+ stop: nextSignal.stop,
963
+ tp: nextSignal.takeProfit,
964
+ riskFrac: signalRiskFraction,
965
+ fixedQty: nextSignal.qty,
966
+ expiresAt: this.index + Math.max(1, expiryBars),
967
+ startedAtIndex: this.index,
968
+ meta: nextSignal,
969
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
970
+ };
971
+
972
+ if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
973
+ if (
974
+ !this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
975
+ ) {
976
+ this.pending = null;
977
+ }
978
+ }
979
+ }
980
+ }
981
+
982
+ this.recordFrame(bar);
983
+ this.index += 1;
984
+ return bar;
985
+ }
986
+
987
+ buildResult() {
988
+ const metrics = buildMetrics({
989
+ closed: this.closed,
990
+ equityStart: this.options.equity,
991
+ equityFinal: this.currentEquity,
992
+ candles: this.candles,
993
+ estBarMs: this.estimatedBarMs,
994
+ eqSeries: this.eqSeries,
995
+ });
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
+ : [];
1001
+
1002
+ return {
1003
+ symbol: this.options.symbol,
1004
+ interval: this.options.interval,
1005
+ range: this.options.range,
1006
+ trades: this.closed,
1007
+ positions,
1008
+ openPositions,
1009
+ metrics,
1010
+ eqSeries: this.eqSeries,
1011
+ replay: {
1012
+ frames: this.replayFrames,
1013
+ events: this.replayEvents,
1014
+ },
1015
+ };
1016
+ }
1017
+ }
1018
+
1019
+ export function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct) {
1020
+ const limits = [];
1021
+ if (Number.isFinite(capPct) && capPct > 0) limits.push(totalEquity * capPct);
1022
+ if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
1023
+ if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
1024
+ limits.push(totalEquity * maxAllocationPct);
1025
+ }
1026
+ return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
1027
+ }