@pineforge/codegen-pyodide 0.8.1 → 0.10.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.
@@ -136,6 +136,7 @@ from ..ast_nodes import (
136
136
  FuncCall,
137
137
  Identifier,
138
138
  MemberAccess,
139
+ NaLiteral,
139
140
  TupleLiteral,
140
141
  StringLiteral,
141
142
  )
@@ -160,6 +161,7 @@ from .tables import (
160
161
  SKIP_VAR_TYPES,
161
162
  STR_FUNC_MAP,
162
163
  TIME_FIELD_EXPRS,
164
+ _math_minmax_na_expr,
163
165
  _merge_kwargs,
164
166
  _merge_kwargs_with_defaults,
165
167
  tz_time_field_lambda,
@@ -174,9 +176,21 @@ def _parse_pine_datestring_ms(text: str) -> int | None:
174
176
  "MMM DD YYYY ..." forms. A dateString without a time zone is GMT+0 per
175
177
  the Pine reference. Returns None when the string cannot be parsed.
176
178
  """
177
- from datetime import datetime, timezone
179
+ import re
180
+ from datetime import datetime, timezone, timedelta
178
181
 
179
182
  txt = text.strip()
183
+ # Pine dateStrings may carry a trailing timezone WORD ("2024-01-01 00:00 UTC",
184
+ # "1 Jan 2020 09:30 GMT+2") that neither fromisoformat nor the strptime forms
185
+ # below can read. Peel it off and fold it into an explicit offset (UTC/GMT
186
+ # with an optional ±H[:MM] suffix; the bare word is +00:00).
187
+ tzoff = None
188
+ m = re.search(r"\s+(?:UTC|GMT)([+-]\d{1,2})?(?::?(\d{2}))?$", txt, re.I)
189
+ if m:
190
+ txt = txt[: m.start()].strip()
191
+ h = int(m.group(1) or 0)
192
+ mm = int(m.group(2) or 0)
193
+ tzoff = timezone(timedelta(hours=h, minutes=(mm if h >= 0 else -mm)))
180
194
  dt = None
181
195
  try:
182
196
  dt = datetime.fromisoformat(txt)
@@ -186,6 +200,7 @@ def _parse_pine_datestring_ms(text: str) -> int | None:
186
200
  "%d %b %Y %H:%M:%S", "%d %b %Y %H:%M", "%d %b %Y",
187
201
  "%b %d %Y %H:%M:%S %z", "%b %d %Y %H:%M %z",
188
202
  "%b %d %Y %H:%M:%S", "%b %d %Y %H:%M", "%b %d %Y",
203
+ "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d",
189
204
  ):
190
205
  try:
191
206
  dt = datetime.strptime(txt, fmt)
@@ -195,7 +210,7 @@ def _parse_pine_datestring_ms(text: str) -> int | None:
195
210
  if dt is None:
196
211
  return None
197
212
  if dt.tzinfo is None:
198
- dt = dt.replace(tzinfo=timezone.utc)
213
+ dt = dt.replace(tzinfo=(tzoff or timezone.utc))
199
214
  return int(dt.timestamp() * 1000)
200
215
 
201
216
 
@@ -209,6 +224,63 @@ class CallVisitor:
209
224
  # Function-call dispatch
210
225
  # ------------------------------------------------------------------
211
226
 
227
+ def _udt_method_call_emit_name(self, fi, node: FuncCall) -> str:
228
+ """Resolve a UDT method call through the ordinary UDF clone graph."""
229
+ base = self._emit_udt_method_cpp_name(fi)
230
+ dispatch = self._instance_dispatch.get(
231
+ (self._current_instance_name, id(node))
232
+ )
233
+ if dispatch is not None:
234
+ return dispatch
235
+
236
+ cs_info = self.ctx.func_call_cs_map.get(id(node))
237
+ if self._active_call_site_idx is not None and cs_info is not None:
238
+ return f"{base}_cs{self._active_call_site_idx}"
239
+ if cs_info is not None and cs_info[0] == fi.name:
240
+ return f"{base}_cs{cs_info[1]}"
241
+ if (self._active_call_site_idx is not None
242
+ and self.ctx.func_call_site_counts.get(fi.name, 0) > 1):
243
+ return f"{base}_cs{self._active_call_site_idx}"
244
+ return base
245
+
246
+ def _array_init_value_expr(self, elem_spec: TypeSpec | None, value_node) -> str:
247
+ if isinstance(value_node, NaLiteral):
248
+ if elem_spec is not None and elem_spec.kind == "udt":
249
+ return self._default_for_spec(elem_spec)
250
+ cpp_type = self._type_spec_to_cpp(elem_spec)
251
+ if cpp_type in ("double", "int", "int64_t", "bool", "std::string"):
252
+ return f"na<{cpp_type}>()"
253
+ return self._default_for_spec(elem_spec)
254
+ return self._visit_expr(value_node)
255
+
256
+ def _array_method_args(
257
+ self, method: str, arg_nodes: list, spec: TypeSpec | None,
258
+ ) -> list[str]:
259
+ elem_spec = (
260
+ spec.element
261
+ if spec is not None and spec.kind == "array" and spec.element is not None
262
+ else TypeSpec.primitive("float")
263
+ )
264
+ value_arg_indexes = {
265
+ "set": {1},
266
+ "push": {0},
267
+ "unshift": {0},
268
+ "insert": {1},
269
+ "fill": {0},
270
+ "includes": {0},
271
+ "indexof": {0},
272
+ "lastindexof": {0},
273
+ "binary_search": {0},
274
+ "binary_search_leftmost": {0},
275
+ "binary_search_rightmost": {0},
276
+ }.get(method, set())
277
+ return [
278
+ self._array_init_value_expr(elem_spec, arg)
279
+ if idx in value_arg_indexes
280
+ else self._visit_expr(arg)
281
+ for idx, arg in enumerate(arg_nodes)
282
+ ]
283
+
212
284
  def _visit_func_call(self, node: FuncCall) -> str:
213
285
  callee = node.callee
214
286
  if isinstance(callee, MemberAccess):
@@ -217,7 +289,7 @@ class CallVisitor:
217
289
  mk = f"{recv_spec.name}.{callee.member}"
218
290
  fi_u = self._func_info_map.get(mk)
219
291
  if fi_u is not None and getattr(fi_u, "is_udt_method", False):
220
- fn_cpp = self._emit_udt_method_cpp_name(fi_u)
292
+ fn_cpp = self._udt_method_call_emit_name(fi_u, node)
221
293
  recv_e = self._visit_expr(callee.object)
222
294
  param_names = list(fi_u.node.params[1:]) if fi_u.node else []
223
295
  # Drop the leading ``self`` slot from param_defaults so the
@@ -270,7 +342,9 @@ class CallVisitor:
270
342
  meth = callee.member
271
343
  raw_args = [self._visit_expr(a) for a in node.args]
272
344
  if recv_spec is not None and recv_spec.kind == "array" and meth in ARRAY_METHODS:
273
- return self._array_method_expr(recv, meth, raw_args, recv_spec)
345
+ return self._array_method_expr(
346
+ recv, meth, self._array_method_args(meth, node.args, recv_spec), recv_spec
347
+ )
274
348
  if recv_spec is not None and recv_spec.kind == "map" and meth in MAP_METHODS:
275
349
  return self._map_method_expr(recv, meth, raw_args, recv_spec)
276
350
  args = ", ".join(raw_args)
@@ -294,7 +368,9 @@ class CallVisitor:
294
368
  return self._map_method_expr(m, meth_raw, margs, self._map_spec_for_name(oname))
295
369
  if oname in self._array_vars and meth_raw in ARRAY_METHODS:
296
370
  arr = self._safe_name(oname)
297
- margs = [self._visit_expr(a) for a in node.args]
371
+ margs = self._array_method_args(
372
+ meth_raw, node.args, self._array_spec_for_name(oname)
373
+ )
298
374
  return self._array_method_expr(arr, meth_raw, margs, self._array_spec_for_name(oname))
299
375
  if oname in self._matrix_specs and meth_raw in MATRIX_METHODS:
300
376
  arr = self._safe_name(oname)
@@ -323,7 +399,7 @@ class CallVisitor:
323
399
  mk = f"{udt_t}.{meth_raw}"
324
400
  fi_u = self._func_info_map.get(mk)
325
401
  if fi_u is not None and getattr(fi_u, "is_udt_method", False):
326
- fn_cpp = self._emit_udt_method_cpp_name(fi_u)
402
+ fn_cpp = self._udt_method_call_emit_name(fi_u, node)
327
403
  recv_e = self._visit_expr(obj)
328
404
  param_names = list(fi_u.node.params[1:]) if fi_u.node else []
329
405
  # Drop the leading ``self`` slot so param_defaults
@@ -387,11 +463,19 @@ class CallVisitor:
387
463
  if site is not None:
388
464
  compute_args = self._ta_compute_args_for_site(site)
389
465
  ta_mem = self._ta_member_name(site)
390
- if getattr(self, "_precalc_loop_active", False) and getattr(site, "is_static", False):
466
+ uses_precalc = self._ta_site_uses_precalc(site)
467
+ if getattr(self, "_precalc_loop_active", False) and uses_precalc:
391
468
  return f"_precalc_{ta_mem}[i]"
392
- if getattr(site, "is_static", False):
393
- return f"(_use_precalc ? _precalc_{ta_mem}[bar_index_] : (is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args})))"
394
- return f"(is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args}))"
469
+ if uses_precalc:
470
+ return (
471
+ f"(_use_precalc ? _precalc_{ta_mem}[bar_index_] : "
472
+ f"(history_advances_new_bar() ? {ta_mem}.compute({compute_args}) "
473
+ f": {ta_mem}.recompute({compute_args})))"
474
+ )
475
+ return (
476
+ f"(history_advances_new_bar() ? {ta_mem}.compute({compute_args}) "
477
+ f": {ta_mem}.recompute({compute_args}))"
478
+ )
395
479
 
396
480
  # math.* calls
397
481
  if namespace == "math":
@@ -451,18 +535,23 @@ class CallVisitor:
451
535
  # Array method syntax: arr.push(val) where namespace is the array variable name
452
536
  if namespace is not None and namespace in self._array_vars and func_name in ARRAY_METHODS:
453
537
  arr = self._safe_name(namespace)
454
- args = [self._visit_expr(a) for a in node.args]
455
- return self._array_method_expr(arr, func_name, args, self._array_spec_for_name(namespace))
538
+ spec = self._array_spec_for_name(namespace)
539
+ args = self._array_method_args(func_name, node.args, spec)
540
+ return self._array_method_expr(arr, func_name, args, spec)
456
541
 
457
542
  # Array operations — emit proper C++ vector operations
458
543
  if namespace == "array":
459
544
  if func_name in ("new", "new_float", "new_int", "new_bool", "new_string") or func_name in ARRAY_DRAWING_NEW_CTORS:
460
545
  spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
461
546
  cpp_type = self._type_spec_to_cpp(spec)
462
- init_default = self._default_for_spec(spec.element if spec.element is not None else TypeSpec.primitive("float"))
547
+ elem_spec = spec.element if spec.element is not None else TypeSpec.primitive("float")
548
+ init_default = self._default_for_spec(elem_spec)
463
549
  if node.args:
464
550
  size_arg = self._visit_expr(node.args[0])
465
- init_val = self._visit_expr(node.args[1]) if len(node.args) > 1 else init_default
551
+ if len(node.args) > 1:
552
+ init_val = self._array_init_value_expr(elem_spec, node.args[1])
553
+ else:
554
+ init_val = init_default
466
555
  return f"{cpp_type}((size_t)({size_arg}), {init_val})"
467
556
  return f"{cpp_type}()"
468
557
  if func_name == "from":
@@ -472,8 +561,8 @@ class CallVisitor:
472
561
  # Method calls: array.method(arr, args...)
473
562
  if func_name in ARRAY_METHODS and node.args:
474
563
  arr = self._visit_expr(node.args[0])
475
- rest = [self._visit_expr(a) for a in node.args[1:]]
476
564
  spec = self._type_spec_from_expr(node.args[0])
565
+ rest = self._array_method_args(func_name, node.args[1:], spec)
477
566
  return self._array_method_expr(arr, func_name, rest, spec)
478
567
  return "0"
479
568
 
@@ -665,11 +754,12 @@ class CallVisitor:
665
754
  # mutex-guarded setenv+localtime_r block as the 2-arg
666
755
  # form.
667
756
  tz_arg = "syminfo_.timezone"
668
- # 2-arg form — honor the tz argument. The shared
669
- # ``tz_time_field_lambda`` (codegen/tables.py) also backs the
670
- # bare variable forms (``hour`` etc. via BAR_BUILTINS), so the
671
- # numbers agree across both forms.
672
- return tz_time_field_lambda(field_expr, ts_arg, tz_arg)
757
+ # Route through the engine's cached pine_<field>() (session_time.hpp),
758
+ # same as the bare variable forms (BAR_BUILTINS) — value-identical but
759
+ # free of the per-call setenv+tzset churn (KI-35). field_expr is unused
760
+ # now (the engine applies the Pine offsets internally).
761
+ del field_expr
762
+ return f"pine_{func_name}((int64_t)({ts_arg}), {tz_arg})"
673
763
 
674
764
  # time(timeframe) or time(timeframe, session[, tz])
675
765
  if func_name == "time" and namespace is None and (node.args or node.kwargs):
@@ -699,6 +789,8 @@ class CallVisitor:
699
789
  is_tz_first = True
700
790
  elif isinstance(node.args[0], StringLiteral):
701
791
  is_tz_first = True
792
+ elif self._infer_type(node.args[0]) == "std::string":
793
+ is_tz_first = True
702
794
 
703
795
  if is_tz_first:
704
796
  # A single string argument is the timestamp(dateString)
@@ -748,7 +840,7 @@ class CallVisitor:
748
840
  sc = args[6] if len(args) > 6 else "0"
749
841
  return (
750
842
  f"[&]() -> int64_t {{ "
751
- f"std::string _tz = ({tz}); "
843
+ f"std::string _tz = pineforge::normalize_timezone_for_posix(({tz})); "
752
844
  f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
753
845
  f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
754
846
  f"static thread_local std::string _last_tz; "
@@ -760,6 +852,7 @@ class CallVisitor:
760
852
  f"struct tm t = {{}}; "
761
853
  f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
762
854
  f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
855
+ f"t.tm_isdst = -1; "
763
856
  f"int64_t _res; "
764
857
  f"if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") {{ "
765
858
  f"_res = (int64_t)timegm(&t) * 1000; "
@@ -840,7 +933,15 @@ class CallVisitor:
840
933
  if func_name == "float" and namespace is None and node.args:
841
934
  return f"(double)({self._visit_expr(node.args[0])})"
842
935
  if func_name == "bool" and namespace is None and node.args:
843
- return f"(bool)({self._visit_expr(node.args[0])})"
936
+ # Pine v6 bools are two-state. Explicit bool(int/float) treats na
937
+ # like false, while a raw C++ cast would make NaN truthy.
938
+ x = self._visit_expr(node.args[0])
939
+ return (
940
+ f"[&](){{ auto _pf_v = ({x}); "
941
+ f"using _pf_t = std::decay_t<decltype(_pf_v)>; "
942
+ f"if constexpr (std::is_same_v<_pf_t, bool>) {{ return _pf_v; }} "
943
+ f"else {{ return is_na(_pf_v) ? false : (bool)_pf_v; }} }}()"
944
+ )
844
945
  if func_name == "string" and namespace is None and node.args:
845
946
  # Pine string(x) cast — same emission as str.tostring(x), with
846
947
  # string passthrough and TV-style "true"/"false" for bools
@@ -1060,17 +1161,32 @@ class CallVisitor:
1060
1161
 
1061
1162
  def _visit_arg_for_series(arg_node, arg_idx):
1062
1163
  """Visit a function argument, returning Series ref for series params."""
1063
- if arg_idx in _func_series_param_indices and isinstance(arg_node, Identifier):
1064
- aname = arg_node.name
1065
- # Bar field: pass _s_close instead of current_bar_.close
1066
- if aname in BAR_FIELDS or aname in BAR_SERIES_PUSH:
1067
- return f"_s_{aname}"
1068
- # Series var: pass the Series object directly
1069
- if aname in self.ctx.series_vars:
1070
- safe = self._safe_name(aname)
1071
- if self._active_var_remap and safe in self._active_var_remap:
1072
- safe = self._active_var_remap[safe]
1073
- return safe
1164
+ if arg_idx in _func_series_param_indices:
1165
+ if isinstance(arg_node, Identifier):
1166
+ aname = arg_node.name
1167
+ # Bar field: pass _s_close instead of current_bar_.close
1168
+ if aname in BAR_FIELDS or aname in BAR_SERIES_PUSH:
1169
+ return f"_s_{aname}"
1170
+ # Series var: pass the Series object directly
1171
+ if aname in self.ctx.series_vars:
1172
+ safe = self._safe_name(aname)
1173
+ if self._active_var_remap and safe in self._active_var_remap:
1174
+ safe = self._active_var_remap[safe]
1175
+ return safe
1176
+ expr_cpp = self._visit_expr(arg_node)
1177
+ cpp_t = self._infer_type(arg_node)
1178
+ if cpp_t not in ("double", "int", "bool"):
1179
+ cpp_t = "double"
1180
+ member = self._inline_history_member(
1181
+ "series_arg", node, arg_idx=arg_idx
1182
+ )
1183
+ return (
1184
+ f"([&]() -> const Series<{cpp_t}>& {{ "
1185
+ f"{cpp_t} _sv = ({expr_cpp}); "
1186
+ f"if (history_advances_new_bar()) {member}.push(_sv); "
1187
+ f"else {member}.update(_sv); "
1188
+ f"return {member}; }}())"
1189
+ )
1074
1190
  return self._visit_expr(arg_node)
1075
1191
 
1076
1192
  if node.kwargs:
@@ -1167,8 +1283,34 @@ class CallVisitor:
1167
1283
 
1168
1284
  def _visit_fixnan(self, node: FuncCall) -> str:
1169
1285
  """Emit fixnan with persistent state member."""
1170
- self._fixnan_counter += 1
1171
- member = f"_prev_fixnan_{self._fixnan_counter}"
1286
+ # Variant-aware lookup keyed off the analyzer-tracked site:
1287
+ # * function-owned site -> dispatch through the active per-call-site
1288
+ # remap so each emitted variant (cs0/cs1/__ni{N}) references its
1289
+ # OWN previous-value member.
1290
+ # * top-level site (owner_func is None) -> use ``site.member_name``
1291
+ # directly. The declarations come from ``ctx.fixnan_sites`` keyed
1292
+ # by these member names, so referencing anything else (e.g. the
1293
+ # legacy monotonic counter) would either dangle a declaration or
1294
+ # silently alias another site's state. In particular, when a
1295
+ # function-owned fixnan is analyzed BEFORE a top-level one, the
1296
+ # counter would restart at 1 and collide with the function's
1297
+ # ``_prev_fixnan_1`` -- corrupting both. Using the site's own
1298
+ # member name keeps declaration and reference in lockstep.
1299
+ # * unmapped site (node not in the site map, e.g. a fixnan reached
1300
+ # only through a path the analyzer didn't register) -> fall back
1301
+ # to the legacy monotonic counter so emission still produces a
1302
+ # referenceable member.
1303
+ site = self._fixnan_site_map.get(id(node))
1304
+ if site is not None:
1305
+ if site.member_name in self._func_fixnan_members:
1306
+ member = self._active_fixnan_remap.get(
1307
+ site.member_name, site.member_name
1308
+ )
1309
+ else:
1310
+ member = site.member_name
1311
+ else:
1312
+ self._fixnan_counter += 1
1313
+ member = f"_prev_fixnan_{self._fixnan_counter}"
1172
1314
  x = self._visit_expr(node.args[0])
1173
1315
  return f"(is_na({x}) ? {member} : ({member} = {x}))"
1174
1316
 
@@ -1252,17 +1394,17 @@ class CallVisitor:
1252
1394
  qty_val = self._visit_expr(qty_n) if qty_n else "na<double>()"
1253
1395
  comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1254
1396
  oca_val = self._visit_expr(oca_name_n) if oca_name_n is not None else '""'
1397
+ profit_ticks = "na<double>()"
1398
+ loss_ticks = "na<double>()"
1255
1399
 
1256
1400
  if profit_n and not limit_n:
1257
- ticks = self._visit_expr(profit_n)
1258
- limit_val = f"(position_entry_price_ + (signed_position_size() > 0 ? 1.0 : -1.0) * ({ticks}) * syminfo_mintick_)"
1401
+ profit_ticks = self._visit_expr(profit_n)
1259
1402
  if loss_n and not stop_n:
1260
- ticks = self._visit_expr(loss_n)
1261
- stop_val = f"(position_entry_price_ - (signed_position_size() > 0 ? 1.0 : -1.0) * ({ticks}) * syminfo_mintick_)"
1403
+ loss_ticks = self._visit_expr(loss_n)
1262
1404
 
1263
1405
  return (f"strategy_exit({exit_id}, {from_id}, {limit_val}, {stop_val}, "
1264
1406
  f"{trail_pts}, {trail_off}, {trail_pr}, {qty_pct}, {comment}, "
1265
- f"{qty_val}, {oca_val})")
1407
+ f"{qty_val}, {oca_val}, {profit_ticks}, {loss_ticks})")
1266
1408
  close_comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1267
1409
  return f"strategy_close({exit_id}, {close_comment})"
1268
1410
 
@@ -1483,23 +1625,12 @@ class CallVisitor:
1483
1625
  if func_name == "avg" and len(args) > 2:
1484
1626
  sum_expr = " + ".join(f"(double)({a})" for a in args)
1485
1627
  return f"(({sum_expr}) / {len(args)}.0)"
1486
- if func_name == "max" and len(args) > 2:
1487
- result = f"std::max((double)({args[0]}), (double)({args[1]}))"
1488
- for a in args[2:]:
1489
- result = f"std::max({result}, (double)({a}))"
1490
- return result
1491
- if func_name == "min" and len(args) > 2:
1492
- result = f"std::min((double)({args[0]}), (double)({args[1]}))"
1493
- for a in args[2:]:
1494
- result = f"std::min({result}, (double)({a}))"
1495
- return result
1628
+ if func_name in ("min", "max"):
1629
+ return _math_minmax_na_expr(func_name, args)
1496
1630
  if func_name in MATH_FUNC_MAP:
1497
1631
  mapped = MATH_FUNC_MAP[func_name]
1498
1632
  if "{0}" in mapped:
1499
1633
  return mapped.format(*args)
1500
- # std::min/std::max require same types — cast to double
1501
- if func_name in ("min", "max") and len(args) == 2:
1502
- return f"{mapped}((double)({args[0]}), (double)({args[1]}))"
1503
1634
  return f"{mapped}({', '.join(args)})"
1504
1635
  # Unknown math.* — safe fallback
1505
1636
  return f"0.0 /* unsupported: math.{func_name} */"
@@ -166,6 +166,20 @@ _BUILTIN_NAMESPACE_NAMES: frozenset[str] = frozenset({
166
166
  })
167
167
 
168
168
 
169
+ # KI-71: Pine relational comparisons (``==`` ``!=`` ``<`` ``>`` ``<=`` ``>=``)
170
+ # with an ``na`` operand evaluate *falsy*. Naive C++ relationals do not honour
171
+ # this for the engine's na sentinels, so these ops route through na-aware
172
+ # lowering when an operand can be na (see ``_visit_binop`` / ``_operand_na_kind``).
173
+ _RELATIONAL_OPS: frozenset[str] = frozenset({"==", "!=", "<", ">", "<=", ">="})
174
+
175
+ # C++ scalar types carrying a detectable ``na`` sentinel via ``is_na``:
176
+ # ``double`` -> NaN (IEEE), ``int``/``int64_t`` -> ``numeric_limits<T>::min()``.
177
+ # Only these route through the na-aware relational lowering — ``is_na`` has no
178
+ # overload for ``bool``/``std::string``/vector/UDT-value operands, and Pine's
179
+ # na-bool is engine-indistinguishable from ``false`` (na<bool>() == false).
180
+ _NA_SCALAR_CPP: frozenset[str] = frozenset({"int", "int64_t", "double"})
181
+
182
+
169
183
  class ExprVisitor:
170
184
  """Expression-level visitor methods shared across the codegen.
171
185
 
@@ -275,6 +289,16 @@ class ExprVisitor:
275
289
  return str(val)
276
290
  if isinstance(val, str):
277
291
  return f'std::string("{val}")'
292
+ # TA runtime-reset lowering: an input-backed var renders as its
293
+ # override-aware getter (not the member name), because the reset may
294
+ # run before the input members are initialised (evaluate_security path).
295
+ if (self._reset_input_getter_mode
296
+ and name in self._input_backed_vars
297
+ and name in self._input_var_to_call):
298
+ call_node = self._input_var_to_call[name]
299
+ func_name_i, namespace_i = self._resolve_callee(call_node.callee)
300
+ title = self._get_input_title(call_node, var_name=name)
301
+ return self._render_input_value(call_node, func_name_i, namespace_i, title)
278
302
  # Pine type name `color` used as a value (no variable) → int64 color constant.
279
303
  # Params handled above; symbol table does not retain function locals after analysis.
280
304
  if name == "color":
@@ -373,7 +397,12 @@ class ExprVisitor:
373
397
  if node.member == "position_size":
374
398
  return "signed_position_size()"
375
399
  if node.member == "position_avg_price":
376
- return "position_entry_price_"
400
+ # Pine returns `na` when the position is flat
401
+ # (position_size == 0); the engine field is 0.0 when flat.
402
+ # Guard so the common `na(strategy.position_avg_price)`
403
+ # flat/in-position idiom is not silently defeated.
404
+ return ("(signed_position_size() == 0.0 ? na<double>() "
405
+ ": position_entry_price_)")
377
406
  if node.member == "position_entry_name":
378
407
  return "position_entry_name()"
379
408
  # Trade counts
@@ -478,13 +507,34 @@ class ExprVisitor:
478
507
  (node.member in TA_IMPLICIT_COMPUTE_FULL and node.member in TA_COMPUTE_ARGS and TA_COMPUTE_ARGS[node.member] == [])
479
508
  or node.member == "vwap"
480
509
  ):
