@hoox-sh/pynets 0.1.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.
- package/CHANGELOG.md +59 -0
- package/README.md +20 -9
- package/dist/browser.js +95 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +23002 -12427
- package/package.json +1 -1
- package/src/ast/builder.ts +235 -12
- package/src/ast/nodes.ts +114 -4
- package/src/ast/unparse.ts +192 -17
- package/src/cli/rich.ts +4 -3
- package/src/cli.ts +29 -7
- package/src/generated/PinescriptLexer.interp +5 -1
- package/src/generated/PinescriptLexer.tokens +97 -95
- package/src/generated/PinescriptLexer.ts +512 -502
- package/src/generated/PinescriptParser.interp +8 -1
- package/src/generated/PinescriptParser.tokens +97 -95
- package/src/generated/PinescriptParser.ts +1907 -1287
- package/src/generated/PinescriptParserVisitor.ts +36 -1
- package/src/index.ts +22 -0
- package/src/runtime/array.ts +238 -68
- package/src/runtime/compile/collections.ts +492 -0
- package/src/runtime/compile/draw.ts +181 -0
- package/src/runtime/compile/emit.ts +1421 -0
- package/src/runtime/compile/emit_call.ts +1796 -0
- package/src/runtime/compile/emit_udf.ts +326 -0
- package/src/runtime/compile/engine.ts +265 -0
- package/src/runtime/compile/helpers.ts +140 -0
- package/src/runtime/compile/index.ts +33 -0
- package/src/runtime/compile/runtime.ts +357 -0
- package/src/runtime/compile/strategy.ts +250 -0
- package/src/runtime/compile/types.ts +127 -0
- package/src/runtime/compile/udt_runtime.ts +50 -0
- package/src/runtime/drawings.ts +405 -8
- package/src/runtime/interpret.ts +2387 -281
- package/src/runtime/matrix.ts +371 -42
- package/src/runtime/strategy.ts +695 -33
- package/src/runtime/ta.ts +654 -20
- package/src/runtime/timeframe.ts +199 -0
package/src/runtime/strategy.ts
CHANGED
|
@@ -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
|
|
7
|
-
* update a
|
|
8
|
-
* per bar BEFORE the script body
|
|
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
|
-
|
|
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 =
|
|
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
|
|
735
|
-
fillClose(
|
|
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
|
-
|
|
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)
|
|
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
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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 {
|