@pineforge/codegen-pyodide 0.7.6 → 0.8.1

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.
@@ -141,10 +141,14 @@ from ..ast_nodes import (
141
141
  )
142
142
  from ..symbols import TypeSpec
143
143
  from .. import signatures as sigs
144
+ from .drawing import ALL_DRAWING_METHODS
144
145
  from .tables import (
146
+ ARRAY_DRAWING_NEW_CTORS,
145
147
  ARRAY_METHODS,
146
148
  BAR_FIELDS,
147
149
  BAR_SERIES_PUSH,
150
+ DRAWING_NS,
151
+ DRAWING_TYPE_TO_CPP,
148
152
  MAP_METHODS,
149
153
  MATH_FUNC_MAP,
150
154
  MATRIX_METHODS,
@@ -226,6 +230,32 @@ class CallVisitor:
226
230
  )
227
231
  rest = [self._visit_expr(a) for a in rest_nodes]
228
232
  return f"{fn_cpp}({', '.join([recv_e] + rest)})"
233
+
234
+ # Drawing method dispatch (spec §4.3 / L.1). A KNOWN drawing method on a
235
+ # receiver that resolves to a drawing udt — gated on the METHOD NAME
236
+ # FIRST so a user method (egoigor's ``ln.slope()``, already routed by the
237
+ # block above) is never captured here. This single check covers all
238
+ # receiver shapes (identifier ``ln.set_x2(v)``, obj.field
239
+ # ``d.fld.set_y2(v)``, and arbitrary-expr ``d.upln.get(0).delete()``), so
240
+ # it precedes the obj.field.method / identifier branches below AND the
241
+ # generic ``delete`` -> ``_delete_`` rewrites + _resolve_callee.
242
+ if isinstance(callee, MemberAccess) and callee.member in ALL_DRAWING_METHODS:
243
+ recv_spec = self._type_spec_from_expr(callee.object)
244
+ if (recv_spec is not None and recv_spec.kind == "udt"
245
+ and recv_spec.name in DRAWING_TYPE_TO_CPP):
246
+ return self._emit_drawing_method(
247
+ recv_spec.name, callee.member, callee.object,
248
+ list(node.args), node,
249
+ )
250
+
251
+ # chart.point.now/new/from_index/from_time/copy — REAL data (a ChartPoint
252
+ # aggregate). Routed here BEFORE the obj.field.method receiver logic,
253
+ # which would otherwise mis-treat ``chart.point`` as a receiver object
254
+ # and raise on the ``chart.point`` member read (chart ∈ SKIP_NAMESPACES).
255
+ if self._is_chart_point_callee(callee):
256
+ cp_func, _cp_ns = self._resolve_callee(callee)
257
+ return self._emit_chart_point(cp_func, node)
258
+
229
259
  # obj.field.method(args) — must not lower to namespace::method (loses receiver chain).
230
260
  if isinstance(callee, MemberAccess):
231
261
  obj = callee.object
@@ -321,10 +351,22 @@ class CallVisitor:
321
351
  return f"is_na({args})"
322
352
 
323
353
  # nz(x) / nz(x, y)
354
+ #
355
+ # x's emitted C++ source is substituted into the surrounding
356
+ # expression, so it must be evaluated EXACTLY ONCE: when x is a
357
+ # stateful call (e.g. a ta.* site lowered to `.compute()`/
358
+ # `.recompute()`), naively embedding {x} twice — once for the
359
+ # is_na() check, once for the non-na branch — invokes that call
360
+ # twice per bar, silently corrupting the indicator's internal state
361
+ # (e.g. nz(ta.sma(v, 50), v) becomes an effective 25-bar SMA: every
362
+ # bar is pushed into the ring buffer twice). An immediately-invoked
363
+ # lambda hoists x into a local `auto` so it is computed once and
364
+ # both branches read the same value; `[&]` is safe here since the
365
+ # lambda is called synchronously and discarded, never escaping.
324
366
  if func_name == "nz" and namespace is None:
325
367
  x = self._visit_expr(node.args[0])
326
368
  y = self._visit_expr(node.args[1]) if len(node.args) > 1 else "0.0"
327
- return f"(is_na({x}) ? {y} : {x})"
369
+ return f"([&]{{ auto _nz_v = ({x}); return is_na(_nz_v) ? ({y}) : _nz_v; }}())"
328
370
 
329
371
  # fixnan(x) -> persistent state
330
372
  if func_name == "fixnan" and namespace is None:
@@ -414,7 +456,7 @@ class CallVisitor:
414
456
 
415
457
  # Array operations — emit proper C++ vector operations
416
458
  if namespace == "array":
417
- if func_name in ("new", "new_float", "new_int", "new_bool", "new_string"):
459
+ if func_name in ("new", "new_float", "new_int", "new_bool", "new_string") or func_name in ARRAY_DRAWING_NEW_CTORS:
418
460
  spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
419
461
  cpp_type = self._type_spec_to_cpp(spec)
420
462
  init_default = self._default_for_spec(spec.element if spec.element is not None else TypeSpec.primitive("float"))
@@ -439,6 +481,23 @@ class CallVisitor:
439
481
  if namespace == "color":
440
482
  return self._visit_color_call(func_name, node)
441
483
 
484
+ # Bare color(...) cast (cosmetic). The engine has no color-cast helper
485
+ # and colors have no backtest-logic effect, so emit a benign default
486
+ # color (0 = na color, matching the color.new / from_gradient
487
+ # fallbacks). The support checker warns on this construct.
488
+ if namespace is None and func_name == "color" and func_name not in self._func_names:
489
+ return "0"
490
+
491
+ # Drawing-objects-as-data namespace-functional form (spec §4.3 form 1):
492
+ # line.new(...) / line.get_y2(ln) / box.set_top(b, v) / linefill.new(...)
493
+ # MUST precede the SKIP_NAMESPACES early-return (these namespaces were
494
+ # removed from SKIP_NAMESPACES). chart.point.* resolves to namespace
495
+ # "chart", so it is matched by callee shape instead.
496
+ if namespace in DRAWING_NS:
497
+ return self._emit_drawing_namespace_call(namespace, func_name, node)
498
+ if self._is_chart_point_callee(callee):
499
+ return self._emit_chart_point(func_name, node)
500
+
442
501
  # Skip visual/unsupported namespace calls
443
502
  if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES:
444
503
  return "0"
@@ -461,19 +520,34 @@ class CallVisitor:
461
520
  all_args.append(None)
462
521
  all_args[i] = node.kwargs[pname]
463
522
 
464
- # Find matching security call ID
465
- sec_id = None
466
- tf_node = None
467
- expr_node = None
468
- for item in self._security_calls:
469
- sid, tfn, exprn = item["sec_id"], item["tf_node"], item["expr_node"]
470
- if item.get("is_lower_tf_array"):
471
- continue
472
- if exprn is all_args[2] if len(all_args) > 2 else False:
473
- sec_id = sid
474
- tf_node = tfn
475
- expr_node = exprn
476
- break
523
+ # Find matching security call ID. A request.security whose
524
+ # timeframe is a UDF parameter called from multiple sites with
525
+ # multiple distinct literal timeframes is registered as N
526
+ # CLONES (one SecurityCallInfo per call site, same source
527
+ # expr_node identity, distinct sec_id/callsite_idx — see
528
+ # Analyzer._check_mixed_callsite_security_tf). All clones
529
+ # match the identity check below identically, so when more
530
+ # than one matches, disambiguate by which call-site clone's
531
+ # function body is currently being emitted
532
+ # (self._active_call_site_idx, set by _emit_func_def while
533
+ # walking that exact clone's body).
534
+ candidates = [
535
+ item for item in self._security_calls
536
+ if not item.get("is_lower_tf_array")
537
+ and (exprn := item["expr_node"]) is not None
538
+ and (len(all_args) > 2 and exprn is all_args[2])
539
+ ]
540
+ chosen = None
541
+ if len(candidates) == 1:
542
+ chosen = candidates[0]
543
+ elif len(candidates) > 1:
544
+ chosen = next(
545
+ (c for c in candidates
546
+ if c.get("callsite_idx") == self._active_call_site_idx),
547
+ candidates[0],
548
+ )
549
+ sec_id = chosen["sec_id"] if chosen else None
550
+ expr_node = chosen["expr_node"] if chosen else None
477
551
 