481
- # Find the matching call site
482
- for site in self.ctx.ta_call_sites:
510
+ # Find the matching call site. Skip sites pruned as dead
511
+ # code (their owner function is never called): a dead site's
512
+ # member declaration is never emitted (see base.py
513
+ # ``_dead_ta_indices`` / emit_top.py member-decl guard), so
514
+ # binding a LIVE bare property read to one references an
515
+ # undeclared member. Regression: nightowlxtrader-azt — a live
516
+ # ``ta.vwap`` (top-level + inside request.security) resolved
517
+ # to dead ``f5``'s first-in-order ``_ta_vwap_10`` and emitted
518
+ # ``use of undeclared identifier '_ta_vwap_10'``. A live read
519
+ # must bind to a live site.
520
+ for _i, site in enumerate(self.ctx.ta_call_sites):
521
+ if _i in self._dead_ta_indices:
522
+ continue
483
523
  ta_short = site.class_name.split("::")[-1].lower()
484
524
  if site.member_name.startswith(f"_ta_{node.member}_"):
485
525
  if node.member == "vwap":
486
- return f"(is_first_tick_ ? {site.member_name}.compute(current_bar_.close, current_bar_.volume, current_bar_.timestamp) : {site.member_name}.recompute(current_bar_.close, current_bar_.volume, current_bar_.timestamp))"
487
- return f"(is_first_tick_ ? {site.member_name}.compute({TA_IMPLICIT_COMPUTE_FULL[node.member]}) : {site.member_name}.recompute({TA_IMPLICIT_COMPUTE_FULL[node.member]}))"
526
+ return (
527
+ f"(history_advances_new_bar() ? {site.member_name}.compute("
528
+ "current_bar_.close, current_bar_.volume, current_bar_.timestamp) "
529
+ f": {site.member_name}.recompute(current_bar_.close, "
530
+ "current_bar_.volume, current_bar_.timestamp))"
531
+ )
532
+ return (
533
+ f"(history_advances_new_bar() ? {site.member_name}.compute("
534
+ f"{TA_IMPLICIT_COMPUTE_FULL[node.member]}) : "
535
+ f"{site.member_name}.recompute("
536
+ f"{TA_IMPLICIT_COMPUTE_FULL[node.member]}))"
537
+ )
488
538
  # No registered call site for this TA property read —
