@hoox-sh/pynets 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +46 -0
  4. package/README.md +327 -0
  5. package/dist/browser.js +44 -0
  6. package/dist/index.d.ts +348 -0
  7. package/dist/index.js +41741 -0
  8. package/package.json +77 -0
  9. package/src/ast/builder.ts +1002 -0
  10. package/src/ast/dump.ts +43 -0
  11. package/src/ast/helper.ts +77 -0
  12. package/src/ast/nodes.ts +570 -0
  13. package/src/ast/unparse.ts +360 -0
  14. package/src/cli/rich.ts +378 -0
  15. package/src/cli.ts +627 -0
  16. package/src/generated/PinescriptLexer.interp +267 -0
  17. package/src/generated/PinescriptLexer.tokens +131 -0
  18. package/src/generated/PinescriptLexer.ts +673 -0
  19. package/src/generated/PinescriptLexerBase.ts +395 -0
  20. package/src/generated/PinescriptParser.interp +267 -0
  21. package/src/generated/PinescriptParser.tokens +131 -0
  22. package/src/generated/PinescriptParser.ts +8863 -0
  23. package/src/generated/PinescriptParserBase.ts +21 -0
  24. package/src/generated/PinescriptParserVisitor.ts +840 -0
  25. package/src/index.ts +55 -0
  26. package/src/parser/LexerBase.ts +395 -0
  27. package/src/parser/ParserBase.ts +21 -0
  28. package/src/parser/error.ts +33 -0
  29. package/src/runtime/array.ts +486 -0
  30. package/src/runtime/color.ts +164 -0
  31. package/src/runtime/drawings.ts +510 -0
  32. package/src/runtime/input.ts +102 -0
  33. package/src/runtime/interpret.ts +3897 -0
  34. package/src/runtime/library.ts +282 -0
  35. package/src/runtime/log.ts +69 -0
  36. package/src/runtime/map.ts +85 -0
  37. package/src/runtime/math.ts +231 -0
  38. package/src/runtime/matrix.ts +1154 -0
  39. package/src/runtime/provider.ts +232 -0
  40. package/src/runtime/request.ts +372 -0
  41. package/src/runtime/series.ts +44 -0
  42. package/src/runtime/str.ts +433 -0
  43. package/src/runtime/strategy.ts +1105 -0
  44. package/src/runtime/ta.ts +2093 -0
  45. package/src/runtime/ticker.ts +81 -0
  46. package/src/runtime/time.ts +439 -0
  47. package/src/runtime/timeframe.ts +85 -0
  48. package/src/runtime/udt.ts +119 -0
