@hoox-sh/pynets 0.4.0 → 0.6.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.
@@ -9,8 +9,11 @@
9
9
  * is the host's job (`bucketIndex` / `shouldForwardFill`).
10
10
  * Lower-TF request without a feed is na — no invented intra-bar array.
11
11
  * Interpret should call this from `evalCall`; this module is host-agnostic.
12
+ * `request.footprint` is the Python mock (not foreign-na).
12
13
  */
13
14
 
15
+ import { createFootprint, seedFootprintRng } from "./footprint.ts";
16
+
14
17
  export type Cell = number | null;
15
18
 
16
19
  export interface SecurityArgs {
@@ -29,7 +32,7 @@ export interface RequestHost {
29
32
  * Named or positional args for `resolveRequest`.
30
33
  * Security uses `symbol` / `timeframe` / `sameSymbolValue`;
31
34
  * currency uses `from`/`to` (or `from_currency`/`to_currency`);
32
- * seed uses `seed`.
35
+ * seed uses `seed`; footprint uses `num_ticks` / `va_percentage`.
33
36
  */
34
37
  export interface RequestNamedArgs {
35
38
  symbol?: string | null;
@@ -41,6 +44,8 @@ export interface RequestNamedArgs {
41
44
  from_currency?: string | null;
42
45
  to_currency?: string | null;
43
46
  seed?: unknown;
47
+ num_ticks?: unknown;
48
+ va_percentage?: unknown;
44
49
  }
45
50
 
46
51
  export type RequestArgs = readonly unknown[] | RequestNamedArgs;
@@ -231,7 +236,7 @@ function bareTicker(symbol: string): string {
231
236
  return colon >= 0 ? s.slice(colon + 1) : s;
232
237
  }
233
238
 
234
- /** Missing / foreign fundamentals, economic, quandl, footprint → na. */
239
+ /** Missing / foreign fundamentals, economic, quandl → na. */
235
240
  export function resolveForeignNa(): Cell {
236
241
  return null;
237
242
  }
@@ -249,9 +254,10 @@ export function resolveCurrencyRate(
249
254
  return null;
250
255
  }
251
256
 
252
- /** `request.seed` — record the seed (Python returns None). */
257
+ /** `request.seed` — record the seed and seed the footprint RNG (Python returns None). */
253
258
  export function resolveSeed(seed?: unknown): Cell {
254
259
  lastSeed = coerceSeed(seed ?? 0);
260
+ seedFootprintRng(lastSeed);
255
261
  return null;
256
262
  }
257
263
 
@@ -262,7 +268,7 @@ export function lastRequestSeed(): number | null {
262
268
  /**
263
269
  * Dispatch a `request.*` name. Security keeps `resolveSecurity` semantics;
264
270
  * lower-TF without a feed is na; currency is 1.0 only for identical codes;
265
- * everything else is na.
271
+ * footprint is the Python mock; everything else is na.
266
272
  */
267
273
  export function resolveRequest(
268
274
  fname: string,
@@ -284,6 +290,10 @@ export function resolveRequest(
284
290
  if (fname === "request.seed") {
285
291
  return resolveSeed(seedFromArgs(args));
286
292
  }
293
+ if (fname === "request.footprint") {
294
+ const [numTicks, va] = footprintFromArgs(args);
295
+ return createFootprint(numTicks, va);
296
+ }
287
297
  return resolveForeignNa();
288
298
  }
289
299
 
@@ -326,6 +336,11 @@ function seedFromArgs(args: RequestArgs): unknown {
326
336
  return args.seed ?? 0;
327
337
  }
328
338
 
339
+ function footprintFromArgs(args: RequestArgs): [unknown, unknown] {
340
+ if (!isNamedArgs(args)) return [args[0] ?? 100, args[1] ?? 70];
341
+ return [args.num_ticks ?? 100, args.va_percentage ?? 70];
342
+ }
343
+
329
344
  function currencyPair(
330
345
  from: string | null | undefined,
331
346
  to: string | null | undefined,
@@ -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(