489
539
  # the old fallback emitted std::string("<name>"), a
490
540
  # silent type mismatch. Reject loudly instead.
@@ -724,11 +774,95 @@ class ExprVisitor:
724
774
  return f'std::string("{node.member}")'
725
775
  return f"{obj}.{node.member}"
726
776
 
777
+ def _operand_na_kind(self, node, cpp_type: str) -> str | None:
778
+ """Classify a relational operand by the ``na`` sentinel it can carry.
779
+
780
+ Returns:
781
+
782
+ * ``"int"`` — an ``int``/``int64_t`` expression that can hold the
783
+ ``INT_MIN`` sentinel. Because that sentinel is a *finite* very-negative
784
+ integer (not NaN), naive C++ diverges from Pine's falsy-on-na rule for
785
+ EVERY relational — ordered (``<`` ``>`` ``<=`` ``>=``) and equality
786
+ (``==`` ``!=``) alike.
787
+ * ``"float"`` — a ``double`` expression that can hold NaN. IEEE already
788
+ yields ``false`` for ``==`` ``<`` ``>`` ``<=`` ``>=`` against NaN
789
+ (matching Pine's falsy), so the ONLY diverging float cell is ``!=``
790
+ (IEEE ``NaN != x`` is true; Pine is falsy).
791
+ * ``None`` — provably not na (numeric/bool literal, inlined
792
+ compile-time constant) or a non-scalar type with no ``is_na`` overload;
793
+ the naive emission is already correct.
794
+ """
795
+ if cpp_type not in _NA_SCALAR_CPP:
796
+ return None
797
+ # Literals are never na.
798
+ if isinstance(node, (NumberLiteral, BoolLiteral)):
799
+ return None
800
+ # A bare ``na`` lowers to ``na<double>()`` — a real NaN, i.e. it IS na.
801
+ if self._is_na_expr(node):
802
+ return "float"
803
+ # Inlined compile-time constants (non-input known vars) never hold na.
804
+ if (isinstance(node, Identifier)
805
+ and node.name in self._known_vars
806
+ and node.name not in self._input_backed_vars):
807
+ return None
808
+ return "int" if cpp_type in ("int", "int64_t") else "float"
809
+
810
+ def _emit_na_relational(self, op: str, left: str, right: str) -> str:
811
+ """Emit an na-aware relational: ``false`` when either operand is ``na``.
812
+
813
+ Mirrors the ``nz()`` lambda idiom (``visit_call``): each operand is
814
+ hoisted to a temporary so a stateful operand expression is evaluated
815
+ exactly once (no double-step), then compared only when neither side is
816
+ na. ``is_na`` resolves via the emitted ``using namespace pineforge;``
817
+ (``double`` -> ``isnan``; integral -> ``== numeric_limits<T>::min()``).
818
+ """
819
+ return (f"([&]{{ auto _pna_l = ({left}); auto _pna_r = ({right}); "
820
+ f"return !is_na(_pna_l) && !is_na(_pna_r) && "
821
+ f"(_pna_l {op} _pna_r); }}())")
822
+
823
+ def _lower_relational(self, op: str, left_node, right_node,
824
+ left_cpp: str, right_cpp: str) -> str:
825
+ """Lower a Pine relational to C++, applying KI-71 na-aware wrapping.
826
+
827
+ Shared by ``_visit_binop`` and the ``request.security`` expression
828
+ builder so EVERY relational emission site honours Pine's falsy-on-na
829
+ rule. Wraps only the diverging cells: any na-capable *integer* operand
830
+ (the INT_MIN sentinel poisons all six operators) or a ``!=`` with an
831
+ na-capable *float* operand (the sole IEEE-diverging float cell). Pure
832
+ ``double`` ``==`` ``<`` ``>`` ``<=`` ``>=`` keep the naive emission —
833
+ IEEE is already falsy-on-NaN there, so wrapping would be a pure no-op.
834
+ Non-relational ``op`` (or non-scalar operands with no ``is_na``) fall
835
+ through to the naive ``(left op right)`` form unchanged.
836
+ """
837
+ if op in _RELATIONAL_OPS:
838
+ lt = self._infer_type(left_node)
839
+ rt = self._infer_type(right_node)
840
+ if lt in _NA_SCALAR_CPP and rt in _NA_SCALAR_CPP:
841
+ lk = self._operand_na_kind(left_node, lt)
842
+ rk = self._operand_na_kind(right_node, rt)
843
+ int_na = "int" in (lk, rk)
844
+ float_na = "float" in (lk, rk)
845
+ if int_na or (op == "!=" and float_na):
846
+ return self._emit_na_relational(op, left_cpp, right_cpp)
847
+ return f"({left_cpp} {op} {right_cpp})"
848
+
727
849
  def _visit_binop(self, node: BinOp) -> str:
728
850
  left = self._visit_expr(node.left)
729
851
  right = self._visit_expr(node.right)
730
852
  cpp_ops = {"and": "&&", "or": "||"}
731
853
  op = cpp_ops.get(node.op, node.op)
854
+ if node.op == "+":
855
+ lt = self._infer_type(node.left)
856
+ rt = self._infer_type(node.right)
857
+ if lt == "std::string" or rt == "std::string":
858
+ def _as_string(rendered, inferred):
859
+ if inferred == "std::string":
860
+ return rendered
861
+ if inferred == "bool":
862
+ return f'(({rendered}) ? std::string("true") : std::string("false"))'
863
+ return f"std::to_string({rendered})"
864
+
865
+ return f"({_as_string(left, lt)} + {_as_string(right, rt)})"
732
866
  # PineScript % works on floats — use std::fmod in C++
733
867
  if node.op == "%":
734
868
  return f"std::fmod((double)({left}), (double)({right}))"
@@ -738,7 +872,7 @@ class ExprVisitor:
738
872
  # Ref: https://www.tradingview.com/pine-script-docs/concepts/operators/
739
873
  if node.op == "/":
740
874
  return f"((double)({left}) / (double)({right}))"
741
- return f"({left} {op} {right})"
875
+ return self._lower_relational(op, node.left, node.right, left, right)
742
876
 
