@hoox-sh/pynets 0.4.0 → 0.5.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.
@@ -46,6 +46,8 @@ const NS = new Set([
46
46
  "syminfo",
47
47
  "timenow",
48
48
  "chart",
49
+ "footprint",
50
+ "volume_row",
49
51
  ]);
50
52
 
51
53
  /** TaEngine methods we emit. Unknown `ta.*` → `null`. */
@@ -453,6 +455,8 @@ export function emitCall(ctx: EmitCtx, node: Call, visit: VisitFn): string {
453
455
 
454
456
  const req = emitRequest(ctx, name, args);
455
457
  if (req != null) return req;
458
+ const fp = emitFootprint(name, args);
459
+ if (fp != null) return fp;
456
460
 
457
461
  return "null";
458
462
  }
@@ -1018,7 +1022,6 @@ const SECURITY_FUNCS = new Set([
1018
1022
  "security_lower_tf",
1019
1023
  "request_security",
1020
1024
  "request_security_lower_tf",
1021
- "request_seed",
1022
1025
  ]);
1023
1026
 
1024
1027
  const SIMPLE_CHART_EXPR = new Set([
@@ -1064,10 +1067,60 @@ function emitRequest(ctx: EmitCtx, name: string, args: CallArgs): string | null
1064
1067
  if (name === "request_currency_rate" || name === "currency_rate") {
1065
1068
  return `__h.currencyRate(${pick(args, 0, ["from", "from_currency"], "null")}, ${pick(args, 1, ["to", "to_currency"], "null")})`;
1066
1069
  }
1070
+ if (name === "request_seed") {
1071
+ return `__h.requestSeed(${pick(args, 0, ["seed"], "0")})`;
1072
+ }
1073
+ if (name === "request_footprint") {
1074
+ return `__h.requestFootprint(${pick(args, 0, ["num_ticks"], "100")}, ${pick(args, 1, ["va_percentage"], "70")})`;
1075
+ }
1067
1076
  if (name.startsWith("request_")) return "null";
1068
1077
  return null;
1069
1078
  }
1070
1079
 
1080
+ /** Python `request.footprint` mock + `footprint.*` / `volume_row.*` accessors. */
1081
+ function emitFootprint(name: string, args: CallArgs): string | null {
1082
+ const fp = pick(args, 0, ["id", "footprint"], "null");
1083
+ const vr = pick(args, 0, ["id", "volume_row"], "null");
1084
+ switch (name) {
1085
+ case "footprint_buy_volume":
1086
+ return `__h.footprint.buy_volume(${fp})`;
1087
+ case "footprint_sell_volume":
1088
+ return `__h.footprint.sell_volume(${fp})`;
1089
+ case "footprint_delta":
1090
+ return `__h.footprint.delta(${fp})`;
1091
+ case "footprint_total_volume":
1092
+ return `__h.footprint.total_volume(${fp})`;
1093
+ case "footprint_vah":
1094
+ return `__h.footprint.vah(${fp})`;
1095
+ case "footprint_val":
1096
+ return `__h.footprint.val(${fp})`;
1097
+ case "footprint_poc":
1098
+ return `__h.footprint.poc(${fp})`;
1099
+ case "footprint_rows":
1100
+ return `__h.footprint.rows(${fp})`;
1101
+ case "footprint_get_row_by_price":
1102
+ return `__h.footprint.get_row_by_price(${fp}, ${pick(args, 1, ["price"], "null")})`;
1103
+ case "volume_row_up_price":
1104
+ return `__h.footprint.volume_row.up_price(${vr})`;
1105
+ case "volume_row_down_price":
1106
+ return `__h.footprint.volume_row.down_price(${vr})`;
1107
+ case "volume_row_buy_volume":
1108
+ return `__h.footprint.volume_row.buy_volume(${vr})`;
1109
+ case "volume_row_sell_volume":
1110
+ return `__h.footprint.volume_row.sell_volume(${vr})`;
1111
+ case "volume_row_delta":
1112
+ return `__h.footprint.volume_row.delta(${vr})`;
1113
+ case "volume_row_total_volume":
1114
+ return `__h.footprint.volume_row.total_volume(${vr})`;
1115
+ case "volume_row_has_buy_imbalance":
1116
+ return `__h.footprint.volume_row.has_buy_imbalance(${vr})`;
1117
+ case "volume_row_has_sell_imbalance":
1118
+ return `__h.footprint.volume_row.has_sell_imbalance(${vr})`;
1119
+ default:
1120
+ return null;
1121
+ }
1122
+ }
1123
+
1071
1124
  function isSimpleSecurityExpr(expr: string): boolean {
1072
1125
  const e = expr.trim();
1073
1126
  if (!e) return false;
@@ -56,7 +56,28 @@ import {
56
56
  strUpper,
57
57
  } from "../str.ts";
58
58
  import { LogBook, formatLogParts } from "../log.ts";
59
- import { resolveCurrencyRate } from "../request.ts";
59
+ import { PineArray } from "../array.ts";
60
+ import {
61
+ createFootprint,
62
+ footprintBuyVolume,
63
+ footprintDelta,
64
+ footprintGetRowByPrice,
65
+ footprintPoc,
66
+ footprintRows,
67
+ footprintSellVolume,
68
+ footprintTotalVolume,
69
+ footprintVah,
70
+ footprintVal,
71
+ volumeRowBuyVolume,
72
+ volumeRowDelta,
73
+ volumeRowDownPrice,
74
+ volumeRowHasBuyImbalance,
75
+ volumeRowHasSellImbalance,
76
+ volumeRowSellVolume,
77
+ volumeRowTotalVolume,
78
+ volumeRowUpPrice,
79
+ } from "../footprint.ts";
80
+ import { resolveCurrencyRate, resolveSeed } from "../request.ts";
60
81
  import { TaEngine } from "../ta.ts";
61
82
  import { utcPartsFromMs, timestamp, weekOfYear, timeTradingDay, type UtcParts } from "../time.ts";
62
83
  import { TickerId } from "../ticker.ts";
@@ -227,12 +248,41 @@ export function createCompileHelpers(opts?: CompileHelperOpts) {
227
248
  udtSet,
228
249
  udtRegister,
229
250
  currencyRate: resolveCurrencyRate,
251
+ requestSeed: resolveSeed,
252
+ requestFootprint: createFootprint,
253
+ footprint: {
254
+ buy_volume: footprintBuyVolume,
255
+ sell_volume: footprintSellVolume,
256
+ delta: footprintDelta,
257
+ total_volume: footprintTotalVolume,
258
+ vah: footprintVah,
259
+ val: footprintVal,
260
+ poc: footprintPoc,
261
+ rows: compileFootprintRows,
262
+ get_row_by_price: footprintGetRowByPrice,
263
+ volume_row: {
264
+ up_price: volumeRowUpPrice,
265
+ down_price: volumeRowDownPrice,
266
+ buy_volume: volumeRowBuyVolume,
267
+ sell_volume: volumeRowSellVolume,
268
+ delta: volumeRowDelta,
269
+ total_volume: volumeRowTotalVolume,
270
+ has_buy_imbalance: volumeRowHasBuyImbalance,
271
+ has_sell_imbalance: volumeRowHasSellImbalance,
272
+ },
273
+ },
230
274
  runtimeError(msg: unknown): never {
231
275
  throw new Error(String(msg));
232
276
  },
233
277
  };
234
278
  }
235
279
 
280
+ function compileFootprintRows(fp: unknown): PineArray {
281
+ const arr = new PineArray();
282
+ for (const row of footprintRows(fp)) arr.push(row);
283
+ return arr;
284
+ }
285
+
236
286
  /** Pine `color.new(color, transp)` — first arg is a color, not r,g,b. */
237
287
  function compileColorNew(color: unknown, transp?: unknown) {
238
288
  const parsed = asColor(color);
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Copyright (C) 2024-2026 jango_blockchained
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ *
5
+ * Pine v6 `request.footprint` mock + `footprint.*` / `volume_row.*` accessors.
6
+ * Python `VolumeRow` / `Footprint` / `_handle_request_footprint` (request.py).
7
+ * Seeded mocks use CPython `random.Random` MT19937 so `request.seed` matches.
8
+ */
9
+
10
+ /** Volume row in a footprint (single price level). */
11
+ export class VolumeRow {
12
+ constructor(
13
+ public up_price = 0,
14
+ public down_price = 0,
15
+ public buy_volume = 0,
16
+ public sell_volume = 0,
17
+ public delta = 0,
18
+ public is_imbalance = false,
19
+ public is_poc = false,
20
+ public is_vah = false,
21
+ public is_val = false,
22
+ ) {}
23
+ }
24
+
25
+ /** Footprint object: volume profile for one bar. */
26
+ export class Footprint {
27
+ constructor(
28
+ public num_ticks = 100,
29
+ public va_percentage = 70,
30
+ public buy_volume = 0,
31
+ public sell_volume = 0,
32
+ public delta = 0,
33
+ public total_volume = 0,
34
+ public vah_row: VolumeRow | null = null,
35
+ public val_row: VolumeRow | null = null,
36
+ public poc_row: VolumeRow | null = null,
37
+ public rows: VolumeRow[] = [],
38
+ ) {}
39
+ }
40
+
41
+ const N = 624;
42
+ const M = 397;
43
+ const MATRIX_A = 0x9908b0df;
44
+ const UPPER_MASK = 0x80000000;
45
+ const LOWER_MASK = 0x7fffffff;
46
+
47
+ /** CPython 3 `random.Random` (MT19937) for integer `random.seed`. */
48
+ class PyRandom {
49
+ private readonly mt = new Uint32Array(N);
50
+ private index = N + 1;
51
+
52
+ seed(a: number): void {
53
+ const n = Math.trunc(a);
54
+ const abs = n < 0 ? -n : n;
55
+ const key: number[] = [];
56
+ if (abs === 0) key.push(0);
57
+ else {
58
+ let x = abs;
59
+ while (x > 0) {
60
+ key.push(x >>> 0);
61
+ x = Math.floor(x / 0x100000000);
62
+ }
63
+ }
64
+ this.initByArray(key);
65
+ }
66
+
67
+ random(): number {
68
+ const a = this.genrand() >>> 5;
69
+ const b = this.genrand() >>> 6;
70
+ return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0);
71
+ }
72
+
73
+ private initGenrand(s: number): void {
74
+ const mt = this.mt;
75
+ mt[0] = s >>> 0;
76
+ for (let mti = 1; mti < N; mti++) {
77
+ mt[mti] = (Math.imul(1812433253, mt[mti - 1]! ^ (mt[mti - 1]! >>> 30)) + mti) >>> 0;
78
+ }
79
+ this.index = N;
80
+ }
81
+
82
+ private initByArray(initKey: number[]): void {
83
+ const mt = this.mt;
84
+ this.initGenrand(19650218);
85
+ let i = 1;
86
+ let j = 0;
87
+ const keyLength = initKey.length;
88
+ let k = N > keyLength ? N : keyLength;
89
+ for (; k > 0; k--) {
90
+ mt[i] =
91
+ ((mt[i]! ^ Math.imul(mt[i - 1]! ^ (mt[i - 1]! >>> 30), 1664525)) + initKey[j]! + j) >>> 0;
92
+ i++;
93
+ j++;
94
+ if (i >= N) {
95
+ mt[0] = mt[N - 1]!;
96
+ i = 1;
97
+ }
98
+ if (j >= keyLength) j = 0;
99
+ }
100
+ for (k = N - 1; k > 0; k--) {
101
+ mt[i] = ((mt[i]! ^ Math.imul(mt[i - 1]! ^ (mt[i - 1]! >>> 30), 1566083941)) - i) >>> 0;
102
+ i++;
103
+ if (i >= N) {
104
+ mt[0] = mt[N - 1]!;
105
+ i = 1;
106
+ }
107
+ }
108
+ mt[0] = 0x80000000;
109
+ this.index = N;
110
+ }
111
+
112
+ private genrand(): number {
113
+ const mt = this.mt;
114
+ if (this.index >= N) {
115
+ for (let kk = 0; kk < N - M; kk++) {
116
+ const y = (mt[kk]! & UPPER_MASK) | (mt[kk + 1]! & LOWER_MASK);
117
+ mt[kk] = (mt[kk + M]! ^ (y >>> 1) ^ (y & 1 ? MATRIX_A : 0)) >>> 0;
118
+ }
119
+ for (let kk = N - M; kk < N - 1; kk++) {
120
+ const y = (mt[kk]! & UPPER_MASK) | (mt[kk + 1]! & LOWER_MASK);
121
+ mt[kk] = (mt[kk + (M - N)]! ^ (y >>> 1) ^ (y & 1 ? MATRIX_A : 0)) >>> 0;
122
+ }
123
+ const y = (mt[N - 1]! & UPPER_MASK) | (mt[0]! & LOWER_MASK);
124
+ mt[N - 1] = (mt[M - 1]! ^ (y >>> 1) ^ (y & 1 ? MATRIX_A : 0)) >>> 0;
125
+ this.index = 0;
126
+ }
127
+ let y = mt[this.index++]!;
128
+ y ^= y >>> 11;
129
+ y ^= (y << 7) & 0x9d2c5680;
130
+ y ^= (y << 15) & 0xefc60000;
131
+ y ^= y >>> 18;
132
+ return y >>> 0;
133
+ }
134
+ }
135
+
136
+ let pyRng: PyRandom | null = null;
137
+
138
+ /** `request.seed` — CPython `random.seed(seed_int)`. */
139
+ export function seedFootprintRng(seed: number): void {
140
+ pyRng = new PyRandom();
141
+ pyRng.seed(seed);
142
+ }
143
+
144
+ function nextRandom(): number {
145
+ return pyRng != null ? pyRng.random() : Math.random();
146
+ }
147
+
148
+ function asCount(value: unknown, fallback: number): number {
149
+ if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
150
+ return fallback;
151
+ }
152
+
153
+ function rowAt(rows: VolumeRow[], idx: number): VolumeRow | null {
154
+ return idx >= 0 && idx < rows.length ? rows[idx]! : null;
155
+ }
156
+
157
+ /**
158
+ * `request.footprint(num_ticks, va_percentage)` mock.
159
+ * Missing ticks / VA % use Python defaults 100 / 70. No data_feed → vol_scale 1.
160
+ */
161
+ export function createFootprint(numTicks: unknown = 100, vaPercentage: unknown = 70): Footprint {
162
+ const n = asCount(numTicks, 100);
163
+ const va = asCount(vaPercentage, 70);
164
+ const rows: VolumeRow[] = [];
165
+ const basePrice = 100.0;
166
+ const tickSize = 0.01;
167
+ for (let i = 0; i < n; i++) {
168
+ const priceLevel = basePrice + i * tickSize;
169
+ rows.push(
170
+ new VolumeRow(
171
+ priceLevel + tickSize,
172
+ priceLevel,
173
+ 1000.0 + nextRandom() * 500,
174
+ 900.0 + nextRandom() * 500,
175
+ 100.0 + (nextRandom() * 200 - 100),
176
+ nextRandom() < 0.1,
177
+ i === Math.floor(n / 2),
178
+ i === Math.trunc(n * 0.7),
179
+ i === Math.trunc(n * 0.3),
180
+ ),
181
+ );
182
+ }
183
+ const totalBuy = rows.reduce((s, r) => s + r.buy_volume, 0);
184
+ const totalSell = rows.reduce((s, r) => s + r.sell_volume, 0);
185
+ return new Footprint(
186
+ n,
187
+ va,
188
+ totalBuy,
189
+ totalSell,
190
+ totalBuy - totalSell,
191
+ totalBuy + totalSell,
192
+ rowAt(rows, Math.trunc(n * 0.7)),
193
+ rowAt(rows, Math.trunc(n * 0.3)),
194
+ rowAt(rows, Math.floor(n / 2)),
195
+ rows,
196
+ );
197
+ }
198
+
199
+ export function footprintBuyVolume(fp: unknown): number {
200
+ return fp instanceof Footprint ? fp.buy_volume : 0;
201
+ }
202
+
203
+ export function footprintSellVolume(fp: unknown): number {
204
+ return fp instanceof Footprint ? fp.sell_volume : 0;
205
+ }
206
+
207
+ export function footprintDelta(fp: unknown): number {
208
+ return fp instanceof Footprint ? fp.delta : 0;
209
+ }
210
+
211
+ export function footprintTotalVolume(fp: unknown): number {
212
+ return fp instanceof Footprint ? fp.total_volume : 0;
213
+ }
214
+
215
+ export function footprintVah(fp: unknown): VolumeRow | null {
216
+ return fp instanceof Footprint ? fp.vah_row : null;
217
+ }
218
+
219
+ export function footprintVal(fp: unknown): VolumeRow | null {
220
+ return fp instanceof Footprint ? fp.val_row : null;
221
+ }
222
+
223
+ export function footprintPoc(fp: unknown): VolumeRow | null {
224
+ return fp instanceof Footprint ? fp.poc_row : null;
225
+ }
226
+
227
+ export function footprintRows(fp: unknown): VolumeRow[] {
228
+ return fp instanceof Footprint ? fp.rows.slice() : [];
229
+ }
230
+
231
+ export function footprintGetRowByPrice(fp: unknown, price: unknown): VolumeRow | null {
232
+ if (!(fp instanceof Footprint) || price == null) return null;
233
+ if (typeof price !== "number" || !Number.isFinite(price)) return null;
234
+ for (const row of fp.rows) {
235
+ const lo = Math.min(row.down_price, row.up_price);
236
+ const hi = Math.max(row.down_price, row.up_price);
237
+ if (lo <= price && price <= hi) return row;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ export function volumeRowUpPrice(vr: unknown): number {
243
+ return vr instanceof VolumeRow ? vr.up_price : 0;
244
+ }
245
+
246
+ export function volumeRowDownPrice(vr: unknown): number {
247
+ return vr instanceof VolumeRow ? vr.down_price : 0;
248
+ }
249
+
250
+ export function volumeRowBuyVolume(vr: unknown): number {
251
+ return vr instanceof VolumeRow ? vr.buy_volume : 0;
252
+ }
253
+
254
+ export function volumeRowSellVolume(vr: unknown): number {
255
+ return vr instanceof VolumeRow ? vr.sell_volume : 0;
256
+ }
257
+
258
+ export function volumeRowDelta(vr: unknown): number {
259
+ return vr instanceof VolumeRow ? vr.delta : 0;
260
+ }
261
+
262
+ export function volumeRowTotalVolume(vr: unknown): number {
263
+ return vr instanceof VolumeRow ? vr.buy_volume + vr.sell_volume : 0;
264
+ }
265
+
266
+ export function volumeRowHasBuyImbalance(vr: unknown): boolean {
267
+ return vr instanceof VolumeRow ? Boolean(vr.is_imbalance && vr.buy_volume > vr.sell_volume) : false;
268
+ }
269
+
270
+ export function volumeRowHasSellImbalance(vr: unknown): boolean {
271
+ return vr instanceof VolumeRow ? Boolean(vr.is_imbalance && vr.sell_volume > vr.buy_volume) : false;
272
+ }
@@ -123,6 +123,27 @@ import {
123
123
  type StubKwargs,
124
124
  } from "./library.ts";
125
125
  import { isRequestBuiltin, resolveRequest, resolveSecurity } from "./request.ts";
126
+ import {
127
+ Footprint,
128
+ footprintBuyVolume,
129
+ footprintDelta,
130
+ footprintGetRowByPrice,
131
+ footprintPoc,
132
+ footprintRows,
133
+ footprintSellVolume,
134
+ footprintTotalVolume,
135
+ footprintVah,
136
+ footprintVal,
137
+ volumeRowBuyVolume,
138
+ volumeRowDelta,
139
+ volumeRowDownPrice,
140
+ volumeRowHasBuyImbalance,
141
+ volumeRowHasSellImbalance,
142
+ volumeRowSellVolume,
143
+ volumeRowTotalVolume,
144
+ volumeRowUpPrice,
145
+ type VolumeRow,
146
+ } from "./footprint.ts";
126
147
  import { NA, PineSeries, type Cell } from "./series.ts";
127
148
  import {
128
149
  strContains,
@@ -254,6 +275,8 @@ type Value =
254
275
  | PineMap
255
276
  | PineMatrix
256
277
  | TickerId
278
+ | Footprint
279
+ | VolumeRow
257
280
  | UdtType
258
281
  | UdtInstance
259
282
  | LibraryModule
@@ -337,7 +360,7 @@ interface Env {
337
360
  onceFired: WeakSet<object>;
338
361
  /** Python `_max_bars_back_decls` — recorded only; evaluation is unbounded. */
339
362
  maxBarsBackDecls: Array<{ var: Value; num: Value }>;
340
- /** Python `_alert_fire_bars` keys for `alertcondition` once_per_bar. */
363
+ /** Python `_alert_fire_bars` keys for `alert` / `alertcondition` freq gating. */
341
364
  alertFireBars: Map<string, number>;
342
365
  }
343
366
 
@@ -1510,6 +1533,22 @@ export const MATH_CONSTANTS: Record<string, Value> = {
1510
1533
  "plot.linestyle_solid": "linestyle_solid",
1511
1534
  "plot.linestyle_dashed": "linestyle_dashed",
1512
1535
  "plot.linestyle_dotted": "linestyle_dotted",
1536
+ // Python plotting.py PlotStyle tokens — longer than hline's "solid".
1537
+ "plot.style_line": "style_line",
1538
+ "plot.style_linebr": "style_linebr",
1539
+ "plot.style_stepline": "style_stepline",
1540
+ "plot.style_steplinebr": "style_steplinebr",
1541
+ "plot.style_stepline_diamond": "style_stepline_diamond",
1542
+ "plot.style_histogram": "style_histogram",
1543
+ "plot.style_columns": "style_columns",
1544
+ "plot.style_cross": "style_cross",
1545
+ "plot.style_area": "style_area",
1546
+ "plot.style_areabr": "style_areabr",
1547
+ "plot.style_circles": "style_circles",
1548
+ // Python alerts.py `_alerts_builtin_map`.
1549
+ "alert.freq_once_per_bar": "once_per_bar",
1550
+ "alert.freq_once_per_bar_close": "once_per_bar_close",
1551
+ "alert.freq_all": "all",
1513
1552
  "dayofweek.sunday": 1,
1514
1553
  "dayofweek.monday": 2,
1515
1554
  "dayofweek.tuesday": 3,
@@ -1889,6 +1928,8 @@ function evalCall(node: Call, env: Env): Value {
1889
1928
  ) {
1890
1929
  return evalRequestCall(fname!, node, env);
1891
1930
  }
1931
+ const fpCall = evalFootprintCall(fname, node, env);
1932
+ if (fpCall !== undefined) return fpCall;
1892
1933
  const arrVal = evalArrayCall(fname, node, env);
1893
1934
  if (arrVal !== undefined) return arrVal;
1894
1935
  const colorVal = evalColorCall(fname, node, env);
@@ -2577,14 +2618,66 @@ function packDrawings(book: DrawingBook): DrawingEvent[] | undefined {
2577
2618
  return book.items.length === 0 ? undefined : book.items;
2578
2619
  }
2579
2620
 
2580
- /** Python `_emit_alert(..., source="alertcondition", freq=once_per_bar)`. */
2581
- function fireAlertCondition(env: Env, title: string, message: string): void {
2582
- const key = `alertcondition|${title}|${message}|once_per_bar`;
2583
- if (env.alertFireBars.get(key) === env.barIndex) return;
2621
+ const FREQ_ONCE_PER_BAR = "once_per_bar";
2622
+ const FREQ_ONCE_PER_BAR_CLOSE = "once_per_bar_close";
2623
+ const FREQ_ALL = "all";
2624
+
2625
+ const ALERT_FREQ_ALIASES: Record<string, string> = {
2626
+ once_per_bar: FREQ_ONCE_PER_BAR,
2627
+ freq_once_per_bar: FREQ_ONCE_PER_BAR,
2628
+ "alert.freq_once_per_bar": FREQ_ONCE_PER_BAR,
2629
+ once_per_bar_close: FREQ_ONCE_PER_BAR_CLOSE,
2630
+ freq_once_per_bar_close: FREQ_ONCE_PER_BAR_CLOSE,
2631
+ "alert.freq_once_per_bar_close": FREQ_ONCE_PER_BAR_CLOSE,
2632
+ all: FREQ_ALL,
2633
+ freq_all: FREQ_ALL,
2634
+ "alert.freq_all": FREQ_ALL,
2635
+ };
2636
+
2637
+ /** Python `normalize_alert_freq`. Unknown non-empty tokens are kept (still once-per-bar gated). */
2638
+ function normalizeAlertFreq(freq: unknown): string {
2639
+ if (freq == null) return FREQ_ONCE_PER_BAR;
2640
+ if (typeof freq === "number" && !Number.isFinite(freq)) return FREQ_ONCE_PER_BAR;
2641
+ const s = String(freq).trim().toLowerCase().replace(/ /g, "_");
2642
+ return ALERT_FREQ_ALIASES[s] ?? (s ? s : FREQ_ONCE_PER_BAR);
2643
+ }
2644
+
2645
+ /** Python `_bar_is_confirmed`. This host sets `barstate.isconfirmed` to 1 on every bar. */
2646
+ function barIsConfirmed(_env: Env): boolean {
2647
+ return true;
2648
+ }
2649
+
2650
+ /** Python `_should_fire_alert`. */
2651
+ function shouldFireAlert(env: Env, source: string, title: string, message: string, freq: string): boolean {
2652
+ const freqN = normalizeAlertFreq(freq);
2653
+ if (freqN === FREQ_ALL) return true;
2654
+ if (freqN === FREQ_ONCE_PER_BAR_CLOSE && !barIsConfirmed(env)) return false;
2655
+ const key = `${source}|${title}|${message}|${freqN}`;
2656
+ if (env.alertFireBars.get(key) === env.barIndex) return false;
2584
2657
  env.alertFireBars.set(key, env.barIndex);
2658
+ return true;
2659
+ }
2660
+
2661
+ /** Python `_emit_alert`. Records on the drawing book (no separate alerts array). */
2662
+ function emitAlert(
2663
+ env: Env,
2664
+ message: string,
2665
+ freq: unknown,
2666
+ opts?: { title?: string; source?: string },
2667
+ ): void {
2668
+ const source = opts?.source ?? "alert";
2669
+ const title = opts?.title ?? "";
2670
+ const freqN = normalizeAlertFreq(freq);
2671
+ if (!shouldFireAlert(env, source, title, message, freqN)) return;
2585
2672
  env.drawings.alert(env.barIndex, message);
2586
2673
  const ev = env.drawings.items[env.drawings.items.length - 1];
2587
- if (ev != null) ev.extra = { source: "alertcondition", title, freq: "once_per_bar" };
2674
+ if (ev == null) return;
2675
+ ev.extra = title ? { source, title, freq: freqN } : { source, freq: freqN };
2676
+ }
2677
+
2678
+ /** Python `_emit_alert(..., source="alertcondition", freq=once_per_bar)`. */
2679
+ function fireAlertCondition(env: Env, title: string, message: string): void {
2680
+ emitAlert(env, message, FREQ_ONCE_PER_BAR, { title, source: "alertcondition" });
2588
2681
  }
2589
2682
 
2590
2683
  function inputTitle(node: Call): string | null {
@@ -2895,7 +2988,7 @@ function evalRequestSecurity(node: Call, env: Env): Cell {
2895
2988
  );
2896
2989
  }
2897
2990
 
2898
- function evalRequestCall(fname: string, node: Call, env: Env): Cell {
2991
+ function evalRequestCall(fname: string, node: Call, env: Env): Value {
2899
2992
  if (
2900
2993
  fname === "request.security" ||
2901
2994
  fname === "request.security_lower_tf" ||
@@ -2916,10 +3009,75 @@ function evalRequestCall(fname: string, node: Call, env: Env): Cell {
2916
3009
  const v = resolveRequest(fname, host, { seed });
2917
3010
  return typeof v === "number" || v === null ? v : NA;
2918
3011
  }
3012
+ if (fname === "request.footprint") {
3013
+ const numTicks = unwrap(evalExpr(callArg(node.args, 0, ["num_ticks"]), env));
3014
+ const va = unwrap(evalExpr(callArg(node.args, 1, ["va_percentage"]), env));
3015
+ const v = resolveRequest(fname, host, {
3016
+ num_ticks: numTicks ?? 100,
3017
+ va_percentage: va ?? 70,
3018
+ });
3019
+ return v instanceof Footprint ? v : NA;
3020
+ }
2919
3021
  const v = resolveRequest(fname, host, {});
2920
3022
  return typeof v === "number" || v === null ? v : NA;
2921
3023
  }
2922
3024
 
3025
+ function evalFootprintCall(fname: string | null, node: Call, env: Env): Value | undefined {
3026
+ if (fname == null) return undefined;
3027
+ if (fname.startsWith("footprint.")) {
3028
+ const fp = evalExpr(callArg(node.args, 0, ["id", "footprint"]), env);
3029
+ switch (fname) {
3030
+ case "footprint.buy_volume":
3031
+ return footprintBuyVolume(fp);
3032
+ case "footprint.sell_volume":
3033
+ return footprintSellVolume(fp);
3034
+ case "footprint.delta":
3035
+ return footprintDelta(fp);
3036
+ case "footprint.total_volume":
3037
+ return footprintTotalVolume(fp);
3038
+ case "footprint.vah":
3039
+ return footprintVah(fp);
3040
+ case "footprint.val":
3041
+ return footprintVal(fp);
3042
+ case "footprint.poc":
3043
+ return footprintPoc(fp);
3044
+ case "footprint.rows": {
3045
+ const arr = new PineArray();
3046
+ for (const row of footprintRows(fp)) arr.push(row);
3047
+ return arr;
3048
+ }
3049
+ case "footprint.get_row_by_price":
3050
+ return footprintGetRowByPrice(fp, unwrap(evalExpr(callArg(node.args, 1, ["price"]), env)));
3051
+ default:
3052
+ return undefined;
3053
+ }
3054
+ }
3055
+ if (fname.startsWith("volume_row.")) {
3056
+ const vr = evalExpr(callArg(node.args, 0, ["id", "volume_row"]), env);
3057
+ switch (fname) {
3058
+ case "volume_row.up_price":
3059
+ return volumeRowUpPrice(vr);
3060
+ case "volume_row.down_price":
3061
+ return volumeRowDownPrice(vr);
3062
+ case "volume_row.buy_volume":
3063
+ return volumeRowBuyVolume(vr);
3064
+ case "volume_row.sell_volume":
3065
+ return volumeRowSellVolume(vr);
3066
+ case "volume_row.delta":
3067
+ return volumeRowDelta(vr);
3068
+ case "volume_row.total_volume":
3069
+ return volumeRowTotalVolume(vr);
3070
+ case "volume_row.has_buy_imbalance":
3071
+ return volumeRowHasBuyImbalance(vr);
3072
+ case "volume_row.has_sell_imbalance":
3073
+ return volumeRowHasSellImbalance(vr);
3074
+ default:
3075
+ return undefined;
3076
+ }
3077
+ }
3078
+ return undefined;
3079
+ }
3080
+
2923
3081
  function evalMathCall(fname: string | null, node: Call, env: Env): Cell | undefined {
2924
3082
  if (fname === "math.abs" || fname === "abs") {
2925
3083
  return mathAbs(cellArg(node, env, 0, ["number", "x"]));
@@ -4978,8 +5136,16 @@ function evalDrawingCall(fname: string | null, node: Call, env: Env): Value | un
4978
5136
  return book.boxNew(env.barIndex, extra);
4979
5137
  }
4980
5138
  if (fname === "alert") {
5139
+ // Python `_builtin_alert`: zero-arg `alert()` is a soft no-op.
5140
+ if (asArgs(node.args).length === 0) return NA;
4981
5141
  const msg = evalAlertMessage(callArg(node.args, 0, ["message"]), env);
4982
- book.alert(env.barIndex, msg);
5142
+ const freqExpr = callArg(node.args, 1, ["freq"]);
5143
+ let freq: unknown = null;
5144
+ if (freqExpr != null) {
5145
+ const v = evalExpr(freqExpr, env);
5146
+ freq = typeof v === "string" ? v : unwrap(v);
5147
+ }
5148
+ emitAlert(env, msg, freq, { source: "alert" });
4983
5149
  return NA;
4984
5150
  }
4985
5151
  if (fname === "table.new") {