478
552
  if sec_id is not None and expr_node is not None:
479
553
  if isinstance(expr_node, TupleLiteral):
@@ -515,9 +589,12 @@ class CallVisitor:
515
589
 
516
590
  # ticker.* calls
517
591
  if namespace == "ticker":
518
- # ticker.inherit(symbol, ...) and ticker.standard(symbol) — passthrough:
519
- # emit the symbol argument unchanged (same-symbol passthrough).
520
- if func_name in ("inherit", "standard"):
592
+ # ticker.inherit(symbol, ...) / ticker.standard(symbol) — passthrough,
593
+ # and ticker.heikinashi(symbol) — same-symbol HA: emit the symbol
594
+ # argument unchanged. The runtime HA candle transform is applied by
595
+ # the engine via register_security_eval's heikinashi flag, so the
596
+ # ticker value itself just needs to be the (string) chart symbol.
597
+ if func_name in ("inherit", "standard", "heikinashi"):
521
598
  if node.args:
522
599
  return self._visit_expr(node.args[0])
523
600
  if "symbol" in node.kwargs:
@@ -908,11 +985,25 @@ class CallVisitor:
908
985
  elif f.default:
909
986
  val = self._visit_expr(f.default)
910
987
  if val is not None:
911
- # Fix narrowing: cast na<double>() to correct type for int fields
988
+ # Fix narrowing: brace-init (``T{.field = v}``) disallows
989
+ # narrowing. Pine ``int`` UDT fields are emitted as
990
+ # ``int64_t`` (see base.py) but are initialised from
991
+ # ``na<double>()`` / doubles in places, so cast to the
992
+ # field's type. ``na<double>()`` for an int field → 0.
912
993
  f_cpp_type = self._type_spec_to_cpp(field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name))
913
- if f_cpp_type == "int" and "na<double>" in val:
914
- val = val.replace("na<double>()", "0")
994
+ if f_cpp_type == "int":
995
+ f_cpp_type = "int64_t"
996
+ if f_cpp_type == "int64_t":
997
+ if "na<double>" in val:
998
+ val = val.replace("na<double>()", "na<int64_t>()")
999
+ else:
1000
+ val = f"(int64_t)({val})"
915
1001
  field_inits.append(f".{f.name} = {val}")
1002
+ # Mark the constructed object non-na (the struct's ``__pf_na`` is the
1003
+ # last declared field, so this designator stays in declaration order).
1004
+ # A bare default-constructed UDT keeps ``__pf_na = true`` (na); only a
1005
+ # real ``.new(...)`` flips it false so ``na(obj)`` reports correctly.
1006
+ field_inits.append(".__pf_na = false")
916
1007
  return f"{namespace}{{{', '.join(field_inits)}}}"
917
1008
 
918
1009
  # UDT copy: TypeName.copy(obj)
@@ -1000,6 +1091,17 @@ class CallVisitor:
1000
1091
  all_args.extend(self._visit_expr(v) for v in node.kwargs.values())
1001
1092
  else:
1002
1093
  all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
