@hoox-sh/pynets 0.5.0 → 0.7.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.
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Pine `str.*` helpers. `null` is na.
6
6
  */
7
+ import { timestampToMs } from "./time.ts";
7
8
 
8
9
  function asStr(x: unknown): string | null {
9
10
  if (x == null) return null;
@@ -303,8 +304,8 @@ function coerceFormatTimeMs(time: unknown): number | null {
303
304
  if (raw == null || typeof raw === "boolean" || typeof raw === "string") return null;
304
305
  if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
305
306
  let ts = Math.trunc(raw);
306
- // Python: 0 < timestamp < 10_000_000_000 → Unix seconds, coerce to ms.
307
- if (ts > 0 && ts < 10_000_000_000) ts *= 1000;
307
+ // Python `utility._timestamp_to_ms`: Unix seconds (< 1e11) become ms.
308
+ ts = timestampToMs(ts) ?? ts;
308
309
  return ts;
309
310
  }
310
311
 
@@ -30,7 +30,7 @@ export interface BrokerSettings {
30
30
  }
31
31
 
32
32
  export interface StrategyEvent {
33
- type: "entry" | "close" | "exit" | "cancel" | "close_all" | "cancel_all" | "fill";
33
+ type: "entry" | "close" | "exit" | "cancel" | "close_all" | "cancel_all" | "fill" | "order";
34
34
  id: string;
35
35
  direction?: StrategyDirection;
36
36
  qty?: number | null;
@@ -63,11 +63,16 @@ export interface PendingOrder {
63
63
  oca_name?: string | null;
64
64
  oca_type?: OcaType;
65
65
  /** Unset on entry orders so existing pending snapshots stay exact. */
66
- kind?: "entry" | "exit";
66
+ kind?: "entry" | "exit" | "close" | "close_all";
67
67
  from_entry?: string | null;
68
68
  trail_offset?: number | null;
69
69
  trail_activation?: number | null;
70
70
  trail_active?: boolean;
71
+ /** Market order queued for the next bar open (PYNE 0.6.29 `placed_bar` gate). */
72
+ deferred?: boolean;
73
+ /** `strategy.entry` target-position semantics at fill time. */
74
+ is_entry?: boolean;
75
+ comment?: string;
71
76
  }
72
77
 
73
78
  export interface PlaceEntryOpts {
@@ -79,6 +84,8 @@ export interface PlaceEntryOpts {
79
84
  time?: number;
80
85
  oca_name?: string | null;
81
86
  oca_type?: string | null;
87
+ /** Queue a market order for the next bar open instead of filling now. */
88
+ deferMarket?: boolean;
82
89
  }
83
90
 
84
91
  /** Named `strategy.exit` args (Pine: id, from_entry, qty, qty_percent, …). */
@@ -99,6 +106,8 @@ export interface PlaceExitOpts {
99
106
  time?: number;
100
107
  /** Current bar OHLC so a newly placed exit can fill same bar. */
101
108
  ohlc?: BarOhlc;
109
+ /** Queue a level-less market exit for the next bar open. */
110
+ deferMarket?: boolean;
102
111
  }
103
112
 
104
113
  /** One open or closed strategy trade (qty is absolute). */
@@ -140,6 +149,8 @@ export interface BarOhlc {
140
149
  high?: number;
141
150
  low?: number;
142
151
  close?: number;
152
+ /** Bar timestamp used for trade `entry_time` / `exit_time`. */
153
+ time?: number;
143
154
  }
144
155
 
145
156
  /** Snapshot of book-level performance scalars (for RuntimeResult). */
@@ -905,6 +916,64 @@ export class StrategyBook {
905
916
  });
906
917
  }
907
918
 
919
+ /**
920
+ * `strategy.close` — queue a market cover for the next bar open and emit
921
+ * the placement `close` event (PYNE 0.6.29). A flat book only emits.
922
+ */
923
+ placeClose(
924
+ bar: number,
925
+ id: string,
926
+ price?: number,
927
+ opts?: { qty?: number | null; time?: number; comment?: string },
928
+ ): void {
929
+ const b = isFiniteNumber(bar) ? bar : 0;
930
+ const oid = String(id ?? "");
931
+ this.ensureSanePosition();
932
+ if (this.position.qty !== 0) {
933
+ const absPos = Math.abs(this.position.qty);
934
+ const rawQty = opts?.qty;
935
+ const q =
936
+ isFiniteNumber(rawQty) && rawQty >= 0 ? Math.min(Math.abs(rawQty), absPos) : absPos;
937
+ if (q > 0) {
938
+ this.upsertPending({
939
+ id: `${oid}:close`,
940
+ direction: this.position.qty > 0 ? "short" : "long",
941
+ qty: q,
942
+ limit: null,
943
+ stop: null,
944
+ bar: b,
945
+ kind: "close",
946
+ deferred: true,
947
+ comment: opts?.comment,
948
+ });
949
+ }
950
+ }
951
+ this.close(b, oid);
952
+ }
953
+
954
+ /**
955
+ * `strategy.close_all` — queue a market flatten for the next bar open and
956
+ * emit the placement `close_all` event (PYNE 0.6.29).
957
+ */
958
+ placeCloseAll(bar: number, price?: number, opts?: { comment?: string }): void {
959
+ const b = isFiniteNumber(bar) ? bar : 0;
960
+ this.ensureSanePosition();
961
+ if (this.position.qty !== 0) {
962
+ this.upsertPending({
963
+ id: "close_all",
964
+ direction: this.position.qty > 0 ? "short" : "long",
965
+ qty: Math.abs(this.position.qty),
966
+ limit: null,
967
+ stop: null,
968
+ bar: b,
969
+ kind: "close_all",
970
+ deferred: true,
971
+ comment: opts?.comment,
972
+ });
973
+ }
974
+ this.events.push({ type: "close_all", id: "", bar: b });
975
+ }
976
+
908
977
  exit(bar: number, id: string, qty?: number | null): void {
909
978
  const event: StrategyEvent = {
910
979
  type: "exit",
@@ -949,8 +1018,9 @@ export class StrategyBook {
949
1018
 
950
1019
  /**
951
1020
  * Place an entry. No limit/stop → market via fillEntry (needs `opts.price`).
952
- * Otherwise records a pending order. Always emits an `entry` event
953
- * (market path emits it through fillEntry).
1021
+ * With `deferMarket`, a market entry is queued and fills at the next bar
1022
+ * open (PYNE 0.6.29 reference-Pine flow). Otherwise records a pending
1023
+ * limit/stop order. Always emits a placement event.
954
1024
  */
955
1025
  placeEntry(
956
1026
  bar: number,
@@ -961,10 +1031,44 @@ export class StrategyBook {
961
1031
  ): void {
962
1032
  const limit = optLevel(opts?.limit);
963
1033
  const stop = optLevel(opts?.stop);
1034
+ const b = isFiniteNumber(bar) ? bar : 0;
1035
+ const oid = String(id ?? "");
964
1036
  if (limit == null && stop == null) {
1037
+ if (opts?.deferMarket === true) {
1038
+ const dir = mapDirection(direction);
1039
+ const absQty = isFiniteNumber(qty) ? Math.abs(qty) : Number.NaN;
1040
+ if (!isFiniteNumber(absQty) || absQty <= 0) return;
1041
+ const t = isFiniteNumber(opts?.time) ? opts.time : b;
1042
+ if (!this.riskAllowsEntry(dir, t)) return;
1043
+ const mark = isFiniteNumber(opts?.price) ? opts.price : 0;
1044
+ const capped = this.capQtyByMaxPosition(absQty, mark);
1045
+ if (!isFiniteNumber(capped) || capped <= 0) return;
1046
+ // Same-direction add gate (PYNE pyramiding cap = pyramiding + 1).
1047
+ const signed = dir === "long" ? capped : -capped;
1048
+ if (this.position.qty !== 0 && Math.sign(this.position.qty) === Math.sign(signed)) {
1049
+ const maxEntries = this.pyramiding !== undefined ? this.pyramiding + 1 : 1;
1050
+ const allowed =
1051
+ this.pyramiding !== undefined && this.pyramiding > 0 && this.opentrades < maxEntries;
1052
+ if (!allowed) return;
1053
+ }
1054
+ this.upsertPending({
1055
+ id: oid,
1056
+ direction: dir,
1057
+ qty: capped,
1058
+ limit: null,
1059
+ stop: null,
1060
+ bar: b,
1061
+ kind: "entry",
1062
+ is_entry: true,
1063
+ deferred: true,
1064
+ comment: opts?.comment,
1065
+ });
1066
+ this.events.push({ type: "order", id: oid, direction: dir, qty: capped, bar: b });
1067
+ return;
1068
+ }
965
1069
  if (isFiniteNumber(opts?.price)) {
966
1070
  const dir = mapDirection(direction);
967
- const t = isFiniteNumber(opts?.time) ? opts.time : bar;
1071
+ const t = isFiniteNumber(opts?.time) ? opts.time : b;
968
1072
  this.updateEquityExtremes(opts.price);
969
1073
  if (!this.riskAllowsEntry(dir, t)) return;
970
1074
  this.fillEntry(bar, id, direction, qty, opts.price, opts);
@@ -973,8 +1077,6 @@ export class StrategyBook {
973
1077
  }
974
1078
  const dir = mapDirection(direction);
975
1079
  const absQty = isFiniteNumber(qty) ? Math.abs(qty) : Number.NaN;
976
- const b = isFiniteNumber(bar) ? bar : 0;
977
- const oid = String(id ?? "");
978
1080
  this.entry(b, oid, dir, isFiniteNumber(absQty) ? absQty : qty);
979
1081
  if (!isFiniteNumber(absQty) || absQty === 0) return;
980
1082
  const pending: PendingOrder = { id: oid, direction: dir, qty: absQty, limit, stop, bar: b };
@@ -1043,6 +1145,23 @@ export class StrategyBook {
1043
1145
 
1044
1146
  const t = isFiniteNumber(opts?.time) ? opts.time : b;
1045
1147
  if (limitP == null && stopP == null && !hasTrail) {
1148
+ if (opts?.deferMarket === true) {
1149
+ const closeDir: StrategyDirection = isLong ? "short" : "long";
1150
+ this.upsertPending({
1151
+ id: `${oid}:close`,
1152
+ direction: closeDir,
1153
+ qty,
1154
+ limit: null,
1155
+ stop: null,
1156
+ bar: b,
1157
+ kind: "exit",
1158
+ from_entry: fromEntry,
1159
+ deferred: true,
1160
+ comment: opts?.comment,
1161
+ });
1162
+ this.events.push({ type: "order", id: oid, direction: closeDir, qty, bar: b });
1163
+ return;
1164
+ }
1046
1165
  if (!isFiniteNumber(opts?.price)) return;
1047
1166
  this.reducePosition(b, oid, opts.price, qty, fromEntry, t, opts?.comment);
1048
1167
  return;
@@ -1132,14 +1251,26 @@ export class StrategyBook {
1132
1251
  const filled: string[] = [];
1133
1252
  for (const order of this.pending.slice()) {
1134
1253
  if (!this.pending.some((p) => p.id === order.id)) continue;
1254
+ // Reference Pine: an order placed during bar N becomes active for bar
1255
+ // N+1 — never fills on its own placement bar.
1256
+ if (order.bar === b) continue;
1135
1257
  if (order.kind === "exit" && isTrailOrder(order)) updateTrailStop(order, high, low);
1136
- const px = triggerPrice(order, open, high, low);
1258
+ let px: number | null;
1259
+ if (order.limit == null && order.stop == null) px = open;
1260
+ else px = triggerPrice(order, open, high, low);
1137
1261
  if (px == null) continue;
1138
1262
  this.dropPending(order.id);
1263
+ const emitEntry = order.kind === "entry" || order.deferred === true;
1264
+ const barTime = isFiniteNumber(ohlc.time) ? ohlc.time : undefined;
1139
1265
  const applied =
1140
1266
  order.kind === "exit"
1141
- ? this.applyExitFill(b, order, px)
1142
- : this.applyEntryFill(b, order.id, order.direction, order.qty, px, false);
1267
+ ? this.applyExitFill(b, order, px, barTime)
1268
+ : order.kind === "close" || order.kind === "close_all"
1269
+ ? this.applyCloseFill(b, order, px, barTime)
1270
+ : this.applyEntryFill(b, order.id, order.direction, order.qty, px, emitEntry, {
1271
+ comment: order.comment,
1272
+ time: barTime,
1273
+ });
1143
1274
  if (applied) {
1144
1275
  const q = Math.abs(this.fills.at(-1)?.qty ?? order.qty);
1145
1276
  this.ocaAfterFill(order, q, b);
@@ -1156,6 +1287,27 @@ export class StrategyBook {
1156
1287
  return filled;
1157
1288
  }
1158
1289
 
1290
+ /** Apply a queued market close/close_all at the next bar open. */
1291
+ private applyCloseFill(
1292
+ bar: number,
1293
+ order: PendingOrder,
1294
+ price: number,
1295
+ time?: number,
1296
+ ): boolean {
1297
+ this.ensureSanePosition();
1298
+ if (this.position.qty === 0 || !isFiniteNumber(price)) return false;
1299
+ const absPos = Math.abs(this.position.qty);
1300
+ const rawQty = order.qty;
1301
+ const q = isFiniteNumber(rawQty) && rawQty > 0 ? Math.min(Math.abs(rawQty), absPos) : absPos;
1302
+ if (!(q > 0)) return false;
1303
+ if (q < absPos - 1e-12) {
1304
+ this.reducePosition(bar, order.id, price, q, order.from_entry ?? null, time, order.comment);
1305
+ } else {
1306
+ this.flattenAt(bar, order.id, price, time, order.comment);
1307
+ }
1308
+ return true;
1309
+ }
1310
+
1159
1311
  /** Market entry at `price`. Flat/same-dir adds qty; opposite closes then reverses. */
1160
1312
  fillEntry(
1161
1313
  bar: number,
@@ -1302,9 +1454,9 @@ export class StrategyBook {
1302
1454
  return { status: "ok", qty: Math.min(rawQty, target) };
1303
1455
  }
1304
1456
 
1305
- private applyExitFill(bar: number, order: PendingOrder, price: number): boolean {
1457
+ private applyExitFill(bar: number, order: PendingOrder, price: number, time?: number): boolean {
1306
1458
  const t = isFiniteNumber(bar) ? bar : 0;
1307
- return this.reducePosition(t, order.id, price, order.qty, order.from_entry ?? null, t);
1459
+ return this.reducePosition(t, order.id, price, order.qty, order.from_entry ?? null, time);
1308
1460
  }
1309
1461
 
1310
1462
  private closeLeg(