@pineforge/codegen-pyodide 0.8.0 → 0.9.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,15 +136,20 @@ from ..ast_nodes import (
136
136
  FuncCall,
137
137
  Identifier,
138
138
  MemberAccess,
139
+ NaLiteral,
139
140
  TupleLiteral,
140
141
  StringLiteral,
141
142
  )
142
143
  from ..symbols import TypeSpec
143
144
  from .. import signatures as sigs
145
+ from .drawing import ALL_DRAWING_METHODS
144
146
  from .tables import (
147
+ ARRAY_DRAWING_NEW_CTORS,
145
148
  ARRAY_METHODS,
146
149
  BAR_FIELDS,
147
150
  BAR_SERIES_PUSH,
151
+ DRAWING_NS,
152
+ DRAWING_TYPE_TO_CPP,
148
153
  MAP_METHODS,
149
154
  MATH_FUNC_MAP,
150
155
  MATRIX_METHODS,
@@ -156,6 +161,7 @@ from .tables import (
156
161
  SKIP_VAR_TYPES,
157
162
  STR_FUNC_MAP,
158
163
  TIME_FIELD_EXPRS,
164
+ _math_minmax_na_expr,
159
165
  _merge_kwargs,
160
166
  _merge_kwargs_with_defaults,
161
167
  tz_time_field_lambda,
@@ -170,9 +176,21 @@ def _parse_pine_datestring_ms(text: str) -> int | None:
170
176
  "MMM DD YYYY ..." forms. A dateString without a time zone is GMT+0 per
171
177
  the Pine reference. Returns None when the string cannot be parsed.
172
178
  """
173
- from datetime import datetime, timezone
179
+ import re
180
+ from datetime import datetime, timezone, timedelta
174
181
 
175
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)))
176
194
  dt = None
177
195
  try:
178
196
  dt = datetime.fromisoformat(txt)
@@ -182,6 +200,7 @@ def _parse_pine_datestring_ms(text: str) -> int | None:
182
200
  "%d %b %Y %H:%M:%S", "%d %b %Y %H:%M", "%d %b %Y",
183
201
  "%b %d %Y %H:%M:%S %z", "%b %d %Y %H:%M %z",
184
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",
185
204
  ):
186
205
  try:
187
206
  dt = datetime.strptime(txt, fmt)
@@ -191,7 +210,7 @@ def _parse_pine_datestring_ms(text: str) -> int | None:
191
210
  if dt is None:
192
211
  return None
193
212
  if dt.tzinfo is None:
194
- dt = dt.replace(tzinfo=timezone.utc)
213
+ dt = dt.replace(tzinfo=(tzoff or timezone.utc))
195
214
  return int(dt.timestamp() * 1000)
196
215
 
197
216
 
@@ -205,6 +224,63 @@ class CallVisitor:
205
224
  # Function-call dispatch
206
225
  # ------------------------------------------------------------------
207
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
+
208
284
  def _visit_func_call(self, node: FuncCall) -> str:
209
285
  callee = node.callee
210
286
  if isinstance(callee, MemberAccess):
@@ -213,7 +289,7 @@ class CallVisitor:
213
289
  mk = f"{recv_spec.name}.{callee.member}"
214
290
  fi_u = self._func_info_map.get(mk)
215
291
  if fi_u is not None and getattr(fi_u, "is_udt_method", False):
216
- fn_cpp = self._emit_udt_method_cpp_name(fi_u)
292
+ fn_cpp = self._udt_method_call_emit_name(fi_u, node)
217
293
  recv_e = self._visit_expr(callee.object)
218
294
  param_names = list(fi_u.node.params[1:]) if fi_u.node else []
219
295
  # Drop the leading ``self`` slot from param_defaults so the
@@ -226,6 +302,32 @@ class CallVisitor:
226
302
  )
227
303
  rest = [self._visit_expr(a) for a in rest_nodes]
228
304
  return f"{fn_cpp}({', '.join([recv_e] + rest)})"
305
+
306
+ # Drawing method dispatch (spec §4.3 / L.1). A KNOWN drawing method on a
307
+ # receiver that resolves to a drawing udt — gated on the METHOD NAME
308
+ # FIRST so a user method (egoigor's ``ln.slope()``, already routed by the
309
+ # block above) is never captured here. This single check covers all
310
+ # receiver shapes (identifier ``ln.set_x2(v)``, obj.field
311
+ # ``d.fld.set_y2(v)``, and arbitrary-expr ``d.upln.get(0).delete()``), so
312
+ # it precedes the obj.field.method / identifier branches below AND the
313
+ # generic ``delete`` -> ``_delete_`` rewrites + _resolve_callee.
314
+ if isinstance(callee, MemberAccess) and callee.member in ALL_DRAWING_METHODS:
315
+ recv_spec = self._type_spec_from_expr(callee.object)
316
+ if (recv_spec is not None and recv_spec.kind == "udt"
317
+ and recv_spec.name in DRAWING_TYPE_TO_CPP):
318
+ return self._emit_drawing_method(
319
+ recv_spec.name, callee.member, callee.object,
320
+ list(node.args), node,
321
+ )
322
+
323
+ # chart.point.now/new/from_index/from_time/copy — REAL data (a ChartPoint
324
+ # aggregate). Routed here BEFORE the obj.field.method receiver logic,
325
+ # which would otherwise mis-treat ``chart.point`` as a receiver object
326
+ # and raise on the ``chart.point`` member read (chart ∈ SKIP_NAMESPACES).
327
+ if self._is_chart_point_callee(callee):
328
+ cp_func, _cp_ns = self._resolve_callee(callee)
329
+ return self._emit_chart_point(cp_func, node)
330
+
229
331
  # obj.field.method(args) — must not lower to namespace::method (loses receiver chain).
230
332
  if isinstance(callee, MemberAccess):
231
333
  obj = callee.object
@@ -240,7 +342,9 @@ class CallVisitor:
240
342
  meth = callee.member
241
343
  raw_args = [self._visit_expr(a) for a in node.args]
242
344
  if recv_spec is not None and recv_spec.kind == "array" and meth in ARRAY_METHODS:
243
- 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
+ )
244
348
  if recv_spec is not None and recv_spec.kind == "map" and meth in MAP_METHODS:
245
349
  return self._map_method_expr(recv, meth, raw_args, recv_spec)
246
350
  args = ", ".join(raw_args)
@@ -264,7 +368,9 @@ class CallVisitor:
264
368
  return self._map_method_expr(m, meth_raw, margs, self._map_spec_for_name(oname))
265
369
  if oname in self._array_vars and meth_raw in ARRAY_METHODS:
266
370
  arr = self._safe_name(oname)
267
- 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
+ )
268
374
  return self._array_method_expr(arr, meth_raw, margs, self._array_spec_for_name(oname))
269
375
  if oname in self._matrix_specs and meth_raw in MATRIX_METHODS:
270
376
  arr = self._safe_name(oname)
@@ -293,7 +399,7 @@ class CallVisitor:
293
399
  mk = f"{udt_t}.{meth_raw}"
294
400
  fi_u = self._func_info_map.get(mk)
295
401
  if fi_u is not None and getattr(fi_u, "is_udt_method", False):
296
- fn_cpp = self._emit_udt_method_cpp_name(fi_u)
402
+ fn_cpp = self._udt_method_call_emit_name(fi_u, node)
297
403
  recv_e = self._visit_expr(obj)
298
404
  param_names = list(fi_u.node.params[1:]) if fi_u.node else []
299
405
  # Drop the leading ``self`` slot so param_defaults
@@ -321,10 +427,22 @@ class CallVisitor:
321
427
  return f"is_na({args})"
322
428
 
323
429
  # nz(x) / nz(x, y)
430
+ #
431
+ # x's emitted C++ source is substituted into the surrounding
432
+ # expression, so it must be evaluated EXACTLY ONCE: when x is a
433
+ # stateful call (e.g. a ta.* site lowered to `.compute()`/
434
+ # `.recompute()`), naively embedding {x} twice — once for the
435
+ # is_na() check, once for the non-na branch — invokes that call
436
+ # twice per bar, silently corrupting the indicator's internal state
437
+ # (e.g. nz(ta.sma(v, 50), v) becomes an effective 25-bar SMA: every
438
+ # bar is pushed into the ring buffer twice). An immediately-invoked
439
+ # lambda hoists x into a local `auto` so it is computed once and
440
+ # both branches read the same value; `[&]` is safe here since the
441
+ # lambda is called synchronously and discarded, never escaping.
324
442
  if func_name == "nz" and namespace is None:
325
443
  x = self._visit_expr(node.args[0])
326
444
  y = self._visit_expr(node.args[1]) if len(node.args) > 1 else "0.0"
327
- return f"(is_na({x}) ? {y} : {x})"
445
+ return f"([&]{{ auto _nz_v = ({x}); return is_na(_nz_v) ? ({y}) : _nz_v; }}())"
328
446
 
329
447
  # fixnan(x) -> persistent state
330
448
  if func_name == "fixnan" and namespace is None:
@@ -345,11 +463,19 @@ class CallVisitor:
345
463
  if site is not None:
346
464
  compute_args = self._ta_compute_args_for_site(site)
347
465
  ta_mem = self._ta_member_name(site)
348
- 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:
349
468
  return f"_precalc_{ta_mem}[i]"
350
- if getattr(site, "is_static", False):
351
- return f"(_use_precalc ? _precalc_{ta_mem}[bar_index_] : (is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args})))"
352
- 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
+ )
353
479
 
354
480
  # math.* calls
355
481
  if namespace == "math":
@@ -409,18 +535,23 @@ class CallVisitor:
409
535
  # Array method syntax: arr.push(val) where namespace is the array variable name
410
536
  if namespace is not None and namespace in self._array_vars and func_name in ARRAY_METHODS:
411
537
  arr = self._safe_name(namespace)
412
- args = [self._visit_expr(a) for a in node.args]
413
- 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)
414
541
 
415
542
  # Array operations — emit proper C++ vector operations
416
543
  if namespace == "array":
417
- if func_name in ("new", "new_float", "new_int", "new_bool", "new_string"):
544
+ if func_name in ("new", "new_float", "new_int", "new_bool", "new_string") or func_name in ARRAY_DRAWING_NEW_CTORS:
418
545
  spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
419
546
  cpp_type = self._type_spec_to_cpp(spec)
420
- 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)
421
549
  if node.args:
422
550
  size_arg = self._visit_expr(node.args[0])
423
- 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
424
555
  return f"{cpp_type}((size_t)({size_arg}), {init_val})"
425
556
  return f"{cpp_type}()"
426
557
  if func_name == "from":
@@ -430,8 +561,8 @@ class CallVisitor:
430
561
  # Method calls: array.method(arr, args...)
431
562
  if func_name in ARRAY_METHODS and node.args:
432
563
  arr = self._visit_expr(node.args[0])
433
- rest = [self._visit_expr(a) for a in node.args[1:]]
434
564
  spec = self._type_spec_from_expr(node.args[0])
565
+ rest = self._array_method_args(func_name, node.args[1:], spec)
435
566
  return self._array_method_expr(arr, func_name, rest, spec)
436
567
  return "0"
437
568
 
@@ -446,6 +577,16 @@ class CallVisitor:
446
577
  if namespace is None and func_name == "color" and func_name not in self._func_names:
447
578
  return "0"
448
579
 
580
+ # Drawing-objects-as-data namespace-functional form (spec §4.3 form 1):
581
+ # line.new(...) / line.get_y2(ln) / box.set_top(b, v) / linefill.new(...)
582
+ # MUST precede the SKIP_NAMESPACES early-return (these namespaces were
583
+ # removed from SKIP_NAMESPACES). chart.point.* resolves to namespace
584
+ # "chart", so it is matched by callee shape instead.
585
+ if namespace in DRAWING_NS:
586
+ return self._emit_drawing_namespace_call(namespace, func_name, node)
587
+ if self._is_chart_point_callee(callee):
588
+ return self._emit_chart_point(func_name, node)
589
+
449
590
  # Skip visual/unsupported namespace calls
450
591
  if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES:
451
592
  return "0"
@@ -468,19 +609,34 @@ class CallVisitor:
468
609
  all_args.append(None)
469
610
  all_args[i] = node.kwargs[pname]
470
611
 
471
- # Find matching security call ID
472
- sec_id = None
473
- tf_node = None
474
- expr_node = None
475
- for item in self._security_calls:
476
- sid, tfn, exprn = item["sec_id"], item["tf_node"], item["expr_node"]
477
- if item.get("is_lower_tf_array"):
478
- continue
479
- if exprn is all_args[2] if len(all_args) > 2 else False:
480
- sec_id = sid
481
- tf_node = tfn
482
- expr_node = exprn
483
- break
612
+ # Find matching security call ID. A request.security whose
613
+ # timeframe is a UDF parameter called from multiple sites with
614
+ # multiple distinct literal timeframes is registered as N
615
+ # CLONES (one SecurityCallInfo per call site, same source
616
+ # expr_node identity, distinct sec_id/callsite_idx — see
617
+ # Analyzer._check_mixed_callsite_security_tf). All clones
618
+ # match the identity check below identically, so when more
619
+ # than one matches, disambiguate by which call-site clone's
620
+ # function body is currently being emitted
621
+ # (self._active_call_site_idx, set by _emit_func_def while
622
+ # walking that exact clone's body).
623
+ candidates = [
624
+ item for item in self._security_calls
625
+ if not item.get("is_lower_tf_array")
626
+ and (exprn := item["expr_node"]) is not None
627
+ and (len(all_args) > 2 and exprn is all_args[2])
628
+ ]
629
+ chosen = None
630
+ if len(candidates) == 1:
631
+ chosen = candidates[0]
632
+ elif len(candidates) > 1:
633
+ chosen = next(
634
+ (c for c in candidates
635
+ if c.get("callsite_idx") == self._active_call_site_idx),
636
+ candidates[0],
637
+ )
638
+ sec_id = chosen["sec_id"] if chosen else None
639
+ expr_node = chosen["expr_node"] if chosen else None
484
640
 
485
641
  if sec_id is not None and expr_node is not None:
486
642
  if isinstance(expr_node, TupleLiteral):
@@ -522,9 +678,12 @@ class CallVisitor:
522
678
 
523
679
  # ticker.* calls
524
680
  if namespace == "ticker":
525
- # ticker.inherit(symbol, ...) and ticker.standard(symbol) — passthrough:
526
- # emit the symbol argument unchanged (same-symbol passthrough).
527
- if func_name in ("inherit", "standard"):
681
+ # ticker.inherit(symbol, ...) / ticker.standard(symbol) — passthrough,
682
+ # and ticker.heikinashi(symbol) — same-symbol HA: emit the symbol
683
+ # argument unchanged. The runtime HA candle transform is applied by
684
+ # the engine via register_security_eval's heikinashi flag, so the
685
+ # ticker value itself just needs to be the (string) chart symbol.
686
+ if func_name in ("inherit", "standard", "heikinashi"):
528
687
  if node.args:
529
688
  return self._visit_expr(node.args[0])
530
689
  if "symbol" in node.kwargs:
@@ -595,11 +754,12 @@ class CallVisitor:
595
754
  # mutex-guarded setenv+localtime_r block as the 2-arg
596
755
  # form.
597
756
  tz_arg = "syminfo_.timezone"
598
- # 2-arg form — honor the tz argument. The shared
599
- # ``tz_time_field_lambda`` (codegen/tables.py) also backs the
600
- # bare variable forms (``hour`` etc. via BAR_BUILTINS), so the
601
- # numbers agree across both forms.
602
- 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})"
603
763
 
604
764
  # time(timeframe) or time(timeframe, session[, tz])
605
765
  if func_name == "time" and namespace is None and (node.args or node.kwargs):
@@ -629,6 +789,8 @@ class CallVisitor:
629
789
  is_tz_first = True
630
790
  elif isinstance(node.args[0], StringLiteral):
631
791
  is_tz_first = True
792
+ elif self._infer_type(node.args[0]) == "std::string":
793
+ is_tz_first = True
632
794
 
633
795
  if is_tz_first:
634
796
  # A single string argument is the timestamp(dateString)
@@ -678,7 +840,7 @@ class CallVisitor:
678
840
  sc = args[6] if len(args) > 6 else "0"
679
841
  return (
680
842
  f"[&]() -> int64_t {{ "
681
- f"std::string _tz = ({tz}); "
843
+ f"std::string _tz = pineforge::normalize_timezone_for_posix(({tz})); "
682
844
  f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
683
845
  f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
684
846
  f"static thread_local std::string _last_tz; "
@@ -690,6 +852,7 @@ class CallVisitor:
690
852
  f"struct tm t = {{}}; "
691
853
  f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
692
854
  f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
855
+ f"t.tm_isdst = -1; "
693
856
  f"int64_t _res; "
694
857
  f"if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") {{ "
695
858
  f"_res = (int64_t)timegm(&t) * 1000; "
@@ -770,7 +933,15 @@ class CallVisitor:
770
933
  if func_name == "float" and namespace is None and node.args:
771
934
  return f"(double)({self._visit_expr(node.args[0])})"
772
935
  if func_name == "bool" and namespace is None and node.args:
773
- 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
+ )
774
945
  if func_name == "string" and namespace is None and node.args:
775
946
  # Pine string(x) cast — same emission as str.tostring(x), with
776
947
  # string passthrough and TV-style "true"/"false" for bools
@@ -915,10 +1086,19 @@ class CallVisitor:
915
1086
  elif f.default:
916
1087
  val = self._visit_expr(f.default)
917
1088
  if val is not None:
918
- # Fix narrowing: cast na<double>() to correct type for int fields
1089
+ # Fix narrowing: brace-init (``T{.field = v}``) disallows
1090
+ # narrowing. Pine ``int`` UDT fields are emitted as
1091
+ # ``int64_t`` (see base.py) but are initialised from
1092
+ # ``na<double>()`` / doubles in places, so cast to the
1093
+ # field's type. ``na<double>()`` for an int field → 0.
919
1094
  f_cpp_type = self._type_spec_to_cpp(field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name))
920
- if f_cpp_type == "int" and "na<double>" in val:
921
- val = val.replace("na<double>()", "0")
1095
+ if f_cpp_type == "int":
1096
+ f_cpp_type = "int64_t"
1097
+ if f_cpp_type == "int64_t":
1098
+ if "na<double>" in val:
1099
+ val = val.replace("na<double>()", "na<int64_t>()")
1100
+ else:
1101
+ val = f"(int64_t)({val})"
922
1102
  field_inits.append(f".{f.name} = {val}")
923
1103
  # Mark the constructed object non-na (the struct's ``__pf_na`` is the
924
1104
  # last declared field, so this designator stays in declaration order).
@@ -981,17 +1161,32 @@ class CallVisitor:
981
1161
 
982
1162
  def _visit_arg_for_series(arg_node, arg_idx):
983
1163
  """Visit a function argument, returning Series ref for series params."""
984
- if arg_idx in _func_series_param_indices and isinstance(arg_node, Identifier):
985
- aname = arg_node.name
986
- # Bar field: pass _s_close instead of current_bar_.close
987
- if aname in BAR_FIELDS or aname in BAR_SERIES_PUSH:
988
- return f"_s_{aname}"
989
- # Series var: pass the Series object directly
990
- if aname in self.ctx.series_vars:
991
- safe = self._safe_name(aname)
992
- if self._active_var_remap and safe in self._active_var_remap:
993
- safe = self._active_var_remap[safe]
994
- 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
+ )
995
1190
  return self._visit_expr(arg_node)
996
1191
 
997
1192
  if node.kwargs:
@@ -1012,6 +1207,17 @@ class CallVisitor:
1012
1207
  all_args.extend(self._visit_expr(v) for v in node.kwargs.values())
1013
1208
  else:
1014
1209
  all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
1210
+ # Drawing-style/visual CONSTANT passed positionally into a user function's
1211
+ # ``string`` parameter: ``label.style_*`` / ``size.*`` / other
1212
+ # DRAWING_STYLE_NS members lower to the bare token ``"0"`` (they only ever
1213
+ # feed dropped visual kwargs). Bound to a ``std::string`` parameter, that
1214
+ # ``0`` constructs ``std::string((char const*)0)`` at the call site -> a
1215
+ # null-pointer ``strlen`` crash at runtime. Coerce such args to
1216
+ # ``std::string("")`` so the (inert, visual-only) value is a valid empty
1217
+ # string. Only touches user functions with a known string param and an
1218
+ # arg that is exactly such a drawing-style constant read.
1219
+ if namespace is None and func_name in self._func_names:
1220
+ self._coerce_drawing_style_string_args(func_name, node.args, all_args)
1015
1221
  # Default args (parser does not store defaults): isInSession(sess, res = timeframe.period)
1016
1222
  if namespace is None and func_name in self._func_names:
1017
1223
  fi = self._func_info_map.get(func_name)
@@ -1023,7 +1229,16 @@ class CallVisitor:
1023
1229
  emit_name = self._func_safe_name(func_name) if func_name in self._func_names else func_name
1024
1230
  # Per-call-site variant: if this function has TA/series calls, call the correct variant
1025
1231
  cs_info = self.ctx.func_call_cs_map.get(id(node))
1026
- if self._active_call_site_idx is not None and cs_info is not None:
1232
+ dispatch_key = (self._current_instance_name, id(node))
1233
+ if dispatch_key in self._instance_dispatch:
1234
+ # Context-sensitive (call-path) dispatch: the instance pre-pass
1235
+ # resolved this nested stateful-helper call to the clone bound to
1236
+ # THIS enclosing path's members (see _build_func_instances). This
1237
+ # is authoritative — it supersedes the textual-cs threading below,
1238
+ # which conflates a callee's own call sites with the enclosing
1239
+ # function's call sites for helpers reached through >1 path.
1240
+ emit_name = self._instance_dispatch[dispatch_key]
1241
+ elif self._active_call_site_idx is not None and cs_info is not None:
1027
1242
  # Inside a per-call-site variant: override the cs_map index with
1028
1243
  # the parent's active call-site index. This ensures sub-functions
1029
1244
  # called from ma_cs6() use their _cs6 variant, not _cs0.
@@ -1040,10 +1255,62 @@ class CallVisitor:
1040
1255
  emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}"
1041
1256
  return f"{prefix}{emit_name}({', '.join(all_args)})"
1042
1257
 
1258
+ def _coerce_drawing_style_string_args(self, func_name, arg_nodes, all_args) -> None:
1259
+ """In-place coerce positional args bound to a ``std::string`` user-function
1260
+ parameter that lowered to the bare token ``"0"`` from a drawing-style /
1261
+ visual constant (``label.style_*`` etc.). Such a literal ``0`` binds as
1262
+ ``std::string((char const*)0)`` and segfaults on first use. Replace with
1263
+ ``std::string("")`` (the value is visual-only and inert in a backtest)."""
1264
+ from .tables import DRAWING_STYLE_NS
1265
+ fi = self._func_info_map.get(func_name)
1266
+ if not fi or not getattr(fi, "node", None) or not fi.node.params:
1267
+ return
1268
+ specs = getattr(fi, "param_type_specs", []) or []
1269
+ for i, arg in enumerate(arg_nodes):
1270
+ if i >= len(all_args) or all_args[i] != "0":
1271
+ continue
1272
+ # Only when the destination parameter is a string.
1273
+ spec = specs[i] if i < len(specs) else None
1274
+ is_string_param = spec is not None and getattr(spec, "kind", None) == "primitive" \
1275
+ and getattr(spec, "name", None) == "string"
1276
+ if not is_string_param:
1277
+ continue
1278
+ # Only when the source really is a drawing-style/visual constant read
1279
+ # (so we never silently turn a numeric ``0`` into an empty string).
1280
+ if (isinstance(arg, MemberAccess) and isinstance(arg.object, Identifier)
1281
+ and arg.object.name in DRAWING_STYLE_NS):
1282
+ all_args[i] = 'std::string("")'
1283
+
1043
1284
  def _visit_fixnan(self, node: FuncCall) -> str:
1044
1285
  """Emit fixnan with persistent state member."""
1045
- self._fixnan_counter += 1
1046
- 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}"
1047
1314
  x = self._visit_expr(node.args[0])
1048
1315
  return f"(is_na({x}) ? {member} : ({member} = {x}))"
1049
1316
 
@@ -1127,17 +1394,17 @@ class CallVisitor:
1127
1394
  qty_val = self._visit_expr(qty_n) if qty_n else "na<double>()"
1128
1395
  comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1129
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>()"
1130
1399
 
1131
1400
  if profit_n and not limit_n:
1132
- ticks = self._visit_expr(profit_n)
1133
- 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)
1134
1402
  if loss_n and not stop_n:
1135
- ticks = self._visit_expr(loss_n)
1136
- 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)
1137
1404
 
1138
1405
  return (f"strategy_exit({exit_id}, {from_id}, {limit_val}, {stop_val}, "
1139
1406
  f"{trail_pts}, {trail_off}, {trail_pr}, {qty_pct}, {comment}, "
1140
- f"{qty_val}, {oca_val})")
1407
+ f"{qty_val}, {oca_val}, {profit_ticks}, {loss_ticks})")
1141
1408
  close_comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1142
1409
  return f"strategy_close({exit_id}, {close_comment})"
1143
1410
 
@@ -1358,23 +1625,12 @@ class CallVisitor:
1358
1625
  if func_name == "avg" and len(args) > 2:
1359
1626
  sum_expr = " + ".join(f"(double)({a})" for a in args)
1360
1627
  return f"(({sum_expr}) / {len(args)}.0)"
1361
- if func_name == "max" and len(args) > 2:
1362
- result = f"std::max((double)({args[0]}), (double)({args[1]}))"
1363
- for a in args[2:]:
1364
- result = f"std::max({result}, (double)({a}))"
1365
- return result
1366
- if func_name == "min" and len(args) > 2:
1367
- result = f"std::min((double)({args[0]}), (double)({args[1]}))"
1368
- for a in args[2:]:
1369
- result = f"std::min({result}, (double)({a}))"
1370
- return result
1628
+ if func_name in ("min", "max"):
1629
+ return _math_minmax_na_expr(func_name, args)
1371
1630
  if func_name in MATH_FUNC_MAP:
1372
1631
  mapped = MATH_FUNC_MAP[func_name]
1373
1632
  if "{0}" in mapped:
1374
1633
  return mapped.format(*args)
1375
- # std::min/std::max require same types — cast to double
1376
- if func_name in ("min", "max") and len(args) == 2:
1377
- return f"{mapped}((double)({args[0]}), (double)({args[1]}))"
1378
1634
  return f"{mapped}({', '.join(args)})"
1379
1635
  # Unknown math.* — safe fallback
1380
1636
  return f"0.0 /* unsupported: math.{func_name} */"