743
877
  def _visit_unaryop(self, node: UnaryOp) -> str:
744
878
  operand = self._visit_expr(node.operand)
@@ -784,26 +918,26 @@ class ExprVisitor:
784
918
  # ``ta.highest(high, 10)[1]`` or ``f()[2]``. In Pine the call yields a
785
919
  # series, so ``[k]`` reads its value k bars ago — but the call lowers to
786
920
  # a freshly-computed C++ scalar, and ``scalar[k]`` is not subscriptable.
787
- # Materialize the result into a self-contained history buffer: a static
921
+ # Materialize the result into a checkpoint-owned class-member
788
922
  # ``Series<T>`` that pushes (new bar) / updates (intrabar) the value
789
923
  # exactly once per evaluation — same semantics as every other series in
790
924
  # the strategy — and read ``[k]`` off it. The inner call is emitted once
791
- # so its own stateful indicator is not double-stepped, and the buffer
792
- # clears itself on run-start (``is_first_tick_ && bar_index_ == 0``) so a
793
- # reused strategy handle (parameter sweep) does not leak prior-run history.
925
+ # so its own stateful indicator is not double-stepped. The deterministic
926
+ # member identity includes the emitted UDF variant; separate Pine call
927
+ # sites never share history, and on_bar clears every synthetic member at
928
+ # run-start even when this particular expression is conditional.
794
929
  if isinstance(node.object, FuncCall):
795
930
  inner = self._visit_expr(node.object)
796
931
  cpp_t = self._infer_type(node.object)
797
932
  if cpp_t not in ("double", "int", "bool"):
798
933
  cpp_t = "double"
934
+ member = self._inline_history_member("hist_call", node)
799
935
  return (
800
936
  f"([&]() -> {cpp_t} {{ "
801
- f"static thread_local Series<{cpp_t}> _hist_call; "
802
- f"if (is_first_tick_ && bar_index_ == 0) _hist_call.clear(); "
803
937
  f"{cpp_t} _hv = ({inner}); "
804
- f"if (is_first_tick_) _hist_call.push(_hv); "
805
- f"else _hist_call.update(_hv); "
806
- f"return _hist_call[(int)({idx})]; }}())"
938
+ f"if (history_advances_new_bar()) {member}.push(_hv); "
939
+ f"else {member}.update(_hv); "
940
+ f"return {member}[(int)({idx})]; }}())"
807
941
  )
808
942
  obj = self._visit_expr(node.object)
809
943
  # If subscripting a non-series variable (e.g., function parameter),