1094
+ # Drawing-style/visual CONSTANT passed positionally into a user function's
1095
+ # ``string`` parameter: ``label.style_*`` / ``size.*`` / other
1096
+ # DRAWING_STYLE_NS members lower to the bare token ``"0"`` (they only ever
1097
+ # feed dropped visual kwargs). Bound to a ``std::string`` parameter, that
1098
+ # ``0`` constructs ``std::string((char const*)0)`` at the call site -> a
1099
+ # null-pointer ``strlen`` crash at runtime. Coerce such args to
1100
+ # ``std::string("")`` so the (inert, visual-only) value is a valid empty
1101
+ # string. Only touches user functions with a known string param and an
1102
+ # arg that is exactly such a drawing-style constant read.
1103
+ if namespace is None and func_name in self._func_names:
1104
+ self._coerce_drawing_style_string_args(func_name, node.args, all_args)
1003
1105
  # Default args (parser does not store defaults): isInSession(sess, res = timeframe.period)
1004
1106
  if namespace is None and func_name in self._func_names:
1005
1107
  fi = self._func_info_map.get(func_name)
@@ -1011,7 +1113,16 @@ class CallVisitor:
1011
1113
  emit_name = self._func_safe_name(func_name) if func_name in self._func_names else func_name
1012
1114
  # Per-call-site variant: if this function has TA/series calls, call the correct variant
1013
1115
  cs_info = self.ctx.func_call_cs_map.get(id(node))
1014
- if self._active_call_site_idx is not None and cs_info is not None:
1116
+ dispatch_key = (self._current_instance_name, id(node))
1117
+ if dispatch_key in self._instance_dispatch:
1118
+ # Context-sensitive (call-path) dispatch: the instance pre-pass
1119
+ # resolved this nested stateful-helper call to the clone bound to
1120
+ # THIS enclosing path's members (see _build_func_instances). This
1121
+ # is authoritative — it supersedes the textual-cs threading below,
1122
+ # which conflates a callee's own call sites with the enclosing
1123
+ # function's call sites for helpers reached through >1 path.
1124
+ emit_name = self._instance_dispatch[dispatch_key]
1125
+ elif self._active_call_site_idx is not None and cs_info is not None:
1015
1126
  # Inside a per-call-site variant: override the cs_map index with
1016
1127
  # the parent's active call-site index. This ensures sub-functions
1017
1128
  # called from ma_cs6() use their _cs6 variant, not _cs0.
@@ -1028,6 +1139,32 @@ class CallVisitor:
1028
1139
  emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}"
1029
1140
  return f"{prefix}{emit_name}({', '.join(all_args)})"
1030
1141
 
1142
+ def _coerce_drawing_style_string_args(self, func_name, arg_nodes, all_args) -> None:
1143
+ """In-place coerce positional args bound to a ``std::string`` user-function
1144
+ parameter that lowered to the bare token ``"0"`` from a drawing-style /
1145
+ visual constant (``label.style_*`` etc.). Such a literal ``0`` binds as
1146
+ ``std::string((char const*)0)`` and segfaults on first use. Replace with
1147
+ ``std::string("")`` (the value is visual-only and inert in a backtest)."""
1148
+ from .tables import DRAWING_STYLE_NS
1149
+ fi = self._func_info_map.get(func_name)
1150
+ if not fi or not getattr(fi, "node", None) or not fi.node.params:
1151
+ return
1152
+ specs = getattr(fi, "param_type_specs", []) or []
1153
+ for i, arg in enumerate(arg_nodes):
1154
+ if i >= len(all_args) or all_args[i] != "0":
1155
+ continue
1156
+ # Only when the destination parameter is a string.
1157
+ spec = specs[i] if i < len(specs) else None
1158
+ is_string_param = spec is not None and getattr(spec, "kind", None) == "primitive" \
1159
+ and getattr(spec, "name", None) == "string"
1160
+ if not is_string_param:
1161
+ continue
1162
+ # Only when the source really is a drawing-style/visual constant read
1163
+ # (so we never silently turn a numeric ``0`` into an empty string).
1164
+ if (isinstance(arg, MemberAccess) and isinstance(arg.object, Identifier)
1165
+ and arg.object.name in DRAWING_STYLE_NS):
1166
+ all_args[i] = 'std::string("")'
1167
+
1031
1168
  def _visit_fixnan(self, node: FuncCall) -> str:
1032
1169
  """Emit fixnan with persistent state member."""
1033
1170
  self._fixnan_counter += 1
@@ -111,6 +111,8 @@ from .tables import (
111
111
  COLOR_CONST_MAP,
112
112
  DAYOFWEEK_MAP,
113
113
  DISPLAY_MAP,
114
+ DRAWING_STYLE_NS,
115
+ DRAWING_TYPE_TO_CPP,
114
116
  ON_OFF_INHERIT_MAP,
115
117
  ORDER_DIRECTION_MAP,
116
118
  SKIP_NAMESPACES,
@@ -180,7 +182,7 @@ class ExprVisitor:
180
182
  if isinstance(node, NumberLiteral):
181
183
  return str(node.value)
182
184
  if isinstance(node, StringLiteral):
183
- return f'std::string("{node.value}")'
185
+ return f'std::string("{self._cpp_string_escape(node.value)}")'
184
186
  if isinstance(node, BoolLiteral):
185
187
  return "true" if node.value else "false"
186
188
  if isinstance(node, NaLiteral):
@@ -209,6 +211,44 @@ class ExprVisitor:
209
211
  return f"std::make_tuple({elems})"
210
212
  return "/* unknown */"
211
213
 
214
+ # ------------------------------------------------------------------
215
+ # Target-typed RHS lowering (drawing handles are C++ structs, not doubles)
216
+ # ------------------------------------------------------------------
217
+ def _is_na_expr(self, node) -> bool:
218
+ """True for a bare ``na`` (keyword NaLiteral or ``na`` identifier)."""
219
+ return (isinstance(node, NaLiteral)
220
+ or (isinstance(node, Identifier) and node.name == "na"))
221
+
222
+ def _drawing_na_default(self, target_name: str | None) -> str | None:
223
+ """If ``target_name`` is a drawing-handle variable (line/box/label/
224
+ linefill/chart.point), return its na default literal (e.g. ``Box{}`` —
225
+ a default-constructed handle whose id == -1 == na); otherwise None."""
226
+ if not target_name:
227
+ return None
228
+ udt = self._udt_var_types.get(target_name)
229
+ if udt in DRAWING_TYPE_TO_CPP:
230
+ return f"{DRAWING_TYPE_TO_CPP[udt]}{{}}"
231
+ return None
232
+
233
+ def _visit_rhs_value(self, value_node, target_name: str | None = None,
234
+ target_cpp_type: str | None = None) -> str:
235
+ """Visit an assignment / declaration RHS.
236
+
237
+ A bare ``na`` lowers to a type-appropriate initializer for the target
238
+ instead of ``na<double>()``: drawing handles brace-init to their na
239
+ handle (``Box{}`` / ``Line{}`` / …); ``std::string``/``int``/``int64_t``/
240
+ ``bool`` use ``na<T>()``. Without this, ``Box b = na;`` and
241
+ ``string s = na;`` would both emit ``na<double>()`` and fail to compile
242
+ (no viable ``operator=`` / conversion). Every other RHS lowers unchanged.
243
+ """
244
+ if target_name and self._is_na_expr(value_node):
245
+ draw_default = self._drawing_na_default(target_name)
246
+ if draw_default is not None:
247
+ return draw_default
248
+ if target_cpp_type in ("std::string", "int", "int64_t", "bool"):
249
+ return f"na<{target_cpp_type}>()"
250
+ return self._visit_expr(value_node)
251
+
212
252
  def _visit_ident(self, node: Identifier) -> str:
213
253
  name = node.name
214
254
  # Bare 'na' identifier → na<double>()
@@ -475,8 +515,17 @@ class ExprVisitor:
475
515
  if node.member in ("is_heikinashi", "is_kagi", "is_linebreak",
476
516
  "is_pnf", "is_range", "is_renko"):
477
517
  return "false"
478
- # Defensive: support_checker.UNSUPPORTED_MEMBERS should already have
479
- # rejected any unhandled chart.* member. Reaching here is a bug.
518
+ # Cosmetic / chart-only reads with no batch-mode value (theme
519
+ # colors, viewport bar times). The support checker warns on
520
+ # these (COSMETIC_MEMBERS); emit a benign default (0 = na color /
521
+ # epoch 0). They have no backtest-logic effect.
522
+ if node.member in ("fg_color", "bg_color",
523
+ "left_visible_bar_time",
524
+ "right_visible_bar_time"):
525
+ return "0"
526
+ # Defensive: support_checker (COSMETIC_MEMBERS / UNSUPPORTED_MEMBERS)
527
+ # should already have warned/rejected any unhandled chart.* member.
528
+ # Reaching here is a bug.
480
529
  raise ValueError(
481
530
  f"codegen: unhandled chart.{node.member} — analyzer should have rejected. "
482
531
  f"Add a handler above or extend UNSUPPORTED_MEMBERS."
@@ -580,6 +629,14 @@ class ExprVisitor:
580
629
  if node.member in COLOR_CONST_MAP:
581
630
  return COLOR_CONST_MAP[node.member]
582
631
  return "0"
632
+ # Drawing style/visual CONSTANT member reads (line.style_solid,
633
+ # box.style_dashed, label.style_label_left, ...). These namespaces
634
+ # left SKIP_NAMESPACES (their FuncCall forms now route to the arena),
635
+ # but their constant members only ever feed dropped visual kwargs, so
636
+ # they still lower to "0". The function names arrive as FuncCalls
637
+ # (visit_call), never here. See spec §4.4.
638
+ if ns in DRAWING_STYLE_NS:
639
+ return "0"
583
640
  if ns in SKIP_NAMESPACES:
584
641
  return "0"
585
642
  if ns == "currency":
@@ -651,10 +708,16 @@ class ExprVisitor:
651
708
  or name in self._var_names
652
709
  or name in self._current_loop_vars
653
710
  or name in self._current_func_param_types
711
+ or name in self._current_func_locals
712
+ or name in self._udt_var_types
654
713
  ):
655
714
  safe = self._safe_name(name)
656
715
  if self._active_var_remap and safe in self._active_var_remap:
657
716
  safe = self._active_var_remap[safe]
717
+ # Pointer-aliased UDT local (BUG C, rebinding case): field access
718
+ # goes through ``->`` since the local holds ``UDT*``.
719
+ if name in self._udt_ptr_alias_locals:
720
+ return f"{safe}->{node.member}"
658
721
  return f"{safe}.{node.member}"
659
722
  if name not in self.ctx.series_vars:
660
723
  # Unknown identifier — likely an enum value
@@ -717,6 +780,31 @@ class ExprVisitor:
717
780
  if series_name not in self._strategy_series_vars:
718
781
  self._strategy_series_vars.add(series_name)
719
782
  return f"{series_name}[{idx}]"
783
+ # History reference applied directly to an inline call result, e.g.
784
+ # ``ta.highest(high, 10)[1]`` or ``f()[2]``. In Pine the call yields a
785
+ # series, so ``[k]`` reads its value k bars ago — but the call lowers to
786
+ # a freshly-computed C++ scalar, and ``scalar[k]`` is not subscriptable.
787
+ # Materialize the result into a self-contained history buffer: a static
788
+ # ``Series<T>`` that pushes (new bar) / updates (intrabar) the value
789
+ # exactly once per evaluation — same semantics as every other series in
790
+ # 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.
794
+ if isinstance(node.object, FuncCall):
795
+ inner = self._visit_expr(node.object)
796
+ cpp_t = self._infer_type(node.object)
797
+ if cpp_t not in ("double", "int", "bool"):
798
+ cpp_t = "double"
799
+ return (
800
+ 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
+ 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})]; }}())"
807
+ )
720
808
  obj = self._visit_expr(node.object)
721
809
  # If subscripting a non-series variable (e.g., function parameter),
722
810
  # src[0] → src (current value), src[N>0] → src (can't access history)