@hoox-sh/pynets 0.2.0 → 0.3.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.
@@ -3,19 +3,30 @@
3
3
  * SPDX-License-Identifier: AGPL-3.0-or-later
4
4
  *
5
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.
6
+ * (not a full broker). entry/close stay event-only; placeEntry/placeExit
7
+ * fill or pend. fillEntry/fillClose update a signed position and fills.
8
+ * processPending is called once per bar BEFORE the script body (and again
9
+ * after placeExit for same-bar OHLC). Optional commission, slippage, pyramiding.
9
10
  * Non-finite qty/price is a no-op (never corrupts position).
10
11
  * fillEntry from flat / reverse leftover opens a trade; flatten moves it to closed.
11
12
  */
12
13
 
13
14
  export type StrategyDirection = "long" | "short";
15
+ /** `strategy(..., avg_price_model=)` — stock FIFO reweight / sticky futures AEP. */
16
+ export type AvgPriceModel = "stock" | "futures" | "inverse";
17
+ export type DefaultQtyType = "fixed" | "percent_of_equity" | "cash";
14
18
 
15
19
  export interface BrokerSettings {
16
20
  commission?: number; // fraction of notional, default 0 (0.001 = 0.1%)
17
21
  slippage?: number; // price units added against the trade, default 0
18
22
  pyramiding?: number; // max same-direction adds; 0 = no extra adds; omitted = unlimited
23
+ avg_price_model?: string;
24
+ leverage?: number;
25
+ margin_long?: number;
26
+ margin_short?: number;
27
+ default_qty_type?: string | StrategyCashAmount;
28
+ default_qty_value?: number;
29
+ currency?: string;
19
30
  }
20
31
 
