tradelab 0.1.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.
@@ -0,0 +1,852 @@
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 mergeOptions(options) {
23
+ const normalizedRiskPct = Number.isFinite(options.riskFraction)
24
+ ? options.riskFraction * 100
25
+ : options.riskPct;
26
+
27
+ return {
28
+ candles: normalizeCandles(options.candles ?? []),
29
+ symbol: options.symbol ?? "UNKNOWN",
30
+ equity: options.equity ?? 10_000,
31
+ riskPct: normalizedRiskPct ?? 1,
32
+ signal: options.signal,
33
+ interval: options.interval,
34
+ range: options.range,
35
+ warmupBars: options.warmupBars ?? 200,
36
+ slippageBps: options.slippageBps ?? 1,
37
+ feeBps: options.feeBps ?? 0,
38
+ scaleOutAtR: options.scaleOutAtR ?? 1,
39
+ scaleOutFrac: options.scaleOutFrac ?? 0.5,
40
+ finalTP_R: options.finalTP_R ?? 3,
41
+ maxDailyLossPct: options.maxDailyLossPct ?? 2,
42
+ atrTrailMult: options.atrTrailMult ?? 0,
43
+ atrTrailPeriod: options.atrTrailPeriod ?? 14,
44
+ oco: {
45
+ mode: "intrabar",
46
+ tieBreak: "pessimistic",
47
+ clampStops: true,
48
+ clampEpsBps: 0.25,
49
+ ...(options.oco || {}),
50
+ },
51
+ triggerMode: options.triggerMode,
52
+ flattenAtClose: options.flattenAtClose ?? true,
53
+ dailyMaxTrades: options.dailyMaxTrades ?? 0,
54
+ postLossCooldownBars: options.postLossCooldownBars ?? 0,
55
+ mfeTrail: {
56
+ enabled: false,
57
+ armR: 1,
58
+ givebackR: 0.5,
59
+ ...(options.mfeTrail || {}),
60
+ },
61
+ pyramiding: {
62
+ enabled: false,
63
+ addAtR: 1,
64
+ addFrac: 0.25,
65
+ maxAdds: 1,
66
+ onlyAfterBreakEven: true,
67
+ ...(options.pyramiding || {}),
68
+ },
69
+ volScale: {
70
+ enabled: false,
71
+ atrPeriod: options.atrTrailPeriod ?? 14,
72
+ cutIfAtrX: 1.3,
73
+ cutFrac: 0.33,
74
+ noCutAboveR: 1.5,
75
+ ...(options.volScale || {}),
76
+ },
77
+ qtyStep: options.qtyStep ?? 0.001,
78
+ minQty: options.minQty ?? 0.001,
79
+ maxLeverage: options.maxLeverage ?? 2,
80
+ entryChase: {
81
+ enabled: true,
82
+ afterBars: 2,
83
+ maxSlipR: 0.2,
84
+ convertOnExpiry: false,
85
+ ...(options.entryChase || {}),
86
+ },
87
+ reanchorStopOnFill: options.reanchorStopOnFill ?? true,
88
+ maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
89
+ collectEqSeries: options.collectEqSeries ?? true,
90
+ collectReplay: options.collectReplay ?? true,
91
+ };
92
+ }
93
+
94
+ function normalizeSide(value) {
95
+ if (value === "long" || value === "buy") return "long";
96
+ if (value === "short" || value === "sell") return "short";
97
+ return null;
98
+ }
99
+
100
+ function normalizeSignal(signal, bar, fallbackR) {
101
+ if (!signal) return null;
102
+
103
+ const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
104
+ if (!side) return null;
105
+
106
+ const entry =
107
+ asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
108
+ const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
109
+ if (entry === null || stop === null) return null;
110
+
111
+ const risk = Math.abs(entry - stop);
112
+ if (!(risk > 0)) return null;
113
+
114
+ let takeProfit = asNumber(signal.takeProfit ?? signal.target ?? signal.tp);
115
+ const rrHint = asNumber(signal._rr ?? signal.rr);
116
+ const targetR = rrHint ?? fallbackR;
117
+
118
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
119
+ takeProfit =
120
+ side === "long" ? entry + risk * targetR : entry - risk * targetR;
121
+ }
122
+ if (takeProfit === null) return null;
123
+
124
+ return {
125
+ ...signal,
126
+ side,
127
+ entry,
128
+ stop,
129
+ takeProfit,
130
+ qty: asNumber(signal.qty ?? signal.size),
131
+ riskPct: asNumber(signal.riskPct),
132
+ riskFraction: asNumber(signal.riskFraction),
133
+ _rr: rrHint ?? signal._rr,
134
+ _initRisk: asNumber(signal._initRisk) ?? signal._initRisk,
135
+ };
136
+ }
137
+
138
+ export function backtest(rawOptions) {
139
+ const options = mergeOptions(rawOptions || {});
140
+ const {
141
+ candles,
142
+ symbol,
143
+ equity,
144
+ riskPct,
145
+ signal,
146
+ slippageBps,
147
+ feeBps,
148
+ scaleOutAtR,
149
+ scaleOutFrac,
150
+ finalTP_R,
151
+ maxDailyLossPct,
152
+ atrTrailMult,
153
+ atrTrailPeriod,
154
+ oco,
155
+ triggerMode,
156
+ flattenAtClose,
157
+ dailyMaxTrades,
158
+ postLossCooldownBars,
159
+ mfeTrail,
160
+ pyramiding,
161
+ volScale,
162
+ qtyStep,
163
+ minQty,
164
+ maxLeverage,
165
+ entryChase,
166
+ reanchorStopOnFill,
167
+ maxSlipROnFill,
168
+ collectEqSeries,
169
+ collectReplay,
170
+ warmupBars,
171
+ } = options;
172
+
173
+ if (!Array.isArray(candles) || candles.length === 0) {
174
+ throw new Error("backtest() requires a non-empty candles array");
175
+ }
176
+
177
+ if (typeof signal !== "function") {
178
+ throw new Error("backtest() requires a signal function");
179
+ }
180
+
181
+ const closed = [];
182
+ let currentEquity = equity;
183
+ let open = null;
184
+ let cooldown = 0;
185
+ let pending = null;
186
+
187
+ let currentDay = null;
188
+ let dayPnl = 0;
189
+ let dayTrades = 0;
190
+ let dayEquityStart = equity;
191
+
192
+ const wantReplay = Boolean(collectReplay);
193
+ const wantEqSeries = Boolean(collectEqSeries);
194
+ const estimatedBarMs = estimateBarMs(candles);
195
+ const atrSourcePeriod = volScale.enabled ? volScale.atrPeriod : atrTrailPeriod;
196
+ const needAtr = atrTrailMult > 0 || volScale.enabled;
197
+ const atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
198
+
199
+ const eqSeries = wantEqSeries ? [{ time: candles[0].time, equity: currentEquity }] : [];
200
+ const replayFrames = wantReplay ? [] : [];
201
+ const replayEvents = wantReplay ? [] : [];
202
+ let tradeIdCounter = 0;
203
+
204
+ const useVolScale = Boolean(volScale.enabled);
205
+ const useAtrTrail = atrTrailMult > 0;
206
+ const useMfeTrail = Boolean(mfeTrail.enabled);
207
+ const usePyramiding = Boolean(pyramiding.enabled);
208
+ const trigger = triggerMode || oco.mode || "intrabar";
209
+
210
+ function recordFrame(bar) {
211
+ if (wantEqSeries) {
212
+ eqSeries.push({ time: bar.time, equity: currentEquity });
213
+ }
214
+
215
+ if (wantReplay) {
216
+ replayFrames.push({
217
+ t: new Date(bar.time).toISOString(),
218
+ price: bar.close,
219
+ equity: currentEquity,
220
+ posSide: open ? open.side : null,
221
+ posSize: open ? open.size : 0,
222
+ });
223
+ }
224
+ }
225
+
226
+ function closeLeg({ openPos, qty, exitPx, exitFeePerUnit, time, reason }) {
227
+ const direction = openPos.side === "long" ? 1 : -1;
228
+ const entryFill = openPos.entryFill;
229
+ const grossPnl = (exitPx - entryFill) * direction * qty;
230
+ const entryFeePortion =
231
+ (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
232
+ const exitFeeTotal = exitFeePerUnit * qty;
233
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal;
234
+
235
+ currentEquity += pnl;
236
+ dayPnl += pnl;
237
+
238
+ if (wantEqSeries) {
239
+ eqSeries.push({ time, equity: currentEquity });
240
+ }
241
+
242
+ const remaining = openPos.size - qty;
243
+ const eventType =
244
+ reason === "SCALE"
245
+ ? "scale-out"
246
+ : reason === "TP"
247
+ ? "tp"
248
+ : reason === "SL"
249
+ ? "sl"
250
+ : reason === "EOD"
251
+ ? "eod"
252
+ : remaining <= 0
253
+ ? "exit"
254
+ : "scale-out";
255
+
256
+ if (wantReplay) {
257
+ replayEvents.push({
258
+ t: new Date(time).toISOString(),
259
+ price: exitPx,
260
+ type: eventType,
261
+ side: openPos.side,
262
+ size: qty,
263
+ tradeId: openPos.id,
264
+ reason,
265
+ pnl,
266
+ });
267
+ }
268
+
269
+ const record = {
270
+ ...openPos,
271
+ size: qty,
272
+ exit: {
273
+ price: exitPx,
274
+ time,
275
+ reason,
276
+ pnl,
277
+ exitATR: openPos._lastATR ?? undefined,
278
+ },
279
+ mfeR: openPos._mfeR ?? 0,
280
+ maeR: openPos._maeR ?? 0,
281
+ adds: openPos._adds ?? 0,
282
+ };
283
+
284
+ closed.push(record);
285
+ openPos.size -= qty;
286
+ openPos._realized = (openPos._realized || 0) + pnl;
287
+ return record;
288
+ }
289
+
290
+ function tightenStopToNetBreakeven(openPos, lastClose) {
291
+ if (!openPos || openPos.size <= 0) return;
292
+ const realized = openPos._realized || 0;
293
+ if (realized <= 0) return;
294
+
295
+ const direction = openPos.side === "long" ? 1 : -1;
296
+ const breakevenDelta = Math.abs(realized / openPos.size);
297
+ const breakevenPrice =
298
+ direction === 1
299
+ ? openPos.entryFill - breakevenDelta
300
+ : openPos.entryFill + breakevenDelta;
301
+
302
+ const tightened =
303
+ direction === 1
304
+ ? Math.max(openPos.stop, breakevenPrice)
305
+ : Math.min(openPos.stop, breakevenPrice);
306
+
307
+ openPos.stop = oco.clampStops
308
+ ? clampStop(lastClose, tightened, openPos.side, oco)
309
+ : tightened;
310
+ }
311
+
312
+ function forceExit(reason, bar) {
313
+ if (!open) return;
314
+
315
+ const exitSide = open.side === "long" ? "short" : "long";
316
+ const { price: filled, fee: exitFee } = applyFill(bar.close, exitSide, {
317
+ slippageBps,
318
+ feeBps,
319
+ kind: "market",
320
+ });
321
+
322
+ closeLeg({
323
+ openPos: open,
324
+ qty: open.size,
325
+ exitPx: filled,
326
+ exitFeePerUnit: exitFee,
327
+ time: bar.time,
328
+ reason,
329
+ });
330
+
331
+ cooldown = open._cooldownBars || 0;
332
+ open = null;
333
+ }
334
+
335
+ function openFromPending(bar, index, entryPrice, fillKind = "limit") {
336
+ if (!pending) return false;
337
+
338
+ const plannedRisk = Math.max(
339
+ 1e-8,
340
+ pending.plannedRiskAbs ?? Math.abs(pending.entry - pending.stop)
341
+ );
342
+ const slipR = Math.abs(entryPrice - pending.entry) / plannedRisk;
343
+ if (slipR > maxSlipROnFill) return false;
344
+
345
+ let stopPrice = pending.stop;
346
+ if (reanchorStopOnFill) {
347
+ const direction = pending.side === "long" ? 1 : -1;
348
+ stopPrice =
349
+ direction === 1
350
+ ? entryPrice - plannedRisk
351
+ : entryPrice + plannedRisk;
352
+ }
353
+
354
+ let takeProfit = pending.tp;
355
+ const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
356
+ const rrHint = pending.meta?._rr;
357
+
358
+ if (reanchorStopOnFill && Number.isFinite(rrHint)) {
359
+ const plannedTarget =
360
+ pending.side === "long"
361
+ ? pending.entry + rrHint * plannedRisk
362
+ : pending.entry - rrHint * plannedRisk;
363
+ const closeEnough =
364
+ Math.abs((pending.tp ?? plannedTarget) - plannedTarget) <=
365
+ Math.max(1e-8, plannedRisk * 1e-6);
366
+
367
+ if (closeEnough) {
368
+ takeProfit =
369
+ pending.side === "long"
370
+ ? entryPrice + rrHint * immediateRisk
371
+ : entryPrice - rrHint * immediateRisk;
372
+ }
373
+ }
374
+
375
+ const rawSize =
376
+ pending.fixedQty ??
377
+ calculatePositionSize({
378
+ equity: currentEquity,
379
+ entry: entryPrice,
380
+ stop: stopPrice,
381
+ riskFraction: pending.riskFrac,
382
+ qtyStep,
383
+ minQty,
384
+ maxLeverage,
385
+ });
386
+ const size = roundStep(rawSize, qtyStep);
387
+ if (size < minQty) return false;
388
+
389
+ const { price: entryFill, fee: entryFee } = applyFill(
390
+ entryPrice,
391
+ pending.side,
392
+ {
393
+ slippageBps,
394
+ feeBps,
395
+ kind: fillKind,
396
+ }
397
+ );
398
+
399
+ open = {
400
+ symbol,
401
+ ...pending.meta,
402
+ id: ++tradeIdCounter,
403
+ side: pending.side,
404
+ entry: entryPrice,
405
+ stop: stopPrice,
406
+ takeProfit,
407
+ size,
408
+ openTime: bar.time,
409
+ entryFill,
410
+ entryFeeTotal: entryFee * size,
411
+ initSize: size,
412
+ baseSize: size,
413
+ _mfeR: 0,
414
+ _maeR: 0,
415
+ _adds: 0,
416
+ _initRisk: Math.abs(entryPrice - stopPrice) || 1e-8,
417
+ };
418
+
419
+ if (atrValues && atrValues[index] !== undefined) {
420
+ open.entryATR = atrValues[index];
421
+ open._lastATR = atrValues[index];
422
+ }
423
+
424
+ dayTrades += 1;
425
+ pending = null;
426
+
427
+ if (wantReplay) {
428
+ replayEvents.push({
429
+ t: new Date(bar.time).toISOString(),
430
+ price: entryFill,
431
+ type: "entry",
432
+ side: open.side,
433
+ size,
434
+ tradeId: open.id,
435
+ });
436
+ }
437
+
438
+ return true;
439
+ }
440
+
441
+ const startIndex = Math.min(Math.max(1, warmupBars), candles.length);
442
+ const history = candles.slice(0, startIndex);
443
+
444
+ for (let index = startIndex; index < candles.length; index += 1) {
445
+ const bar = candles[index];
446
+ history.push(bar);
447
+
448
+ const dayKey =
449
+ flattenAtClose || trigger === "close"
450
+ ? dayKeyET(bar.time)
451
+ : dayKeyUTC(bar.time);
452
+ if (currentDay === null || dayKey !== currentDay) {
453
+ currentDay = dayKey;
454
+ dayPnl = 0;
455
+ dayTrades = 0;
456
+ dayEquityStart = currentEquity;
457
+ }
458
+
459
+ if (open && open._maxBarsInTrade > 0) {
460
+ const barsHeld = Math.max(
461
+ 1,
462
+ Math.round((bar.time - open.openTime) / estimatedBarMs)
463
+ );
464
+ if (barsHeld >= open._maxBarsInTrade) {
465
+ forceExit("TIME", bar);
466
+ }
467
+ }
468
+
469
+ if (open && Number.isFinite(open._maxHoldMin) && open._maxHoldMin > 0) {
470
+ const heldMinutes = (bar.time - open.openTime) / 60000;
471
+ if (heldMinutes >= open._maxHoldMin) {
472
+ forceExit("TIME", bar);
473
+ }
474
+ }
475
+
476
+ if (flattenAtClose && open && isEODBar(bar.time)) {
477
+ forceExit("EOD", bar);
478
+ }
479
+
480
+ if (open) {
481
+ const direction = open.side === "long" ? 1 : -1;
482
+ const risk = open._initRisk || 1e-8;
483
+ const highR =
484
+ open.side === "long"
485
+ ? (bar.high - open.entry) / risk
486
+ : (open.entry - bar.low) / risk;
487
+ const lowR =
488
+ open.side === "long"
489
+ ? (bar.low - open.entry) / risk
490
+ : (open.entry - bar.high) / risk;
491
+ const markR =
492
+ direction === 1
493
+ ? (bar.close - open.entry) / risk
494
+ : (open.entry - bar.close) / risk;
495
+
496
+ if (atrValues && atrValues[index] !== undefined) {
497
+ open._lastATR = atrValues[index];
498
+ }
499
+
500
+ open._mfeR = Math.max(open._mfeR ?? -Infinity, highR);
501
+ open._maeR = Math.min(open._maeR ?? Infinity, lowR);
502
+
503
+ if (open._breakevenAtR > 0 && highR >= open._breakevenAtR && !open._beArmed) {
504
+ const tightened =
505
+ open.side === "long"
506
+ ? Math.max(open.stop, open.entry)
507
+ : Math.min(open.stop, open.entry);
508
+ open.stop = oco.clampStops
509
+ ? clampStop(bar.close, tightened, open.side, oco)
510
+ : tightened;
511
+ open._beArmed = true;
512
+ }
513
+
514
+ if (open._trailAfterR > 0 && highR >= open._trailAfterR) {
515
+ const candidate =
516
+ open.side === "long" ? bar.close - risk : bar.close + risk;
517
+ const tightened =
518
+ open.side === "long"
519
+ ? Math.max(open.stop, candidate)
520
+ : Math.min(open.stop, candidate);
521
+ open.stop = oco.clampStops
522
+ ? clampStop(bar.close, tightened, open.side, oco)
523
+ : tightened;
524
+ }
525
+
526
+ if (useMfeTrail && open._mfeR >= mfeTrail.armR) {
527
+ const targetR = Math.max(0, open._mfeR - Math.max(0, mfeTrail.givebackR));
528
+ const candidate =
529
+ open.side === "long"
530
+ ? open.entry + targetR * risk
531
+ : open.entry - targetR * risk;
532
+ const tightened =
533
+ open.side === "long"
534
+ ? Math.max(open.stop, candidate)
535
+ : Math.min(open.stop, candidate);
536
+ open.stop = oco.clampStops
537
+ ? clampStop(bar.close, tightened, open.side, oco)
538
+ : tightened;
539
+ }
540
+
541
+ if (useAtrTrail && atrValues && atrValues[index] !== undefined) {
542
+ const trailDistance = atrValues[index] * atrTrailMult;
543
+ const candidate =
544
+ open.side === "long"
545
+ ? bar.close - trailDistance
546
+ : bar.close + trailDistance;
547
+ const tightened =
548
+ open.side === "long"
549
+ ? Math.max(open.stop, candidate)
550
+ : Math.min(open.stop, candidate);
551
+ open.stop = oco.clampStops
552
+ ? clampStop(bar.close, tightened, open.side, oco)
553
+ : tightened;
554
+ }
555
+
556
+ if (
557
+ useVolScale &&
558
+ open.entryATR &&
559
+ open.size > minQty &&
560
+ atrValues &&
561
+ atrValues[index] !== undefined
562
+ ) {
563
+ const ratio = atrValues[index] / Math.max(1e-12, open.entryATR);
564
+ const shouldCut =
565
+ ratio >= volScale.cutIfAtrX &&
566
+ markR < volScale.noCutAboveR &&
567
+ !open._volCutDone;
568
+
569
+ if (shouldCut) {
570
+ const cutQty = roundStep(open.size * volScale.cutFrac, qtyStep);
571
+ if (cutQty >= minQty && cutQty < open.size) {
572
+ const exitSide = open.side === "long" ? "short" : "long";
573
+ const { price: filled, fee: exitFee } = applyFill(
574
+ bar.close,
575
+ exitSide,
576
+ { slippageBps, feeBps, kind: "market" }
577
+ );
578
+ closeLeg({
579
+ openPos: open,
580
+ qty: cutQty,
581
+ exitPx: filled,
582
+ exitFeePerUnit: exitFee,
583
+ time: bar.time,
584
+ reason: "SCALE",
585
+ });
586
+ tightenStopToNetBreakeven(open, bar.close);
587
+ open._volCutDone = true;
588
+ }
589
+ }
590
+ }
591
+
592
+ let addedThisBar = false;
593
+ if (usePyramiding && (open._adds ?? 0) < pyramiding.maxAdds) {
594
+ const addNumber = (open._adds || 0) + 1;
595
+ const triggerR = pyramiding.addAtR * addNumber;
596
+ const triggerPrice =
597
+ open.side === "long"
598
+ ? open.entry + triggerR * risk
599
+ : open.entry - triggerR * risk;
600
+ const breakEvenSatisfied =
601
+ !pyramiding.onlyAfterBreakEven ||
602
+ (open.side === "long" && open.stop >= open.entry) ||
603
+ (open.side === "short" && open.stop <= open.entry);
604
+ const touched =
605
+ open.side === "long"
606
+ ? trigger === "intrabar"
607
+ ? bar.high >= triggerPrice
608
+ : bar.close >= triggerPrice
609
+ : trigger === "intrabar"
610
+ ? bar.low <= triggerPrice
611
+ : bar.close <= triggerPrice;
612
+
613
+ if (breakEvenSatisfied && touched) {
614
+ const baseSize = open.baseSize || open.initSize;
615
+ const addQty = roundStep(baseSize * pyramiding.addFrac, qtyStep);
616
+ if (addQty >= minQty) {
617
+ const { price: addFill, fee: addFee } = applyFill(
618
+ triggerPrice,
619
+ open.side,
620
+ { slippageBps, feeBps, kind: "limit" }
621
+ );
622
+ const newSize = open.size + addQty;
623
+ open.entryFeeTotal += addFee * addQty;
624
+ open.entryFill =
625
+ (open.entryFill * open.size + addFill * addQty) / newSize;
626
+ open.size = newSize;
627
+ open.initSize += addQty;
628
+ if (!open.baseSize) open.baseSize = baseSize;
629
+ open._adds = addNumber;
630
+ addedThisBar = true;
631
+ }
632
+ }
633
+ }
634
+
635
+ if (!addedThisBar && !open._scaled && scaleOutAtR > 0) {
636
+ const triggerPrice =
637
+ open.side === "long"
638
+ ? open.entry + scaleOutAtR * risk
639
+ : open.entry - scaleOutAtR * risk;
640
+ const touched =
641
+ open.side === "long"
642
+ ? trigger === "intrabar"
643
+ ? bar.high >= triggerPrice
644
+ : bar.close >= triggerPrice
645
+ : trigger === "intrabar"
646
+ ? bar.low <= triggerPrice
647
+ : bar.close <= triggerPrice;
648
+
649
+ if (touched) {
650
+ const exitSide = open.side === "long" ? "short" : "long";
651
+ const { price: filled, fee: exitFee } = applyFill(triggerPrice, exitSide, {
652
+ slippageBps,
653
+ feeBps,
654
+ kind: "limit",
655
+ });
656
+ const qty = roundStep(open.size * scaleOutFrac, qtyStep);
657
+ if (qty >= minQty && qty < open.size) {
658
+ closeLeg({
659
+ openPos: open,
660
+ qty,
661
+ exitPx: filled,
662
+ exitFeePerUnit: exitFee,
663
+ time: bar.time,
664
+ reason: "SCALE",
665
+ });
666
+ open._scaled = true;
667
+ open.takeProfit =
668
+ open.side === "long"
669
+ ? open.entry + finalTP_R * risk
670
+ : open.entry - finalTP_R * risk;
671
+ tightenStopToNetBreakeven(open, bar.close);
672
+ open._beArmed = true;
673
+ }
674
+ }
675
+ }
676
+
677
+ const exitSide = open.side === "long" ? "short" : "long";
678
+ const { hit, px } = ocoExitCheck({
679
+ side: open.side,
680
+ stop: open.stop,
681
+ tp: open.takeProfit,
682
+ bar,
683
+ mode: oco.mode,
684
+ tieBreak: oco.tieBreak,
685
+ });
686
+
687
+ if (hit) {
688
+ const exitKind = hit === "TP" ? "limit" : "stop";
689
+ const { price: filled, fee: exitFee } = applyFill(px, exitSide, {
690
+ slippageBps,
691
+ feeBps,
692
+ kind: exitKind,
693
+ });
694
+ const localCooldown = open._cooldownBars || 0;
695
+ closeLeg({
696
+ openPos: open,
697
+ qty: open.size,
698
+ exitPx: filled,
699
+ exitFeePerUnit: exitFee,
700
+ time: bar.time,
701
+ reason: hit,
702
+ });
703
+ cooldown =
704
+ (hit === "SL"
705
+ ? Math.max(cooldown, postLossCooldownBars)
706
+ : cooldown) || localCooldown;
707
+ open = null;
708
+ }
709
+ }
710
+
711
+ const maxLossDollars = (maxDailyLossPct / 100) * dayEquityStart;
712
+ const dailyLossHit = dayPnl <= -Math.abs(maxLossDollars);
713
+ const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
714
+
715
+ if (!open && pending) {
716
+ if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
717
+ if (entryChase.enabled && entryChase.convertOnExpiry) {
718
+ const riskAtEdge = Math.abs(
719
+ pending.meta._initRisk ?? (pending.entry - pending.stop)
720
+ );
721
+ const priceNow = bar.close;
722
+ const direction = pending.side === "long" ? 1 : -1;
723
+ const slippedR =
724
+ Math.max(
725
+ 0,
726
+ direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
727
+ ) / Math.max(1e-8, riskAtEdge);
728
+
729
+ if (slippedR > maxSlipROnFill) {
730
+ pending = null;
731
+ } else if (!openFromPending(bar, index, priceNow, "market")) {
732
+ pending = null;
733
+ }
734
+ } else {
735
+ pending = null;
736
+ }
737
+ } else if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
738
+ if (!openFromPending(bar, index, pending.entry, "limit")) {
739
+ pending = null;
740
+ }
741
+ } else if (entryChase.enabled) {
742
+ const elapsedBars = index - (pending.startedAtIndex ?? index);
743
+ const midpoint = pending.meta?._imb?.mid;
744
+
745
+ if (!pending._chasedCE && midpoint !== undefined && elapsedBars >= Math.max(1, entryChase.afterBars)) {
746
+ pending.entry = midpoint;
747
+ pending._chasedCE = true;
748
+ }
749
+
750
+ if (pending._chasedCE) {
751
+ const riskRef = Math.abs(
752
+ pending.meta?._initRisk ?? (pending.entry - pending.stop)
753
+ );
754
+ const priceNow = bar.close;
755
+ const direction = pending.side === "long" ? 1 : -1;
756
+ const slippedR =
757
+ Math.max(
758
+ 0,
759
+ direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
760
+ ) / Math.max(1e-8, riskRef);
761
+
762
+ if (slippedR > maxSlipROnFill) {
763
+ pending = null;
764
+ } else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
765
+ if (!openFromPending(bar, index, priceNow, "market")) {
766
+ pending = null;
767
+ }
768
+ }
769
+ }
770
+ }
771
+ }
772
+
773
+ if (open || cooldown > 0) {
774
+ if (cooldown > 0) cooldown -= 1;
775
+ recordFrame(bar);
776
+ continue;
777
+ }
778
+
779
+ if (dailyLossHit || dailyTradeCapHit) {
780
+ pending = null;
781
+ recordFrame(bar);
782
+ continue;
783
+ }
784
+
785
+ if (!pending) {
786
+ const rawSignal = signal({
787
+ candles: history,
788
+ index,
789
+ bar,
790
+ equity: currentEquity,
791
+ openPosition: open,
792
+ pendingOrder: pending,
793
+ });
794
+ const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
795
+
796
+ if (nextSignal) {
797
+ const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
798
+ ? nextSignal.riskFraction
799
+ : Number.isFinite(nextSignal.riskPct)
800
+ ? nextSignal.riskPct / 100
801
+ : riskPct / 100;
802
+ const expiryBars = nextSignal._entryExpiryBars ?? 5;
803
+ pending = {
804
+ side: nextSignal.side,
805
+ entry: nextSignal.entry,
806
+ stop: nextSignal.stop,
807
+ tp: nextSignal.takeProfit,
808
+ riskFrac: signalRiskFraction,
809
+ fixedQty: nextSignal.qty,
810
+ expiresAt: index + Math.max(1, expiryBars),
811
+ startedAtIndex: index,
812
+ meta: nextSignal,
813
+ plannedRiskAbs: Math.abs(
814
+ nextSignal._initRisk ?? (nextSignal.entry - nextSignal.stop)
815
+ ),
816
+ };
817
+
818
+ if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
819
+ if (!openFromPending(bar, index, pending.entry, "limit")) {
820
+ pending = null;
821
+ }
822
+ }
823
+ }
824
+ }
825
+
826
+ recordFrame(bar);
827
+ }
828
+
829
+ const metrics = buildMetrics({
830
+ closed,
831
+ equityStart: equity,
832
+ equityFinal: currentEquity,
833
+ candles,
834
+ estBarMs: estimatedBarMs,
835
+ eqSeries,
836
+ });
837
+ const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
838
+
839
+ return {
840
+ symbol: options.symbol,
841
+ interval: options.interval,
842
+ range: options.range,
843
+ trades: closed,
844
+ positions,
845
+ metrics,
846
+ eqSeries,
847
+ replay: {
848
+ frames: replayFrames,
849
+ events: replayEvents,
850
+ },
851
+ };
852
+ }