@@ -0,0 +1,1105 @@
1
+ /**
2
+ * Copyright (C) 2024-2026 jango_blockchained
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ *
5
+ * Strategy event bus plus a tiny market-fill + pending limit/stop model
6
+ * (not a full broker). entry/close/exit stay event-only. fillEntry/fillClose
7
+ * update a single signed position and fills. processPending is called once
8
+ * per bar BEFORE the script body. Optional commission, slippage, pyramiding.
9
+ * Non-finite qty/price is a no-op (never corrupts position).
10
+ * fillEntry from flat / reverse leftover opens a trade; flatten moves it to closed.
11
+ */
12
+
13
+ export type StrategyDirection = "long" | "short";
14
+
15
+ export interface BrokerSettings {
16
+ commission?: number; // fraction of notional, default 0 (0.001 = 0.1%)
17
+ slippage?: number; // price units added against the trade, default 0
18
+ pyramiding?: number; // max same-direction adds; 0 = no extra adds; omitted = unlimited
19
+ }
20
+
21
+ export interface StrategyEvent {
22
+ type: "entry" | "close" | "exit" | "cancel" | "close_all" | "cancel_all" | "fill";
23
+ id: string;
24
+ direction?: StrategyDirection;
25
+ qty?: number | null;
26
+ bar: number;
27
+ }
28
+
29
+ export interface Position {
30
+ qty: number; // signed: >0 long, <0 short
31
+ avgPrice: number | null;
32
+ }
33
+
34
+ export interface Fill {
35
+ bar: number;
36
+ id: string;
37
+ side: "buy" | "sell";
38
+ qty: number;
39
+ price: number;
40
+ }
41
+
42
+ export type OcaType = "none" | "cancel" | "reduce";
43
+ export type AllowEntryIn = "all" | "long" | "short";
44
+
45
+ export interface PendingOrder {
46
+ id: string;
47
+ direction: StrategyDirection;
48
+ qty: number;
49
+ limit: number | null;
50
+ stop: number | null;
51
+ bar: number;
52
+ oca_name?: string | null;
53
+ oca_type?: OcaType;
54
+ }
55
+
56
+ export interface PlaceEntryOpts {
57
+ limit?: number | null;
58
+ stop?: number | null;
59
+ /** Market fill price when both limit and stop are unset. */
60
+ price?: number;
61
+ comment?: string;
62
+ time?: number;
63
+ oca_name?: string | null;
64
+ oca_type?: string | null;
65
+ }
66
+
67
+ /** One open or closed strategy trade (qty is absolute). */
68
+ export interface Trade {
69
+ id: string;
70
+ direction: StrategyDirection;
71
+ qty: number;
72
+ entryBar: number;
73
+ entryPrice: number;
74
+ entryTime?: number;
75
+ exitBar?: number;
76
+ exitPrice?: number;
77
+ profit?: number;
78
+ commission: number;
79
+ comment?: string;
80
+ max_runup?: number;
81
+ max_drawdown?: number;
82
+ }
83
+
84
+ export interface BarOhlc {
85
+ open?: number;
86
+ high?: number;
87
+ low?: number;
88
+ close?: number;
89
+ }
90
+
91
+ /** Snapshot of book-level performance scalars (for RuntimeResult). */
92
+ export interface StrategySummary {
93
+ netprofit: number;
94
+ netprofitPercent: number;
95
+ openprofit: number;
96
+ equity: number;
97
+ wintrades: number;
98
+ losstrades: number;
99
+ eventrades: number;
100
+ closedtrades: number;
101
+ opentrades: number;
102
+ grossprofit: number;
103
+ grossloss: number;
104
+ avgTrade: number;
105
+ avgWinningTrade: number;
106
+ avgLosingTrade: number;
107
+ percentProfitable: number;
108
+ profitFactor: number;
109
+ maxDrawdown: number;
110
+ maxDrawdownPercent: number;
111
+ maxRunup: number;
112
+ maxRunupPercent: number;
113
+ initialCapital: number;
114
+ }
115
+
116
+ function isFiniteNumber(v: unknown): v is number {
117
+ return typeof v === "number" && Number.isFinite(v);
118
+ }
119
+
120
+ function optLevel(v: number | null | undefined): number | null {
121
+ return isFiniteNumber(v) ? v : null;
122
+ }
123
+
124
+ /** Raw trigger price (slippage applied later via recordFill). */
125
+ function triggerPrice(order: PendingOrder, open: number, high: number, low: number): number | null {
126
+ const lim = order.limit;
127
+ const stop = order.stop;
128
+ const d = order.direction;
129
+ if (lim != null && stop != null) {
130
+ // Stop-limit: stop must trigger and limit must still be available.
131
+ if (d === "long" && high >= stop && low <= lim) return lim;
132
+ if (d === "short" && low <= stop && high >= lim) return lim;
133
+ return null;
134
+ }
135
+ if (lim != null) {
136
+ if (d === "long" && low <= lim) return Math.min(lim, open);
137
+ if (d === "short" && high >= lim) return Math.max(lim, open);
138
+ return null;
139
+ }
140
+ if (stop != null) {
141
+ if (d === "long" && high >= stop) return Math.max(stop, open);
142
+ if (d === "short" && low <= stop) return Math.min(stop, open);
143
+ return null;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function finiteOr(v: unknown, fallback: number): number {
149
+ return isFiniteNumber(v) ? v : fallback;
150
+ }
151
+
152
+ /** Omitted / non-finite → unlimited (`undefined`). Else max(0, trunc). */
153
+ function normalizePyramiding(v: unknown): number | undefined {
154
+ if (v == null || !isFiniteNumber(v)) return undefined;
155
+ return Math.max(0, Math.trunc(v));
156
+ }
157
+
158
+ /** 1 / "long" / *long* → long; -1 / "short" / *short* → short. */
159
+ function mapDirection(direction: unknown): StrategyDirection {
160
+ if (direction === 1 || direction === "1") return "long";
161
+ if (direction === -1 || direction === "-1") return "short";
162
+ const s = String(direction ?? "").toLowerCase();
163
+ if (s.includes("long")) return "long";
164
+ if (s.includes("short")) return "short";
165
+ return "long";
166
+ }
167
+
168
+ /** Calendar-day bucket: ms epoch, seconds epoch, or raw (Python `_day_bucket`). */
169
+ function dayBucket(ts: number): number {
170
+ const t = Math.trunc(ts);
171
+ if (t > 10_000_000_000) return Math.trunc(t / 86_400_000);
172
+ if (t > 10_000_000) return Math.trunc(t / 86_400);
173
+ return t;
174
+ }
175
+
176
+ /** `strategy.oca.cancel` / `oca.cancel` / `cancel` → cancel (else reduce / none). */
177
+ function normalizeOcaType(raw: unknown): OcaType {
178
+ const s = String(raw ?? "none").trim().toLowerCase();
179
+ if (s === "cancel" || s === "oca.cancel" || s === "strategy.oca.cancel") return "cancel";
180
+ if (s === "reduce" || s === "oca.reduce" || s === "strategy.oca.reduce") return "reduce";
181
+ return "none";
182
+ }
183
+
184
+ export class StrategyBook {
185
+ readonly events: StrategyEvent[] = [];
186
+ readonly position: Position = { qty: 0, avgPrice: null };
187
+ readonly fills: Fill[] = [];
188
+ readonly pending: PendingOrder[] = [];
189
+ readonly openTrades: Trade[] = [];
190
+ readonly closedTrades: Trade[] = [];
191
+ commissionPaid = 0;
192
+ realizedPnl = 0;
193
+ closedCount = 0;
194
+ wintrades = 0;
195
+ losstrades = 0;
196
+ eventrades = 0;
197
+ grossprofit = 0;
198
+ /** Positive magnitude of losing-trade profits. */
199
+ grossloss = 0;
200
+ initialCapital = 1_000_000;
201
+
202
+ /** strategy.risk.allow_entry_in — `all` | `long` | `short`. */
203
+ allow_entry_in: AllowEntryIn = "all";
204
+ max_position_size_percent?: number;
205
+ max_drawdown_risk?: number;
206
+ max_drawdown_risk_percent?: number;
207
+ max_cons_loss_days?: number;
208
+ /** Intraday loss halt as % of initial capital; `Infinity` = unset. */
209
+ max_intraday_loss = Number.POSITIVE_INFINITY;
210
+ max_intraday_filled_orders?: number;
211
+ entries_blocked = false;
212
+ consecutive_loss_days = 0;
213
+
214
+ get opentrades(): number {
215
+ return this.openTrades.length;
216
+ }
217
+
218
+ get closedtrades(): number {
219
+ return this.closedTrades.length;
220
+ }
221
+
222
+ private commission: number;
223
+ private slippage: number;
224
+ private pyramiding: number | undefined;
225
+ private sameDirAdds = 0;
226
+ private lastTradeDay: number | null = null;
227
+ private dayPnl = 0;
228
+ private fillsDay: number | null = null;
229
+ private dayFilledOrders = 0;
230
+ private equityPeak: number;
231
+ private equityTrough: number;
232
+ private maxDrawdownAbs = 0;
233
+ private maxDdPercent = 0;
234
+ private maxRunupAbs = 0;
235
+ private maxRuPercent = 0;
236
+ private maxHeldAll = 0;
237
+ private maxHeldLong = 0;
238
+ private maxHeldShort = 0;
239
+
240
+ constructor(settings?: BrokerSettings) {
241
+ this.commission = finiteOr(settings?.commission, 0);
242
+ this.slippage = finiteOr(settings?.slippage, 0);
243
+ this.pyramiding = normalizePyramiding(settings?.pyramiding);
244
+ this.equityPeak = this.initialCapital;
245
+ this.equityTrough = this.initialCapital;
246
+ }
247
+
248
+ /** Mark-to-market equity: initial + realized − commission + open PnL. */
249
+ equity(mark: number): number {
250
+ this.ensureSanePosition();
251
+ const q = this.position.qty;
252
+ const avg = this.position.avgPrice;
253
+ const realized = isFiniteNumber(this.realizedPnl) ? this.realizedPnl : 0;
254
+ const paid = isFiniteNumber(this.commissionPaid) ? this.commissionPaid : 0;
255
+ const open =
256
+ q === 0 || !isFiniteNumber(avg) || !isFiniteNumber(mark) ? 0 : q * (mark - avg);
257
+ const eq = this.initialCapital + realized - paid + open;
258
+ this.trackEquityCurve(eq);
259
+ return eq;
260
+ }
261
+
262
+ closedTrade(i: number): Trade | null {
263
+ return this.pickTrade(this.closedTrades, i);
264
+ }
265
+
266
+ openTrade(i: number): Trade | null {
267
+ return this.pickTrade(this.openTrades, i);
268
+ }
269
+
270
+ closedEntryBar(i: number): number | null {
271
+ return this.closedTrade(i)?.entryBar ?? null;
272
+ }
273
+
274
+ closedEntryPrice(i: number): number | null {
275
+ return this.closedTrade(i)?.entryPrice ?? null;
276
+ }
277
+
278
+ closedExitBar(i: number): number | null {
279
+ return this.closedTrade(i)?.exitBar ?? null;
280
+ }
281
+
282
+ closedExitPrice(i: number): number | null {
283
+ return this.closedTrade(i)?.exitPrice ?? null;
284
+ }
285
+
286
+ closedProfit(i: number): number | null {
287
+ const t = this.closedTrade(i);
288
+ return t != null && t.profit !== undefined ? t.profit : null;
289
+ }
290
+
291
+ closedSize(i: number): number | null {
292
+ return this.closedTrade(i)?.qty ?? null;
293
+ }
294
+
295
+ closedId(i: number): string | null {
296
+ return this.closedTrade(i)?.id ?? null;
297
+ }
298
+
299
+ closedCommission(i: number): number | null {
300
+ return this.closedTrade(i)?.commission ?? null;
301
+ }
302
+
303
+ openEntryBar(i: number): number | null {
304
+ return this.openTrade(i)?.entryBar ?? null;
305
+ }
306
+
307
+ openEntryPrice(i: number): number | null {
308
+ return this.openTrade(i)?.entryPrice ?? null;
309
+ }
310
+
311
+ openSize(i: number): number | null {
312
+ return this.openTrade(i)?.qty ?? null;
313
+ }
314
+
315
+ openId(i: number): string | null {
316
+ return this.openTrade(i)?.id ?? null;
317
+ }
318
+
319
+ openProfit(i: number, markPrice: number): number | null {
320
+ const t = this.openTrade(i);
321
+ if (t == null || !isFiniteNumber(markPrice)) return null;
322
+ return this.markProfit(t, markPrice);
323
+ }
324
+
325
+ avgTrade(): number {
326
+ const n = this.closedTrades.length;
327
+ return n === 0 ? 0 : (this.grossprofit - this.grossloss) / n;
328
+ }
329
+
330
+ avgWinningTrade(): number {
331
+ return this.wintrades === 0 ? 0 : this.grossprofit / this.wintrades;
332
+ }
333
+
334
+ avgLosingTrade(): number {
335
+ return this.losstrades === 0 ? 0 : this.grossloss / this.losstrades;
336
+ }
337
+
338
+ /** Realized net profit (closed-trade price PnL). */
339
+ netprofit(): number {
340
+ return isFiniteNumber(this.realizedPnl) ? this.realizedPnl : 0;
341
+ }
342
+
343
+ /** `100 * netprofit / initialCapital`; 0 if capital ≤ 0. */
344
+ netprofitPercent(_mark?: number): number {
345
+ return this.pctOfInitial(this.netprofit());
346
+ }
347
+
348
+ /** Signed open PnL at `mark`; 0 if flat or mark is non-finite. */
349
+ openprofit(mark: number): number {
350
+ this.ensureSanePosition();
351
+ const q = this.position.qty;
352
+ const avg = this.position.avgPrice;
353
+ if (q === 0 || !isFiniteNumber(avg) || !isFiniteNumber(mark)) return 0;
354
+ return q * (mark - avg);
355
+ }
356
+
357
+ /** `100 * openprofit / initialCapital`; 0 if capital ≤ 0. */
358
+ openprofitPercent(mark: number): number {
359
+ return this.pctOfInitial(this.openprofit(mark));
360
+ }
361
+
362
+ grossprofitPercent(): number {
363
+ return this.pctOfInitial(this.grossprofit);
364
+ }
365
+
366
+ grosslossPercent(): number {
367
+ return this.pctOfInitial(this.grossloss);
368
+ }
369
+
370
+ avgTradePercent(): number {
371
+ return this.pctOfInitial(this.avgTrade());
372
+ }
373
+
374
+ avgWinningTradePercent(): number {
375
+ return this.pctOfInitial(this.avgWinningTrade());
376
+ }
377
+
378
+ avgLosingTradePercent(): number {
379
+ return this.pctOfInitial(this.avgLosingTrade());
380
+ }
381
+
382
+ /** Book-level peak-to-trough equity drop (positive magnitude). */
383
+ maxDrawdown(): number {
384
+ return this.maxDrawdownAbs;
385
+ }
386
+
387
+ maxDrawdownPercent(): number {
388
+ return this.maxDdPercent;
389
+ }
390
+
391
+ /** Book-level trough-to-peak equity rise. */
392
+ maxRunup(): number {
393
+ return this.maxRunupAbs;
394
+ }
395
+
396
+ maxRunupPercent(): number {
397
+ return this.maxRuPercent;
398
+ }
399
+
400
+ /** `100 * wintrades / closedtrades`; 0 if none closed. */
401
+ percentProfitable(): number {
402
+ const n = this.closedTrades.length;
403
+ return n === 0 ? 0 : (100 * this.wintrades) / n;
404
+ }
405
+
406
+ /**
407
+ * `grossprofit / grossloss`. 0 if both 0; 999.99 if profit and no loss
408
+ * (Python backtest caps Inf).
409
+ */
410
+ profitFactor(): number {
411
+ const gp = this.grossprofit;
412
+ const gl = this.grossloss;
413
+ if (!isFiniteNumber(gp) || !isFiniteNumber(gl)) return 0;
414
+ if (gl > 0) return gp / gl;
415
+ return gp > 0 ? 999.99 : 0;
416
+ }
417
+
418
+ maxContractsHeldAll(): number {
419
+ return this.maxHeldAll;
420
+ }
421
+
422
+ maxContractsHeldLong(): number {
423
+ return this.maxHeldLong;
424
+ }
425
+
426
+ maxContractsHeldShort(): number {
427
+ return this.maxHeldShort;
428
+ }
429
+
430
+ summary(mark: number): StrategySummary {
431
+ return {
432
+ netprofit: this.netprofit(),
433
+ netprofitPercent: this.netprofitPercent(mark),
434
+ openprofit: this.openprofit(mark),
435
+ equity: this.equity(mark),
436
+ wintrades: this.wintrades,
437
+ losstrades: this.losstrades,
438
+ eventrades: this.eventrades,
439
+ closedtrades: this.closedtrades,
440
+ opentrades: this.opentrades,
441
+ grossprofit: this.grossprofit,
442
+ grossloss: this.grossloss,
443
+ avgTrade: this.avgTrade(),
444
+ avgWinningTrade: this.avgWinningTrade(),
445
+ avgLosingTrade: this.avgLosingTrade(),
446
+ percentProfitable: this.percentProfitable(),
447
+ profitFactor: this.profitFactor(),
448
+ maxDrawdown: this.maxDrawdown(),
449
+ maxDrawdownPercent: this.maxDrawdownPercent(),
450
+ maxRunup: this.maxRunup(),
451
+ maxRunupPercent: this.maxRunupPercent(),
452
+ initialCapital: this.initialCapital,
453
+ };
454
+ }
455
+
456
+ /** Update per-open-trade max_runup / max_drawdown vs mark (high/low if finite, else close). */
457
+ markOpenTrades(high: number, low: number, close: number): void {
458
+ if (this.openTrades.length === 0) return;
459
+ for (const t of this.openTrades) {
460
+ const fav = t.direction === "long"
461
+ ? (isFiniteNumber(high) ? high : close)
462
+ : (isFiniteNumber(low) ? low : close);
463
+ const adv = t.direction === "long"
464
+ ? (isFiniteNumber(low) ? low : close)
465
+ : (isFiniteNumber(high) ? high : close);
466
+ if (isFiniteNumber(fav)) {
467
+ const runup = this.markProfit(t, fav);
468
+ if (runup > 0) t.max_runup = Math.max(t.max_runup ?? 0, runup);
469
+ }
470
+ if (isFiniteNumber(adv)) {
471
+ const dd = this.markProfit(t, adv);
472
+ if (dd < 0) t.max_drawdown = Math.max(t.max_drawdown ?? 0, -dd);
473
+ }
474
+ }
475
+ }
476
+
477
+ configure(settings: BrokerSettings): void {
478
+ if (isFiniteNumber(settings.commission)) this.commission = settings.commission;
479
+ if (isFiniteNumber(settings.slippage)) this.slippage = settings.slippage;
480
+ if (settings.pyramiding !== undefined) {
481
+ const pyr = normalizePyramiding(settings.pyramiding);
482
+ if (pyr !== undefined || settings.pyramiding == null) this.pyramiding = pyr;
483
+ }
484
+ }
485
+
486
+ /** `strategy.risk.allow_entry_in(value)` — `all` | `long` | `short`. */
487
+ riskAllowEntryIn(value: unknown = "all"): void {
488
+ const s = String(value ?? "all").replace(/strategy\./gi, "").trim().toLowerCase();
489
+ this.allow_entry_in = s === "long" || s === "short" || s === "all" ? s : "all";
490
+ }
491
+
492
+ /** `strategy.risk.max_position_size(percent)` — cap entry notional vs equity. */
493
+ riskMaxPositionSize(percent: unknown): void {
494
+ if (percent == null || !isFiniteNumber(percent) || percent <= 0) return;
495
+ this.max_position_size_percent = percent;
496
+ }
497
+
498
+ /**
499
+ * `strategy.risk.max_drawdown(value, type)` — absolute or % of peak equity.
500
+ * Percent types: `percent` / `percentage` / `percent_of_equity` / `%`.
501
+ */
502
+ riskMaxDrawdown(value: unknown, type: unknown = "absolute"): void {
503
+ if (value == null || !isFiniteNumber(value) || value < 0) return;
504
+ const rt = String(type ?? "absolute").replace(/strategy\./gi, "").trim().toLowerCase();
505
+ if (rt === "percent" || rt === "percentage" || rt === "percent_of_equity" || rt === "%") {
506
+ this.max_drawdown_risk_percent = value;
507
+ } else {
508
+ this.max_drawdown_risk = value;
509
+ }
510
+ }
511
+
512
+ /** `strategy.risk.max_cons_loss_days(days)` — halt after N finalized loss days. */
513
+ riskMaxConsLossDays(days: unknown): void {
514
+ if (days == null || !isFiniteNumber(days)) return;
515
+ const d = Math.trunc(days);
516
+ if (d < 0) return;
517
+ this.max_cons_loss_days = d;
518
+ }
519
+
520
+ /** `strategy.risk.max_intraday_loss(percent)` — halt on day loss % of capital. */
521
+ riskMaxIntradayLoss(percent: unknown): void {
522
+ if (percent == null || !isFiniteNumber(percent) || percent < 0) return;
523
+ this.max_intraday_loss = percent;
524
+ }
525
+
526
+ /** `strategy.risk.max_intraday_filled_orders(max)` — cap fills per day bucket. */
527
+ riskMaxIntradayFilledOrders(max: unknown): void {
528
+ if (max == null || !isFiniteNumber(max)) return;
529
+ const n = Math.trunc(max);
530
+ if (n < 0) return;
531
+ this.max_intraday_filled_orders = n;
532
+ }
533
+
534
+ /**
535
+ * Track consecutive calendar-day losses (Python `note_closed_trade_day`).
536
+ * A day's PnL is finalized when a close lands on a later day bucket.
537
+ */
538
+ noteClosedTradeDay(exitTime: number, profit: number): void {
539
+ if (!isFiniteNumber(exitTime) || !isFiniteNumber(profit)) return;
540
+ const day = dayBucket(exitTime);
541
+ if (this.lastTradeDay == null || day !== this.lastTradeDay) {
542
+ if (this.lastTradeDay != null) {
543
+ if (this.dayPnl < 0) this.consecutive_loss_days += 1;
544
+ else if (this.dayPnl > 0) this.consecutive_loss_days = 0;
545
+ }
546
+ this.lastTradeDay = day;
547
+ this.dayPnl = 0;
548
+ }
549
+ this.dayPnl += profit;
550
+ if (this.max_cons_loss_days != null && this.consecutive_loss_days >= this.max_cons_loss_days) {
551
+ this.entries_blocked = true;
552
+ }
553
+ if (
554
+ Number.isFinite(this.max_intraday_loss) &&
555
+ this.max_intraday_loss < Number.POSITIVE_INFINITY &&
556
+ this.initialCapital > 0 &&
557
+ this.dayPnl < 0
558
+ ) {
559
+ const lossPct = 100 * -this.dayPnl / this.initialCapital;
560
+ if (lossPct >= this.max_intraday_loss) this.entries_blocked = true;
561
+ }
562
+ }
563
+
564
+ entry(
565
+ bar: number,
566
+ id: string,
567
+ direction: StrategyDirection | number | string,
568
+ qty?: number | null,
569
+ ): void {
570
+ const event: StrategyEvent = {
571
+ type: "entry",
572
+ id: String(id ?? ""),
573
+ direction: mapDirection(direction),
574
+ bar: isFiniteNumber(bar) ? bar : 0,
575
+ };
576
+ if (qty !== undefined) event.qty = qty;
577
+ this.events.push(event);
578
+ }
579
+
580
+ close(bar: number, id: string): void {
581
+ this.events.push({
582
+ type: "close",
583
+ id: String(id ?? ""),
584
+ bar: isFiniteNumber(bar) ? bar : 0,
585
+ });
586
+ }
587
+
588
+ exit(bar: number, id: string): void {
589
+ this.events.push({
590
+ type: "exit",
591
+ id: String(id ?? ""),
592
+ bar: isFiniteNumber(bar) ? bar : 0,
593
+ });
594
+ }
595
+
596
+ cancel(bar: number, id: string): void {
597
+ this.dropPending(String(id ?? ""));
598
+ this.events.push({
599
+ type: "cancel",
600
+ id: String(id ?? ""),
601
+ bar: isFiniteNumber(bar) ? bar : 0,
602
+ });
603
+ }
604
+
605
+ /**
606
+ * Always emit `close_all`. When `mark` is finite and a position is open,
607
+ * flatten at that price first (fills + realized PnL) without a `close` event.
608
+ * Also drops the pending book.
609
+ */
610
+ closeAll(bar: number, mark?: number): void {
611
+ const b = isFiniteNumber(bar) ? bar : 0;
612
+ this.dropPending();
613
+ if (isFiniteNumber(mark) && this.position.qty !== 0) {
614
+ this.flattenAt(b, "close_all", mark);
615
+ }
616
+ this.events.push({ type: "close_all", id: "", bar: b });
617
+ }
618
+
619
+ cancelAll(bar: number): void {
620
+ this.dropPending();
621
+ this.events.push({
622
+ type: "cancel_all",
623
+ id: "",
624
+ bar: isFiniteNumber(bar) ? bar : 0,
625
+ });
626
+ }
627
+
628
+ /**
629
+ * Place an entry. No limit/stop → market via fillEntry (needs `opts.price`).
630
+ * Otherwise records a pending order. Always emits an `entry` event
631
+ * (market path emits it through fillEntry).
632
+ */
633
+ placeEntry(
634
+ bar: number,
635
+ id: string,
636
+ direction: StrategyDirection | number | string,
637
+ qty: number,
638
+ opts?: PlaceEntryOpts,
639
+ ): void {
640
+ const limit = optLevel(opts?.limit);
641
+ const stop = optLevel(opts?.stop);
642
+ if (limit == null && stop == null) {
643
+ if (isFiniteNumber(opts?.price)) {
644
+ const dir = mapDirection(direction);
645
+ const t = isFiniteNumber(opts?.time) ? opts.time : bar;
646
+ this.updateEquityExtremes(opts.price);
647
+ if (!this.riskAllowsEntry(dir, t)) return;
648
+ this.fillEntry(bar, id, direction, qty, opts.price, opts);
649
+ } else this.entry(bar, id, direction, qty);
650
+ return;
651
+ }
652
+ const dir = mapDirection(direction);
653
+ const absQty = isFiniteNumber(qty) ? Math.abs(qty) : Number.NaN;
654
+ const b = isFiniteNumber(bar) ? bar : 0;
655
+ const oid = String(id ?? "");
656
+ this.entry(b, oid, dir, isFiniteNumber(absQty) ? absQty : qty);
657
+ if (!isFiniteNumber(absQty) || absQty === 0) return;
658
+ const pending: PendingOrder = { id: oid, direction: dir, qty: absQty, limit, stop, bar: b };
659
+ const ocaName = opts?.oca_name == null || opts.oca_name === "" ? undefined : String(opts.oca_name);
660
+ const ocaType = normalizeOcaType(opts?.oca_type);
661
+ if (ocaName !== undefined) pending.oca_name = ocaName;
662
+ if (ocaType !== "none") pending.oca_type = ocaType;
663
+ this.upsertPending(pending);
664
+ }
665
+
666
+ /** Alias of {@link placeEntry} (`strategy.order`). */
667
+ order(
668
+ bar: number,
669
+ id: string,
670
+ direction: StrategyDirection | number | string,
671
+ qty: number,
672
+ opts?: PlaceEntryOpts,
673
+ ): void {
674
+ this.placeEntry(bar, id, direction, qty, opts);
675
+ }
676
+
677
+ /**
678
+ * Fill pending limit/stop/stop-limit orders against this bar's OHLC.
679
+ * Hosts call this once per bar BEFORE the script body (Python interpret).
680
+ * Returns ids that filled this bar.
681
+ */
682
+ processPending(bar: number, ohlc: BarOhlc): string[] {
683
+ if (this.pending.length === 0 || ohlc == null) return [];
684
+ const hasAny =
685
+ isFiniteNumber(ohlc.open) ||
686
+ isFiniteNumber(ohlc.high) ||
687
+ isFiniteNumber(ohlc.low) ||
688
+ isFiniteNumber(ohlc.close);
689
+ if (!hasAny) return [];
690
+ const b = isFiniteNumber(bar) ? bar : 0;
691
+ const close = isFiniteNumber(ohlc.close)
692
+ ? ohlc.close
693
+ : isFiniteNumber(ohlc.open)
694
+ ? ohlc.open
695
+ : (isFiniteNumber(ohlc.high) ? ohlc.high : ohlc.low as number);
696
+ const open = isFiniteNumber(ohlc.open) ? ohlc.open : close;
697
+ const high = isFiniteNumber(ohlc.high) ? ohlc.high : Math.max(open, close);
698
+ const low = isFiniteNumber(ohlc.low) ? ohlc.low : Math.min(open, close);
699
+ const filled: string[] = [];
700
+ for (const order of this.pending.slice()) {
701
+ if (!this.pending.some((p) => p.id === order.id)) continue;
702
+ const px = triggerPrice(order, open, high, low);
703
+ if (px == null) continue;
704
+ this.dropPending(order.id);
705
+ const applied = this.applyEntryFill(b, order.id, order.direction, order.qty, px, false);
706
+ if (applied) {
707
+ const q = Math.abs(this.fills.at(-1)?.qty ?? order.qty);
708
+ this.ocaAfterFill(order, q, b);
709
+ this.events.push({
710
+ type: "fill",
711
+ id: order.id,
712
+ direction: order.direction,
713
+ qty: q,
714
+ bar: b,
715
+ });
716
+ filled.push(order.id);
717
+ }
718
+ }
719
+ return filled;
720
+ }
721
+
722
+ /** Market entry at `price`. Flat/same-dir adds qty; opposite closes then reverses. */
723
+ fillEntry(
724
+ bar: number,
725
+ id: string,
726
+ direction: StrategyDirection | number | string,
727
+ qty: number,
728
+ price: number,
729
+ opts?: Pick<PlaceEntryOpts, "comment" | "time">,
730
+ ): void {
731
+ this.applyEntryFill(bar, id, direction, qty, price, true, opts);
732
+ }
733
+
734
+ /** Flatten at `price` and record a close event. */
735
+ fillClose(bar: number, id: string, price: number, opts?: Pick<PlaceEntryOpts, "time">): void {
736
+ this.ensureSanePosition();
737
+ const b = isFiniteNumber(bar) ? bar : 0;
738
+ const oid = String(id ?? "");
739
+ if (this.position.qty !== 0) {
740
+ if (!isFiniteNumber(price)) return;
741
+ this.flattenAt(b, oid, price, opts?.time);
742
+ }
743
+ this.close(b, oid);
744
+ }
745
+
746
+ private applyEntryFill(
747
+ bar: number,
748
+ id: string,
749
+ direction: StrategyDirection | number | string,
750
+ qty: number,
751
+ price: number,
752
+ emitEntry: boolean,
753
+ opts?: Pick<PlaceEntryOpts, "comment" | "time">,
754
+ ): boolean {
755
+ this.ensureSanePosition();
756
+ if (!isFiniteNumber(qty) || qty === 0 || !isFiniteNumber(price)) return false;
757
+ const absQty = Math.abs(qty);
758
+ if (!isFiniteNumber(absQty) || absQty === 0) return false;
759
+ const dir = mapDirection(direction);
760
+ const b = isFiniteNumber(bar) ? bar : 0;
761
+ const oid = String(id ?? "");
762
+ const t = isFiniteNumber(opts?.time) ? opts.time : b;
763
+ this.updateEquityExtremes(price);
764
+ if (!this.riskAllowsEntry(dir, t)) return false;
765
+ const capped = this.capQtyByMaxPosition(absQty, price);
766
+ if (!isFiniteNumber(capped) || capped <= 0) return false;
767
+ const signed = dir === "long" ? capped : -capped;
768
+
769
+ if (this.position.qty !== 0 && Math.sign(this.position.qty) !== Math.sign(signed)) {
770
+ if (!this.flattenAt(b, oid, price, opts?.time)) return false;
771
+ this.close(b, oid);
772
+ } else if (
773
+ this.position.qty !== 0 &&
774
+ this.pyramiding !== undefined &&
775
+ this.sameDirAdds >= this.pyramiding
776
+ ) {
777
+ return false;
778
+ }
779
+
780
+ // After a reverse flatten the book is flat — this is a fresh first entry.
781
+ if (this.position.qty !== 0) this.sameDirAdds++;
782
+ else this.sameDirAdds = 0;
783
+ this.addPosition(b, oid, signed, price, opts);
784
+ this.noteFilledOrder(t);
785
+ this.updateEquityExtremes(price);
786
+ if (emitEntry) this.entry(b, oid, dir, capped);
787
+ return true;
788
+ }
789
+
790
+ private upsertPending(order: PendingOrder): void {
791
+ const i = this.pending.findIndex((p) => p.id === order.id);
792
+ if (i >= 0) this.pending[i] = order;
793
+ else this.pending.push(order);
794
+ }
795
+
796
+ private dropPending(id?: string): void {
797
+ if (id === undefined) {
798
+ this.pending.length = 0;
799
+ return;
800
+ }
801
+ for (let i = this.pending.length - 1; i >= 0; i--) {
802
+ if (this.pending[i]!.id === id) this.pending.splice(i, 1);
803
+ }
804
+ }
805
+
806
+ private addPosition(
807
+ bar: number,
808
+ id: string,
809
+ signedQty: number,
810
+ price: number,
811
+ opts?: Pick<PlaceEntryOpts, "comment" | "time">,
812
+ ): void {
813
+ if (!isFiniteNumber(signedQty) || signedQty === 0 || !isFiniteNumber(price)) return;
814
+ const oldQty = this.position.qty;
815
+ const oldAbs = Math.abs(oldQty);
816
+ const addAbs = Math.abs(signedQty);
817
+ const side = signedQty > 0 ? "buy" : "sell";
818
+ const px = this.recordFill(bar, id, side, addAbs, price);
819
+ if (!isFiniteNumber(px)) return;
820
+ const fee = this.fillFee(addAbs, px);
821
+ const oldAvg = this.position.avgPrice;
822
+ this.position.avgPrice =
823
+ oldAbs === 0 || !isFiniteNumber(oldAvg)
824
+ ? px
825
+ : (oldAvg * oldAbs + px * addAbs) / (oldAbs + addAbs);
826
+ const nextQty = oldQty + signedQty;
827
+ // Guard against −0 and any non-finite leftover from a bad add.
828
+ this.position.qty = isFiniteNumber(nextQty) && nextQty !== 0 ? nextQty : 0;
829
+ if (this.position.qty === 0) {
830
+ this.position.avgPrice = null;
831
+ return;
832
+ }
833
+ const dir: StrategyDirection = this.position.qty > 0 ? "long" : "short";
834
+ this.notePositionSize();
835
+ const comment = opts?.comment != null ? String(opts.comment) : undefined;
836
+ const entryTime = isFiniteNumber(opts?.time) ? opts.time : undefined;
837
+ if (oldAbs === 0 || this.openTrades.length === 0) {
838
+ this.openTrades.length = 0;
839
+ const trade: Trade = {
840
+ id,
841
+ direction: dir,
842
+ qty: Math.abs(this.position.qty),
843
+ entryBar: bar,
844
+ entryPrice: this.position.avgPrice ?? px,
845
+ commission: fee,
846
+ };
847
+ if (entryTime !== undefined) trade.entryTime = entryTime;
848
+ if (comment !== undefined) trade.comment = comment;
849
+ this.openTrades.push(trade);
850
+ return;
851
+ }
852
+ const t = this.openTrades[0]!;
853
+ t.qty = Math.abs(this.position.qty);
854
+ t.entryPrice = this.position.avgPrice ?? px;
855
+ t.commission += fee;
856
+ }
857
+
858
+ /**
859
+ * Flatten the open position at `price`. Returns false when there is nothing
860
+ * to close or the fill cannot be recorded without corrupting state.
861
+ */
862
+ private flattenAt(bar: number, id: string, price: number, exitTime?: number): boolean {
863
+ const q = this.position.qty;
864
+ if (q === 0 || !isFiniteNumber(q) || !isFiniteNumber(price)) return false;
865
+ const side = q > 0 ? "sell" : "buy";
866
+ const absQ = Math.abs(q);
867
+ const px = this.recordFill(bar, id, side, absQ, price);
868
+ if (!isFiniteNumber(px)) return false;
869
+ const avg = this.position.avgPrice;
870
+ const basis = isFiniteNumber(avg) ? avg : px;
871
+ const pnl = q * (px - basis);
872
+ if (isFiniteNumber(pnl)) this.realizedPnl += pnl;
873
+ const t = isFiniteNumber(exitTime) ? exitTime : bar;
874
+ this.closeOpenTrades(bar, id, px, absQ, q > 0 ? "long" : "short", basis, t);
875
+ this.position.qty = 0;
876
+ this.position.avgPrice = null;
877
+ this.sameDirAdds = 0;
878
+ this.closedCount += 1;
879
+ this.noteFilledOrder(t);
880
+ this.updateEquityExtremes(px);
881
+ return true;
882
+ }
883
+
884
+ private ensureSanePosition(): void {
885
+ const q = this.position.qty;
886
+ const avg = this.position.avgPrice;
887
+ if (!isFiniteNumber(q) || (q !== 0 && !isFiniteNumber(avg))) {
888
+ this.position.qty = 0;
889
+ this.position.avgPrice = null;
890
+ this.sameDirAdds = 0;
891
+ this.openTrades.length = 0;
892
+ }
893
+ }
894
+
895
+ private closeOpenTrades(
896
+ bar: number,
897
+ id: string,
898
+ exitPrice: number,
899
+ absQ: number,
900
+ direction: StrategyDirection,
901
+ basis: number,
902
+ exitTime?: number,
903
+ ): void {
904
+ if (this.openTrades.length === 0 && absQ > 0) {
905
+ this.openTrades.push({
906
+ id,
907
+ direction,
908
+ qty: absQ,
909
+ entryBar: bar,
910
+ entryPrice: basis,
911
+ commission: 0,
912
+ });
913
+ }
914
+ const totalQty = this.openTrades.reduce((s, t) => s + t.qty, 0);
915
+ const exitFeeTotal = this.fillFee(absQ, exitPrice);
916
+ for (const ot of this.openTrades) {
917
+ const share = totalQty > 0 ? ot.qty / totalQty : 1;
918
+ const exitComm = exitFeeTotal * share;
919
+ const profit = this.markProfit(ot, exitPrice) - exitComm;
920
+ const closed: Trade = {
921
+ id: ot.id,
922
+ direction: ot.direction,
923
+ qty: ot.qty,
924
+ entryBar: ot.entryBar,
925
+ entryPrice: ot.entryPrice,
926
+ exitBar: bar,
927
+ exitPrice,
928
+ profit,
929
+ commission: ot.commission + exitComm,
930
+ };
931
+ if (ot.entryTime !== undefined) closed.entryTime = ot.entryTime;
932
+ if (ot.comment !== undefined) closed.comment = ot.comment;
933
+ if (ot.max_runup !== undefined) closed.max_runup = ot.max_runup;
934
+ if (ot.max_drawdown !== undefined) closed.max_drawdown = ot.max_drawdown;
935
+ this.closedTrades.push(closed);
936
+ this.noteClosedProfit(profit);
937
+ this.noteClosedTradeDay(isFiniteNumber(exitTime) ? exitTime : bar, profit);
938
+ }
939
+ this.openTrades.length = 0;
940
+ }
941
+
942
+ private noteClosedProfit(profit: number): void {
943
+ if (!isFiniteNumber(profit)) return;
944
+ if (profit > 0) {
945
+ this.grossprofit += profit;
946
+ this.wintrades += 1;
947
+ } else if (profit < 0) {
948
+ this.grossloss += -profit;
949
+ this.losstrades += 1;
950
+ } else {
951
+ this.eventrades += 1;
952
+ }
953
+ }
954
+
955
+ private markProfit(t: Trade, mark: number): number {
956
+ const signed = t.direction === "long" ? 1 : -1;
957
+ return signed * (mark - t.entryPrice) * t.qty - t.commission;
958
+ }
959
+
960
+ private fillFee(qty: number, price: number): number {
961
+ const rate = isFiniteNumber(this.commission) ? this.commission : 0;
962
+ const fee = Math.abs(qty) * Math.abs(price) * rate;
963
+ return isFiniteNumber(fee) ? fee : 0;
964
+ }
965
+
966
+ private pickTrade(list: Trade[], i: unknown): Trade | null {
967
+ if (!isFiniteNumber(i) || !Number.isInteger(i) || i < 0 || i >= list.length) return null;
968
+ return list[i] ?? null;
969
+ }
970
+
971
+ private riskAllowsEntry(direction: StrategyDirection, time: number): boolean {
972
+ const allow = (this.allow_entry_in || "all").toLowerCase().replace(/strategy\./g, "");
973
+ if (allow === "long" && direction !== "long") return false;
974
+ if (allow === "short" && direction !== "short") return false;
975
+ if (this.entries_blocked) return false;
976
+ if (this.max_drawdown_risk != null && this.maxDrawdownAbs >= this.max_drawdown_risk) {
977
+ this.entries_blocked = true;
978
+ return false;
979
+ }
980
+ if (
981
+ this.max_drawdown_risk_percent != null &&
982
+ this.maxDdPercent >= this.max_drawdown_risk_percent
983
+ ) {
984
+ this.entries_blocked = true;
985
+ return false;
986
+ }
987
+ if (this.max_cons_loss_days != null && this.consecutive_loss_days >= this.max_cons_loss_days) {
988
+ this.entries_blocked = true;
989
+ return false;
990
+ }
991
+ if (
992
+ Number.isFinite(this.max_intraday_loss) &&
993
+ this.max_intraday_loss < Number.POSITIVE_INFINITY &&
994
+ this.initialCapital > 0 &&
995
+ this.dayPnl < 0
996
+ ) {
997
+ const lossPct = 100 * -this.dayPnl / this.initialCapital;
998
+ if (lossPct >= this.max_intraday_loss) {
999
+ this.entries_blocked = true;
1000
+ return false;
1001
+ }
1002
+ }
1003
+ if (this.max_intraday_filled_orders != null) {
1004
+ this.rollFillDay(time);
1005
+ if (this.dayFilledOrders >= this.max_intraday_filled_orders) return false;
1006
+ }
1007
+ return true;
1008
+ }
1009
+
1010
+ private capQtyByMaxPosition(qty: number, fillPrice: number): number {
1011
+ const pct = this.max_position_size_percent;
1012
+ if (pct == null || pct <= 0 || !isFiniteNumber(fillPrice) || fillPrice <= 0) return qty;
1013
+ const eq = this.equity(fillPrice);
1014
+ const maxQty = (eq * (pct / 100)) / fillPrice;
1015
+ if (!isFiniteNumber(maxQty) || maxQty < 0) return 0;
1016
+ return Math.min(qty, maxQty);
1017
+ }
1018
+
1019
+ private updateEquityExtremes(mark: number): void {
1020
+ this.equity(mark);
1021
+ }
1022
+
1023
+ /** Peak/trough and max drawdown / runup from an equity sample (Python `_track_equity_curve`). */
1024
+ private trackEquityCurve(eq: number): void {
1025
+ if (!isFiniteNumber(eq)) return;
1026
+ if (eq > this.equityPeak) this.equityPeak = eq;
1027
+ if (eq < this.equityTrough) this.equityTrough = eq;
1028
+ const dd = this.equityPeak - eq;
1029
+ if (dd > this.maxDrawdownAbs) {
1030
+ this.maxDrawdownAbs = dd;
1031
+ if (this.equityPeak > 0) this.maxDdPercent = (100 * dd) / this.equityPeak;
1032
+ }
1033
+ const ru = eq - this.equityTrough;
1034
+ if (ru > this.maxRunupAbs) {
1035
+ this.maxRunupAbs = ru;
1036
+ if (this.equityTrough > 0) this.maxRuPercent = (100 * ru) / this.equityTrough;
1037
+ }
1038
+ }
1039
+
1040
+ /** `100 * amount / initialCapital`; 0 if capital ≤ 0 (Python `_pct_of_initial`). */
1041
+ private pctOfInitial(amount: number): number {
1042
+ const cap = this.initialCapital;
1043
+ if (!isFiniteNumber(cap) || cap <= 0 || !isFiniteNumber(amount)) return 0;
1044
+ return (100 * amount) / cap;
1045
+ }
1046
+
1047
+ /** Update max contracts held after a fill (Python `note_position_size`). */
1048
+ private notePositionSize(): void {
1049
+ const size = Math.abs(this.position.qty);
1050
+ if (!isFiniteNumber(size) || size === 0) return;
1051
+ if (size > this.maxHeldAll) this.maxHeldAll = size;
1052
+ if (this.position.qty > 0 && size > this.maxHeldLong) this.maxHeldLong = size;
1053
+ if (this.position.qty < 0 && size > this.maxHeldShort) this.maxHeldShort = size;
1054
+ }
1055
+
1056
+ private rollFillDay(ts: number): void {
1057
+ const day = dayBucket(isFiniteNumber(ts) ? ts : 0);
1058
+ if (this.fillsDay == null || day !== this.fillsDay) {
1059
+ this.fillsDay = day;
1060
+ this.dayFilledOrders = 0;
1061
+ }
1062
+ }
1063
+
1064
+ private noteFilledOrder(ts: number): void {
1065
+ this.rollFillDay(ts);
1066
+ this.dayFilledOrders += 1;
1067
+ }
1068
+
1069
+ private ocaAfterFill(filled: PendingOrder, fillQty: number, bar: number): void {
1070
+ const name = filled.oca_name;
1071
+ const otype = filled.oca_type ?? "none";
1072
+ if (!name || otype === "none") return;
1073
+ for (const other of this.pending.slice()) {
1074
+ if (other.id === filled.id || other.oca_name !== name) continue;
1075
+ if (otype === "cancel") {
1076
+ this.dropPending(other.id);
1077
+ this.events.push({ type: "cancel", id: other.id, bar });
1078
+ } else if (otype === "reduce") {
1079
+ other.qty = Math.max(0, other.qty - fillQty);
1080
+ if (other.qty <= 1e-12) {
1081
+ this.dropPending(other.id);
1082
+ this.events.push({ type: "cancel", id: other.id, bar });
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ /** Long/buy pays +slip; short/sell receives −slip. Records actual fill price. */
1089
+ private recordFill(
1090
+ bar: number,
1091
+ id: string,
1092
+ side: "buy" | "sell",
1093
+ qty: number,
1094
+ price: number,
1095
+ ): number {
1096
+ const slip = isFiniteNumber(this.slippage) ? this.slippage : 0;
1097
+ const px = side === "buy" ? price + slip : price - slip;
1098
+ if (!isFiniteNumber(px) || !isFiniteNumber(qty) || qty === 0) return Number.NaN;
1099
+ this.fills.push({ bar, id, side, qty, price: px });
1100
+ const rate = isFiniteNumber(this.commission) ? this.commission : 0;
1101
+ const fee = Math.abs(qty) * Math.abs(px) * rate;
1102
+ if (isFiniteNumber(fee)) this.commissionPaid += fee;
1103
+ return px;
1104
+ }
1105
+ }