21
32
  export interface StrategyEvent {
@@ -51,6 +62,12 @@ export interface PendingOrder {
51
62
  bar: number;
52
63
  oca_name?: string | null;
53
64
  oca_type?: OcaType;
65
+ /** Unset on entry orders so existing pending snapshots stay exact. */
66
+ kind?: "entry" | "exit";
67
+ from_entry?: string | null;
68
+ trail_offset?: number | null;
69
+ trail_activation?: number | null;
70
+ trail_active?: boolean;
54
71
  }
55
72
 
56
73
  export interface PlaceEntryOpts {
@@ -64,6 +81,26 @@ export interface PlaceEntryOpts {
64
81
  oca_type?: string | null;
65
82
  }
66
83
 
84
+ /** Named `strategy.exit` args (Pine: id, from_entry, qty, qty_percent, …). */
85
+ export interface PlaceExitOpts {
86
+ from_entry?: string | null;
87
+ qty?: number | null;
88
+ qty_percent?: number | null;
89
+ profit?: number | null;
90
+ limit?: number | null;
91
+ loss?: number | null;
92
+ stop?: number | null;
93
+ trail_price?: number | null;
94
+ trail_points?: number | null;
95
+ trail_offset?: number | null;
96
+ comment?: string;
97
+ /** Mark price for a market exit (no levels). */
98
+ price?: number;
99
+ time?: number;
100
+ /** Current bar OHLC so a newly placed exit can fill same bar. */
101
+ ohlc?: BarOhlc;
102
+ }
103
+
67
104
  /** One open or closed strategy trade (qty is absolute). */
68
105
  export interface Trade {
69
106
  id: string;
@@ -73,14 +110,31 @@ export interface Trade {
73
110
  entryPrice: number;
74
111
  entryTime?: number;
75
112
  exitBar?: number;
113
+ exitTime?: number;
76
114
  exitPrice?: number;
77
115
  profit?: number;
78
116
  commission: number;
79
117
  comment?: string;
118
+ entry_comment?: string;
119
+ exit_comment?: string;
80
120
  max_runup?: number;
81
121
  max_drawdown?: number;
82
122
  }
83
123
 
124
+ /** Free-cash series that also tags `default_qty_type=strategy.cash` (Python `StrategyCashAmount`). */
125
+ export interface StrategyCashAmount {
126
+ value: number;
127
+ _pine_qty_type: "cash";
128
+ }
129
+
130
+ export function isStrategyCashAmount(v: unknown): v is StrategyCashAmount {
131
+ return typeof v === "object" && v !== null && (v as StrategyCashAmount)._pine_qty_type === "cash";
132
+ }
133
+
134
+ export function strategyCashAmount(value: number): StrategyCashAmount {
135
+ return { value: isFiniteNumber(value) ? value : 0, _pine_qty_type: "cash" };
136
+ }
137
+
84
138
  export interface BarOhlc {
85
139
  open?: number;
86
140
  high?: number;
@@ -121,6 +175,67 @@ function optLevel(v: number | null | undefined): number | null {
121
175
  return isFiniteNumber(v) ? v : null;
122
176
  }
123
177
 
178
+ function isTrailOrder(order: PendingOrder): boolean {
179
+ return order.trail_offset != null && order.trail_offset > 0;
180
+ }
181
+
182
+ /** `trail_points` when > 0, else `trail_offset` when > 0. Distances are ticks. */
183
+ function resolveTrailParams(
184
+ trailPrice: number | null,
185
+ trailPoints: number | null,
186
+ trailOffset: number | null,
187
+ mintick: number,
188
+ ): { activation: number | null; offsetPx: number | null } {
189
+ const ticks =
190
+ trailPoints != null && trailPoints > 0
191
+ ? trailPoints
192
+ : trailOffset != null && trailOffset > 0
193
+ ? trailOffset
194
+ : null;
195
+ if (ticks == null) return { activation: null, offsetPx: null };
196
+ const tick = isFiniteNumber(mintick) && mintick > 0 ? mintick : 0.01;
197
+ const offsetPx = ticks * tick;
198
+ if (!isFiniteNumber(offsetPx) || offsetPx <= 0) return { activation: null, offsetPx: null };
199
+ return { activation: trailPrice, offsetPx };
200
+ }
201
+
202
+ function tickOffsetPrice(
203
+ ticks: number | null,
204
+ entryAvg: number | null,
205
+ mintick: number,
206
+ isLong: boolean,
207
+ isProfit: boolean,
208
+ ): number | null {
209
+ if (ticks == null || entryAvg == null) return null;
210
+ if (ticks <= 0 || !isFiniteNumber(ticks) || !isFiniteNumber(entryAvg)) return null;
211
+ const tick = isFiniteNumber(mintick) && mintick > 0 ? mintick : 0.01;
212
+ const offset = ticks * tick;
213
+ if (!isFiniteNumber(offset) || offset <= 0) return null;
214
+ if (isLong) return isProfit ? entryAvg + offset : entryAvg - offset;
215
+ return isProfit ? entryAvg - offset : entryAvg + offset;
216
+ }
217
+
218
+ /** Long exit (sell stop) ratchets up from high; short exit (buy stop) down from low. */
219
+ function updateTrailStop(order: PendingOrder, high: number, low: number): void {
220
+ if (!isTrailOrder(order)) return;
221
+ const offset = order.trail_offset!;
222
+ const action = order.direction;
223
+ if (!order.trail_active) {
224
+ const act = order.trail_activation;
225
+ if (act == null) order.trail_active = true;
226
+ else if (action === "short" && high >= act) order.trail_active = true;
227
+ else if (action === "long" && low <= act) order.trail_active = true;
228
+ else return;
229
+ }
230
+ if (action === "short") {
231
+ const candidate = high - offset;
232
+ if (order.stop == null || candidate > order.stop) order.stop = candidate;
233
+ } else if (action === "long") {
234
+ const candidate = low + offset;
235
+ if (order.stop == null || candidate < order.stop) order.stop = candidate;
236
+ }
237
+ }
238
+
124
239
  /** Raw trigger price (slippage applied later via recordFill). */
125
240
  function triggerPrice(order: PendingOrder, open: number, high: number, low: number): number | null {
126
241
  const lim = order.limit;
@@ -155,6 +270,57 @@ function normalizePyramiding(v: unknown): number | undefined {
155
270
  return Math.max(0, Math.trunc(v));
156
271
  }
157
272
 
273
+ /** Soft-normalize `strategy(..., avg_price_model=)` (Python `_norm_avg_price_model`). */
274
+ function normalizeAvgPriceModel(value: unknown, fallback: AvgPriceModel = "stock"): AvgPriceModel {
275
+ if (value == null) return fallback;
276
+ const raw = String(value).replace(/strategy\./gi, "").replace(/avg_price_/gi, "").trim().toLowerCase();
277
+ if (!raw || raw === "nan" || raw === "na" || raw === "none") return fallback;
278
+ if (raw === "stock" || raw === "pine" || raw === "average" || raw === "avg" || raw === "lot" || raw === "fifo") {
279
+ return "stock";
280
+ }
281
+ if (raw === "futures" || raw === "future" || raw === "perp" || raw === "perpetual" || raw === "net" || raw === "linear") {
282
+ return "futures";
283
+ }
284
+ if (raw === "inverse" || raw === "coin" || raw === "coin_m" || raw === "harmonic") return "inverse";
285
+ return fallback;
286
+ }
287
+
288
+ function softFloatDecl(value: unknown, fallback: number): number {
289
+ if (value == null) return fallback;
290
+ if (typeof value === "boolean") return value ? 1 : 0;
291
+ if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
292
+ if (typeof value === "string") {
293
+ const f = Number(value);
294
+ return Number.isFinite(f) ? f : fallback;
295
+ }
296
+ return fallback;
297
+ }
298
+
299
+ /** Leverage ≥ 1; (0, 1) clamps to 1× (Python `_norm_leverage`). */
300
+ function normalizeLeverage(value: unknown, fallback = 1): number {
301
+ const lev = softFloatDecl(value, fallback);
302
+ if (lev <= 0) return fallback;
303
+ if (lev < 1) return 1;
304
+ return lev;
305
+ }
306
+
307
+ function pineQtyType(value: unknown): string | undefined {
308
+ if (value == null || typeof value !== "object") return undefined;
309
+ const tagged = (value as { _pine_qty_type?: unknown })._pine_qty_type;
310
+ return typeof tagged === "string" ? tagged : undefined;
311
+ }
312
+
313
+ function normalizeDefaultQtyType(value: unknown): DefaultQtyType | undefined {
314
+ if (value == null) return undefined;
315
+ const tagged = pineQtyType(value);
316
+ if (tagged === "cash" || tagged === "fixed" || tagged === "percent_of_equity") return tagged;
317
+ if (typeof value === "number") return undefined;
318
+ let dqt = String(value).replace(/strategy\./gi, "").trim().toLowerCase();
319
+ if (dqt === "percent" || dqt === "percentage") dqt = "percent_of_equity";
320
+ if (dqt === "fixed" || dqt === "percent_of_equity" || dqt === "cash") return dqt;
321
+ return undefined;
322
+ }
323
+
158
324
  /** 1 / "long" / *long* → long; -1 / "short" / *short* → short. */
159
325
  function mapDirection(direction: unknown): StrategyDirection {
160
326
  if (direction === 1 || direction === "1") return "long";
@@ -198,6 +364,24 @@ export class StrategyBook {
198
364
  /** Positive magnitude of losing-trade profits. */
199
365
  grossloss = 0;
200
366
  initialCapital = 1_000_000;
367
+ /** `syminfo.mintick`; profit/loss/trail distances are ticks × this. */
368
+ mintick = 0.01;
369
+
370
+ /** `strategy(..., avg_price_model=)` — stock (default) | futures | inverse. */
371
+ avg_price_model: AvgPriceModel = "stock";
372
+ /** Buying-power multiplier; default 1×. `qty = margin * leverage / price` for cash/%. */
373
+ leverage = 1;
374
+ /** TV-style margin % (`100 / leverage` when leverage is set). */
375
+ margin_long = 100;
376
+ margin_short = 100;
377
+ default_qty_type: DefaultQtyType = "fixed";
378
+ default_qty_value = 1;
379
+ /** `strategy.account_currency` from `strategy(..., currency=)`. */
380
+ account_currency = "USD";
381
+ /** Current open position's entry id (Python `position_entry_name`). */
382
+ position_entry_name = "";
383
+ /** Pine `strategy.closedtrades.first_index` — always 0 here. */
384
+ closedtrades_first_index = 0;
201
385
 
202
386
  /** strategy.risk.allow_entry_in — `all` | `long` | `short`. */
203
387
  allow_entry_in: AllowEntryIn = "all";
@@ -243,6 +427,23 @@ export class StrategyBook {
243
427
  this.pyramiding = normalizePyramiding(settings?.pyramiding);
244
428
  this.equityPeak = this.initialCapital;
245
429
  this.equityTrough = this.initialCapital;
430
+ if (settings) this.applyDeclSettings(settings);
431
+ }
432
+
433
+ /** Margin locked in open trades: sum(|entry * size|) / leverage (Python `capital_held`). */
434
+ capitalHeld(): number {
435
+ let notional = 0;
436
+ for (const t of this.openTrades) {
437
+ const n = Math.abs(t.entryPrice * t.qty);
438
+ if (isFiniteNumber(n)) notional += n;
439
+ }
440
+ const lev = this.leverage && this.leverage > 0 ? this.leverage : 1;
441
+ return notional / lev;
442
+ }
443
+
444
+ /** Free cash: equity(mark) − capitalHeld() (Python `StrategyState.cash`). */
445
+ cash(mark: number): number {
446
+ return this.equity(mark) - this.capitalHeld();
246
447
  }
247
448
 
248
449
  /** Mark-to-market equity: initial + realized − commission + open PnL. */
@@ -300,6 +501,31 @@ export class StrategyBook {
300
501
  return this.closedTrade(i)?.commission ?? null;
301
502
  }
302
503
 
504
+ closedEntryTime(i: number): number {
505
+ return this.closedTrade(i)?.entryTime ?? 0;
506
+ }
507
+
508
+ closedExitTime(i: number): number {
509
+ return this.closedTrade(i)?.exitTime ?? 0;
510
+ }
511
+
512
+ closedEntryComment(i: number): string {
513
+ const t = this.closedTrade(i);
514
+ return t == null ? "" : String(t.entry_comment ?? t.comment ?? "");
515
+ }
516
+
517
+ closedExitComment(i: number): string {
518
+ return this.closedTrade(i)?.exit_comment ?? "";
519
+ }
520
+
521
+ closedMaxDrawdown(i: number): number {
522
+ return this.closedTrade(i)?.max_drawdown ?? 0;
523
+ }
524
+
525
+ closedMaxRunup(i: number): number {
526
+ return this.closedTrade(i)?.max_runup ?? 0;
527
+ }
528
+
303
529
  openEntryBar(i: number): number | null {
304
530
  return this.openTrade(i)?.entryBar ?? null;
305
531
  }
@@ -322,6 +548,27 @@ export class StrategyBook {
322
548
  return this.markProfit(t, markPrice);
323
549
  }
324
550
 
551
+ openEntryTime(i: number): number {
552
+ return this.openTrade(i)?.entryTime ?? 0;
553
+ }
554
+
555
+ openEntryComment(i: number): string {
556
+ const t = this.openTrade(i);
557
+ return t == null ? "" : String(t.entry_comment ?? t.comment ?? "");
558
+ }
559
+
560
+ openCommission(i: number): number {
561
+ return this.openTrade(i)?.commission ?? 0;
562
+ }
563
+
564
+ openMaxDrawdown(i: number): number {
565
+ return this.openTrade(i)?.max_drawdown ?? 0;
566
+ }
567
+
568
+ openMaxRunup(i: number): number {
569
+ return this.openTrade(i)?.max_runup ?? 0;
570
+ }
571
+
325
572
  avgTrade(): number {
326
573
  const n = this.closedTrades.length;
327
574
  return n === 0 ? 0 : (this.grossprofit - this.grossloss) / n;
@@ -481,6 +728,79 @@ export class StrategyBook {
481
728
  const pyr = normalizePyramiding(settings.pyramiding);
482
729
  if (pyr !== undefined || settings.pyramiding == null) this.pyramiding = pyr;
483
730
  }
731
+ this.applyDeclSettings(settings);
732
+ }
733
+
734
+ /**
735
+ * Resolve omitted `strategy.entry` qty from `default_qty_type` / `leverage`.
736
+ * fixed: value (default 1); percent/cash: `margin * leverage / price`.
737
+ */
738
+ resolveDefaultQty(fillPrice: number): number {
739
+ const dqt = this.default_qty_type || "fixed";
740
+ const val = isFiniteNumber(this.default_qty_value) ? this.default_qty_value : 0;
741
+ const price = isFiniteNumber(fillPrice) && fillPrice > 0 ? fillPrice : 1;
742
+ const lev = this.leverage && this.leverage > 0 ? this.leverage : 1;
743
+ if (dqt === "percent_of_equity") {
744
+ const margin = this.equity(price) * (val / 100);
745
+ return Math.max(0, (margin * lev) / price);
746
+ }
747
+ if (dqt === "cash") return Math.max(0, (val * lev) / price);
748
+ return Math.max(0, val > 0 ? val : 1);
749
+ }
750
+
751
+ /**
752
+ * Isolated liq estimate: long `entry*(1-1/lev)`, short `entry*(1+1/lev)`.
753
+ * `null` (na) when flat or leverage ≤ 1.
754
+ */
755
+ marginLiquidationPrice(): number | null {
756
+ this.ensureSanePosition();
757
+ const q = this.position.qty;
758
+ const avg = this.position.avgPrice;
759
+ if (q === 0 || !isFiniteNumber(avg) || avg <= 0) return null;
760
+ const lev = this.leverage && this.leverage > 0 ? this.leverage : 1;
761
+ if (lev <= 1) return null;
762
+ return q > 0 ? avg * (1 - 1 / lev) : avg * (1 + 1 / lev);
763
+ }
764
+
765
+ /** Apply `strategy()` extras: avg model, leverage/margin, default qty. */
766
+ private applyDeclSettings(settings: BrokerSettings): void {
767
+ if (settings.avg_price_model != null) {
768
+ this.avg_price_model = normalizeAvgPriceModel(settings.avg_price_model);
769
+ }
770
+ if (settings.default_qty_type != null) {
771
+ const dqt = normalizeDefaultQtyType(settings.default_qty_type);
772
+ if (dqt) this.default_qty_type = dqt;
773
+ }
774
+ if (settings.default_qty_value != null) {
775
+ const v = softFloatDecl(settings.default_qty_value, Number.NaN);
776
+ if (Number.isFinite(v)) this.default_qty_value = v;
777
+ }
778
+ if (typeof settings.currency === "string" && settings.currency !== "") {
779
+ this.account_currency = settings.currency;
780
+ }
781
+ const hasLev = settings.leverage != null;
782
+ const hasMl = settings.margin_long != null;
783
+ const hasMs = settings.margin_short != null;
784
+ if (hasLev) {
785
+ this.leverage = normalizeLeverage(settings.leverage);
786
+ this.margin_long = 100 / this.leverage;
787
+ this.margin_short = 100 / this.leverage;
788
+ }
789
+ if (hasMl) {
790
+ const ml = softFloatDecl(settings.margin_long, 100);
791
+ if (ml > 0) {
792
+ this.margin_long = ml;
793
+ if (!hasLev) this.leverage = Math.max(1, 100 / ml);
794
+ }
795
+ }
796
+ if (hasMs) {
797
+ const ms = softFloatDecl(settings.margin_short, 100);
798
+ if (ms > 0) {
799
+ this.margin_short = ms;
800
+ if (!hasLev && !hasMl) this.leverage = Math.max(1, 100 / ms);
801
+ else if (!hasLev && hasMl) this.leverage = Math.max(1, 100 / Math.max(this.margin_long, this.margin_short));
802
+ }
803
+ }
484
804
  }
485
805
 
486
806
  /** `strategy.risk.allow_entry_in(value)` — `all` | `long` | `short`. */
@@ -585,12 +905,14 @@ export class StrategyBook {
585
905
  });
586
906
  }
587
907
 
588
- exit(bar: number, id: string): void {
589
- this.events.push({
908
+ exit(bar: number, id: string, qty?: number | null): void {
909
+ const event: StrategyEvent = {
590
910
  type: "exit",
591
911
  id: String(id ?? ""),
592
912
  bar: isFiniteNumber(bar) ? bar : 0,
593
- });
913
+ };
914
+ if (qty !== undefined) event.qty = qty;
915
+ this.events.push(event);
594
916
  }
595
917
 
596
918
  cancel(bar: number, id: string): void {
@@ -674,6 +996,117 @@ export class StrategyBook {
674
996
  this.placeEntry(bar, id, direction, qty, opts);
675
997
  }
676
998
 
999
+ /**
1000
+ * Place a `strategy.exit` bracket. Always emits an `exit` event (except
1001
+ * invalid qty). No levels → market close now. Else pending fill via
1002
+ * processPending (same-bar when `opts.ohlc` is set).
1003
+ */
1004
+ placeExit(bar: number, id: string, opts?: PlaceExitOpts): void {
1005
+ const b = isFiniteNumber(bar) ? bar : 0;
1006
+ const oid = String(id ?? "exit");
1007
+ const rawFrom = opts?.from_entry;
1008
+ const fromEntry =
1009
+ rawFrom == null || rawFrom === "" ? null : String(rawFrom);
1010
+
1011
+ const targetSize = this.entryOpenSize(fromEntry);
1012
+ const qtyStatus = this.resolveExitQty(targetSize, opts?.qty, opts?.qty_percent);
1013
+ if (qtyStatus.status === "invalid") return;
1014
+
1015
+ const qty = qtyStatus.qty;
1016
+ const isLong = this.position.qty > 0;
1017
+ const isFlat = this.position.qty === 0;
1018
+ let limitP = optLevel(opts?.limit);
1019
+ let stopP = optLevel(opts?.stop);
1020
+ const profitTicks = optLevel(opts?.profit);
1021
+ const lossTicks = optLevel(opts?.loss);
1022
+ if (!isFlat) {
1023
+ const entryAvg = this.exitEntryAvg(fromEntry);
1024
+ if (limitP == null) {
1025
+ limitP = tickOffsetPrice(profitTicks, entryAvg, this.mintick, isLong, true);
1026
+ }
1027
+ if (stopP == null) {
1028
+ stopP = tickOffsetPrice(lossTicks, entryAvg, this.mintick, isLong, false);
1029
+ }
1030
+ }
1031
+ const trail = resolveTrailParams(
1032
+ optLevel(opts?.trail_price),
1033
+ optLevel(opts?.trail_points),
1034
+ optLevel(opts?.trail_offset),
1035
+ this.mintick,
1036
+ );
1037
+ const hasTrail = trail.offsetPx != null;
1038
+
1039
+ this.exit(b, oid, qty);
1040
+
1041
+ if (isFlat || qty <= 0) return;
1042
+ if (fromEntry != null && targetSize <= 0) return;
1043
+
1044
+ const t = isFiniteNumber(opts?.time) ? opts.time : b;
1045
+ if (limitP == null && stopP == null && !hasTrail) {
1046
+ if (!isFiniteNumber(opts?.price)) return;
1047
+ this.reducePosition(b, oid, opts.price, qty, fromEntry, t, opts?.comment);
1048
+ return;
1049
+ }
1050
+
1051
+ this.dropExitPending(oid);
1052
+ const closeDir: StrategyDirection = isLong ? "short" : "long";
1053
+ const ocaName = oid;
1054
+
1055
+ const mkExit = (
1056
+ exitId: string,
1057
+ limit: number | null,
1058
+ stop: number | null,
1059
+ trailFields?: Pick<PendingOrder, "trail_offset" | "trail_activation" | "trail_active">,
1060
+ oca?: boolean,
1061
+ ): PendingOrder => {
1062
+ const p: PendingOrder = {
1063
+ id: exitId,
1064
+ direction: closeDir,
1065
+ qty,
1066
+ limit,
1067
+ stop,
1068
+ bar: b,
1069
+ kind: "exit",
1070
+ };
1071
+ if (fromEntry != null) p.from_entry = fromEntry;
1072
+ if (oca) {
1073
+ p.oca_name = ocaName;
1074
+ p.oca_type = "cancel";
1075
+ }
1076
+ if (trailFields) {
1077
+ if (trailFields.trail_offset != null) p.trail_offset = trailFields.trail_offset;
1078
+ if (trailFields.trail_activation !== undefined) p.trail_activation = trailFields.trail_activation;
1079
+ if (trailFields.trail_active != null) p.trail_active = trailFields.trail_active;
1080
+ }
1081
+ return p;
1082
+ };
1083
+
1084
+ const trailFields: Pick<PendingOrder, "trail_offset" | "trail_activation" | "trail_active"> | undefined =
1085
+ hasTrail
1086
+ ? {
1087
+ trail_offset: trail.offsetPx,
1088
+ trail_activation: trail.activation,
1089
+ trail_active: trail.activation == null,
1090
+ }
1091
+ : undefined;
1092
+
1093
+ if (limitP != null && stopP != null) {
1094
+ this.upsertPending(mkExit(`${oid}:limit`, limitP, null, undefined, true));
1095
+ this.upsertPending(mkExit(`${oid}:stop`, null, stopP, trailFields, true));
1096
+ } else if (limitP != null && hasTrail) {
1097
+ this.upsertPending(mkExit(`${oid}:limit`, limitP, null, undefined, true));
1098
+ this.upsertPending(mkExit(`${oid}:trail`, null, stopP, trailFields, true));
1099
+ } else if (limitP != null) {
1100
+ this.upsertPending(mkExit(oid, limitP, null));
1101
+ } else if (hasTrail) {
1102
+ this.upsertPending(mkExit(oid, null, stopP, trailFields));
1103
+ } else {
1104
+ this.upsertPending(mkExit(oid, null, stopP));
1105
+ }
1106
+
1107
+ if (opts?.ohlc != null) this.processPending(b, opts.ohlc);
1108
+ }
1109
+
677
1110
  /**
678
1111
  * Fill pending limit/stop/stop-limit orders against this bar's OHLC.
679
1112
  * Hosts call this once per bar BEFORE the script body (Python interpret).
@@ -699,10 +1132,14 @@ export class StrategyBook {
699
1132
  const filled: string[] = [];
700
1133
  for (const order of this.pending.slice()) {
701
1134
  if (!this.pending.some((p) => p.id === order.id)) continue;
1135
+ if (order.kind === "exit" && isTrailOrder(order)) updateTrailStop(order, high, low);
702
1136
  const px = triggerPrice(order, open, high, low);
703
1137
  if (px == null) continue;
704
1138
  this.dropPending(order.id);
705
- const applied = this.applyEntryFill(b, order.id, order.direction, order.qty, px, false);
1139
+ const applied =
1140
+ order.kind === "exit"
1141
+ ? this.applyExitFill(b, order, px)
1142
+ : this.applyEntryFill(b, order.id, order.direction, order.qty, px, false);
706
1143
  if (applied) {
707
1144
  const q = Math.abs(this.fills.at(-1)?.qty ?? order.qty);
708
1145
  this.ocaAfterFill(order, q, b);
@@ -731,14 +1168,26 @@ export class StrategyBook {
731
1168
  this.applyEntryFill(bar, id, direction, qty, price, true, opts);
732
1169
  }
733
1170
 
734
- /** Flatten at `price` and record a close event. */
735
- fillClose(bar: number, id: string, price: number, opts?: Pick<PlaceEntryOpts, "time">): void {
1171
+ /** Flatten (or reduce when `opts.qty` is a smaller finite size) at `price`. */
1172
+ fillClose(
1173
+ bar: number,
1174
+ id: string,
1175
+ price: number,
1176
+ opts?: Pick<PlaceEntryOpts, "time" | "comment"> & { qty?: number | null },
1177
+ ): void {
736
1178
  this.ensureSanePosition();
737
1179
  const b = isFiniteNumber(bar) ? bar : 0;
738
1180
  const oid = String(id ?? "");
739
1181
  if (this.position.qty !== 0) {
740
1182
  if (!isFiniteNumber(price)) return;
741
- this.flattenAt(b, oid, price, opts?.time);
1183
+ const absPos = Math.abs(this.position.qty);
1184
+ const q = opts?.qty;
1185
+ const cmt = opts?.comment;
1186
+ if (isFiniteNumber(q) && q >= 0 && q < absPos - 1e-12) {
1187
+ if (q > 0) this.reducePosition(b, oid, price, q, null, opts?.time, cmt);
1188
+ } else {
1189
+ this.flattenAt(b, oid, price, opts?.time, cmt);
1190
+ }
742
1191
  }
743
1192
  this.close(b, oid);
744
1193
  }
@@ -803,6 +1252,207 @@ export class StrategyBook {
803
1252
  }
804
1253
  }
805
1254
 
1255
+ /** Drop `id` and `id:*` (replace prior exit legs). */
1256
+ private dropExitPending(exitId: string): void {
1257
+ for (let i = this.pending.length - 1; i >= 0; i--) {
1258
+ const pid = this.pending[i]!.id;
1259
+ if (pid === exitId || pid.startsWith(`${exitId}:`)) this.pending.splice(i, 1);
1260
+ }
1261
+ }
1262
+
1263
+ /** Open size for `from_entry` legs, or whole |position| when unset. */
1264
+ private entryOpenSize(fromEntry: string | null): number {
1265
+ const abs = Math.abs(this.position.qty);
1266
+ if (!fromEntry) return abs;
1267
+ if (this.openTrades.length === 0 && abs > 0) return abs;
1268
+ return this.openTrades.reduce((s, t) => (t.id === fromEntry ? s + t.qty : s), 0);
1269
+ }
1270
+
1271
+ private exitEntryAvg(fromEntry: string | null): number | null {
1272
+ if (fromEntry) {
1273
+ const legs = this.openTrades.filter((t) => t.id === fromEntry);
1274
+ const total = legs.reduce((s, t) => s + t.qty, 0);
1275
+ if (total > 0) {
1276
+ return legs.reduce((s, t) => s + t.entryPrice * t.qty, 0) / total;
1277
+ }
1278
+ if (this.openTrades.length === 0 && this.position.qty !== 0) {
1279
+ const px = this.position.avgPrice;
1280
+ return isFiniteNumber(px) ? px : null;
1281
+ }
1282
+ return null;
1283
+ }
1284
+ if (this.position.qty === 0) return null;
1285
+ const px = this.position.avgPrice;
1286
+ return isFiniteNumber(px) ? px : null;
1287
+ }
1288
+
1289
+ private resolveExitQty(
1290
+ targetSize: number,
1291
+ rawQty: number | null | undefined,
1292
+ rawQtyPercent: number | null | undefined,
1293
+ ): { status: "ok" | "invalid"; qty: number } {
1294
+ const target = Math.max(0, targetSize);
1295
+ const pct = optLevel(rawQtyPercent);
1296
+ if (pct != null) {
1297
+ if (pct <= 0) return { status: "ok", qty: 0 };
1298
+ return { status: "ok", qty: target * (Math.min(pct, 100) / 100) };
1299
+ }
1300
+ if (rawQty == null) return { status: "ok", qty: target };
1301
+ if (!isFiniteNumber(rawQty) || rawQty < 0) return { status: "invalid", qty: 0 };
1302
+ return { status: "ok", qty: Math.min(rawQty, target) };
1303
+ }
1304
+
1305
+ private applyExitFill(bar: number, order: PendingOrder, price: number): boolean {
1306
+ const t = isFiniteNumber(bar) ? bar : 0;
1307
+ return this.reducePosition(t, order.id, price, order.qty, order.from_entry ?? null, t);
1308
+ }
1309
+
1310
+ private closeLeg(
1311
+ ot: Trade,
1312
+ bar: number,
1313
+ exitPrice: number,
1314
+ qty: number,
1315
+ profit: number,
1316
+ commission: number,
1317
+ exitTime?: number,
1318
+ exitComment?: string,
1319
+ ): Trade {
1320
+ const closed: Trade = {
1321
+ id: ot.id,
1322
+ direction: ot.direction,
1323
+ qty,
1324
+ entryBar: ot.entryBar,
1325
+ entryPrice: ot.entryPrice,
1326
+ exitBar: bar,
1327
+ exitPrice,
1328
+ profit,
1329
+ commission,
1330
+ };
1331
+ if (ot.entryTime !== undefined) closed.entryTime = ot.entryTime;
1332
+ if (isFiniteNumber(exitTime)) closed.exitTime = exitTime;
1333
+ const entryCmt = ot.entry_comment ?? ot.comment;
1334
+ if (entryCmt !== undefined) {
1335
+ closed.entry_comment = entryCmt;
1336
+ closed.comment = entryCmt;
1337
+ }
1338
+ if (exitComment != null && exitComment !== "") closed.exit_comment = String(exitComment);
1339
+ if (ot.max_runup !== undefined) closed.max_runup = ot.max_runup;
1340
+ if (ot.max_drawdown !== undefined) closed.max_drawdown = ot.max_drawdown;
1341
+ return closed;
1342
+ }
1343
+
1344
+ /**
1345
+ * Reduce open legs (optional `from_entry` filter) and realize PnL.
1346
+ * Unknown `from_entry` is a soft no-op.
1347
+ */
1348
+ private reducePosition(
1349
+ bar: number,
1350
+ fillId: string,
1351
+ price: number,
1352
+ qty: number,
1353
+ fromEntry: string | null,
1354
+ exitTime?: number,
1355
+ exitComment?: string,
1356
+ ): boolean {
1357
+ this.ensureSanePosition();
1358
+ if (this.position.qty === 0 || !isFiniteNumber(qty) || qty <= 0 || !isFiniteNumber(price)) {
1359
+ return false;
1360
+ }
1361
+ const fe = fromEntry == null || fromEntry === "" ? null : String(fromEntry);
1362
+ if (this.openTrades.length === 0 && this.position.qty !== 0) {
1363
+ this.openTrades.push({
1364
+ id: fe ?? fillId,
1365
+ direction: this.position.qty > 0 ? "long" : "short",
1366
+ qty: Math.abs(this.position.qty),
1367
+ entryBar: bar,
1368
+ entryPrice: this.position.avgPrice ?? price,
1369
+ commission: 0,
1370
+ });
1371
+ }
1372
+ if (fe != null && !this.openTrades.some((t) => t.id === fe)) return false;
1373
+
1374
+ const eligible = fe != null ? this.openTrades.filter((t) => t.id === fe) : this.openTrades;
1375
+ const target = eligible.reduce((s, t) => s + t.qty, 0);
1376
+ const closeQty = Math.min(qty, target);
1377
+ if (closeQty <= 0) return false;
1378
+
1379
+ if (fe == null && closeQty >= Math.abs(this.position.qty) - 1e-12) {
1380
+ return this.flattenAt(bar, fillId, price, exitTime, exitComment);
1381
+ }
1382
+
1383
+ const side = this.position.qty > 0 ? "sell" : "buy";
1384
+ const px = this.recordFill(bar, fillId, side, closeQty, price);
1385
+ if (!isFiniteNumber(px)) return false;
1386
+
1387
+ const useSticky = this.avg_price_model === "futures" || this.avg_price_model === "inverse";
1388
+ const stickyAvg = isFiniteNumber(this.position.avgPrice) ? this.position.avgPrice : px;
1389
+ const exitFeeTotal = this.fillFee(closeQty, px);
1390
+ let remaining = closeQty;
1391
+ let realized = 0;
1392
+ const newOpen: Trade[] = [];
1393
+ for (const ot of this.openTrades) {
1394
+ if (fe != null && ot.id !== fe) {
1395
+ newOpen.push(ot);
1396
+ continue;
1397
+ }
1398
+ if (remaining <= 0) {
1399
+ newOpen.push(ot);
1400
+ continue;
1401
+ }
1402
+ const cq = Math.min(ot.qty, remaining);
1403
+ const entryComm = ot.qty > 0 ? ot.commission * (cq / ot.qty) : 0;
1404
+ const exitComm = closeQty > 0 ? exitFeeTotal * (cq / closeQty) : 0;
1405
+ const basis = useSticky ? stickyAvg : ot.entryPrice;
1406
+ const signed = ot.direction === "long" ? 1 : -1;
1407
+ const profit = signed * (px - basis) * cq - entryComm - exitComm;
1408
+ const closed = this.closeLeg(
1409
+ { ...ot, entryPrice: basis },
1410
+ bar,
1411
+ px,
1412
+ cq,
1413
+ profit,
1414
+ entryComm + exitComm,
1415
+ exitTime,
1416
+ exitComment,
1417
+ );
1418
+ this.closedTrades.push(closed);
1419
+ this.noteClosedProfit(profit);
1420
+ this.noteClosedTradeDay(isFiniteNumber(exitTime) ? exitTime : bar, profit);
1421
+ const pnl = signed * (px - basis) * cq;
1422
+ if (isFiniteNumber(pnl)) realized += pnl;
1423
+ const leftover = ot.qty - cq;
1424
+ if (leftover > 1e-12) {
1425
+ newOpen.push({ ...ot, qty: leftover, commission: ot.commission - entryComm });
1426
+ }
1427
+ remaining -= cq;
1428
+ }
1429
+
1430
+ this.openTrades.length = 0;
1431
+ this.openTrades.push(...newOpen);
1432
+ this.position_entry_name = this.openTrades[0]?.id ?? "";
1433
+ if (isFiniteNumber(realized)) this.realizedPnl += realized;
1434
+
1435
+ const remQty = this.openTrades.reduce((s, t) => s + t.qty, 0);
1436
+ if (remQty <= 1e-12) {
1437
+ this.position.qty = 0;
1438
+ this.position.avgPrice = null;
1439
+ this.sameDirAdds = 0;
1440
+ this.openTrades.length = 0;
1441
+ this.position_entry_name = "";
1442
+ } else {
1443
+ const sign = this.position.qty >= 0 ? 1 : -1;
1444
+ this.position.qty = sign * remQty;
1445
+ this.position.avgPrice = useSticky
1446
+ ? stickyAvg
1447
+ : this.openTrades.reduce((s, t) => s + t.entryPrice * t.qty, 0) / remQty;
1448
+ }
1449
+ this.closedCount += 1;
1450
+ const t = isFiniteNumber(exitTime) ? exitTime : bar;
1451
+ this.noteFilledOrder(t);
1452
+ this.updateEquityExtremes(px);
1453
+ return true;
1454
+ }
1455
+
806
1456
  private addPosition(
807
1457
  bar: number,
808
1458
  id: string,
@@ -832,6 +1482,7 @@ export class StrategyBook {
832
1482
  }
833
1483
  const dir: StrategyDirection = this.position.qty > 0 ? "long" : "short";
834
1484
  this.notePositionSize();
1485
+ this.position_entry_name = id;
835
1486
  const comment = opts?.comment != null ? String(opts.comment) : undefined;
836
1487
  const entryTime = isFiniteNumber(opts?.time) ? opts.time : undefined;
837
1488
  if (oldAbs === 0 || this.openTrades.length === 0) {
@@ -845,21 +1496,42 @@ export class StrategyBook {
845
1496
  commission: fee,
846
1497
  };
847
1498
  if (entryTime !== undefined) trade.entryTime = entryTime;
848
- if (comment !== undefined) trade.comment = comment;
1499
+ if (comment !== undefined) {
1500
+ trade.comment = comment;
1501
+ trade.entry_comment = comment;
1502
+ }
849
1503
  this.openTrades.push(trade);
850
1504
  return;
851
1505
  }
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;
1506
+ const existing = this.openTrades.find((t) => t.id === id && t.direction === dir);
1507
+ if (existing) {
1508
+ const prev = existing.qty;
1509
+ existing.qty = prev + addAbs;
1510
+ existing.entryPrice = (existing.entryPrice * prev + px * addAbs) / existing.qty;
1511
+ existing.commission += fee;
1512
+ return;
1513
+ }
1514
+ const trade: Trade = {
1515
+ id,
1516
+ direction: dir,
1517
+ qty: addAbs,
1518
+ entryBar: bar,
1519
+ entryPrice: px,
1520
+ commission: fee,
1521
+ };
1522
+ if (entryTime !== undefined) trade.entryTime = entryTime;
1523
+ if (comment !== undefined) {
1524
+ trade.comment = comment;
1525
+ trade.entry_comment = comment;
1526
+ }
1527
+ this.openTrades.push(trade);
856
1528
  }
857
1529
 
858
1530
  /**
859
1531
  * Flatten the open position at `price`. Returns false when there is nothing
860
1532
  * to close or the fill cannot be recorded without corrupting state.
861
1533
  */
862
- private flattenAt(bar: number, id: string, price: number, exitTime?: number): boolean {
1534
+ private flattenAt(bar: number, id: string, price: number, exitTime?: number, exitComment?: string): boolean {
863
1535
  const q = this.position.qty;
864
1536
  if (q === 0 || !isFiniteNumber(q) || !isFiniteNumber(price)) return false;
865
1537
  const side = q > 0 ? "sell" : "buy";
@@ -871,10 +1543,11 @@ export class StrategyBook {
871
1543
  const pnl = q * (px - basis);
872
1544
  if (isFiniteNumber(pnl)) this.realizedPnl += pnl;
873
1545
  const t = isFiniteNumber(exitTime) ? exitTime : bar;
874
- this.closeOpenTrades(bar, id, px, absQ, q > 0 ? "long" : "short", basis, t);
1546
+ this.closeOpenTrades(bar, id, px, absQ, q > 0 ? "long" : "short", basis, t, exitComment);
875
1547
  this.position.qty = 0;
876
1548
  this.position.avgPrice = null;
877
1549
  this.sameDirAdds = 0;
1550
+ this.position_entry_name = "";
878
1551
  this.closedCount += 1;
879
1552
  this.noteFilledOrder(t);
880
1553
  this.updateEquityExtremes(px);
@@ -900,6 +1573,7 @@ export class StrategyBook {
900
1573
  direction: StrategyDirection,
901
1574
  basis: number,
902
1575
  exitTime?: number,
1576
+ exitComment?: string,
903
1577
  ): void {
904
1578
  if (this.openTrades.length === 0 && absQ > 0) {
905
1579
  this.openTrades.push({
@@ -917,26 +1591,14 @@ export class StrategyBook {
917
1591
  const share = totalQty > 0 ? ot.qty / totalQty : 1;
918
1592
  const exitComm = exitFeeTotal * share;
919
1593
  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);
1594
+ this.closedTrades.push(
1595
+ this.closeLeg(ot, bar, exitPrice, ot.qty, profit, ot.commission + exitComm, exitTime, exitComment),
1596
+ );
936
1597
  this.noteClosedProfit(profit);
937
1598
  this.noteClosedTradeDay(isFiniteNumber(exitTime) ? exitTime : bar, profit);
938
1599
  }
939
1600
  this.openTrades.length = 0;
1601
+ this.position_entry_name = "";
940
1602
  }
941
1603
 
942
1604
  private noteClosedProfit(profit: number): void {