@pineforge/codegen-pyodide 0.8.1 → 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.
@@ -67,7 +67,7 @@ from typing import Any
67
67
 
68
68
  from ..ast_nodes import (
69
69
  ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess,
70
- NumberLiteral, StringLiteral, TupleLiteral, UnaryOp, VarDecl,
70
+ NumberLiteral, StringLiteral, Ternary, TupleLiteral, UnaryOp, VarDecl,
71
71
  )
72
72
  from ..symbols import PineType
73
73
  from .. import signatures as sigs
@@ -75,7 +75,7 @@ from .. import tv_input_choices as tv_in
75
75
  from .contracts import FixnanCallSite, FuncInfo, SecurityCallInfo, TACallSite
76
76
  from .tables import (
77
77
  BAR_FIELDS, TA_CLASS_MAP, TA_MULTI_CTOR, TA_NO_CTOR, TA_PERIOD_ARG,
78
- TA_TUPLE_RETURNS,
78
+ TA_TUPLE_RETURNS, TA_TUPLE_ELEMENT_COUNTS, TA_COMPUTE_ARGS,
79
79
  )
80
80
 
81
81
 
@@ -205,6 +205,7 @@ class CallHandlers:
205
205
  returns_tuple=returns_tuple,
206
206
  node=node,
207
207
  is_static=is_static,
208
+ owner_func=(self._enclosing_func_names[-1] if self._enclosing_func_names else None),
208
209
  )
209
210
  self._ta_call_sites.append(site)
210
211
  self._ta_member_names.add(site.member_name)
@@ -235,9 +236,14 @@ class CallHandlers:
235
236
  elif func_name in TA_PERIOD_ARG:
236
237
  ctor_indices = {TA_PERIOD_ARG[func_name]}
237
238
 
238
- for i, arg in enumerate(all_args):
239
- if i not in ctor_indices and arg is not None:
240
- compute_args.append(arg)
239
+ if func_name in TA_COMPUTE_ARGS:
240
+ for i in TA_COMPUTE_ARGS[func_name]:
241
+ if i < len(all_args) and all_args[i] is not None:
242
+ compute_args.append(all_args[i])
243
+ else:
244
+ for i, arg in enumerate(all_args):
245
+ if i not in ctor_indices and arg is not None:
246
+ compute_args.append(arg)
241
247
 
242
248
  is_static = self._global_scope and all(self._is_static_expression(arg) for arg in compute_args)
243
249
  site = TACallSite(
@@ -248,6 +254,7 @@ class CallHandlers:
248
254
  returns_tuple=returns_tuple,
249
255
  node=node,
250
256
  is_static=is_static,
257
+ owner_func=(self._enclosing_func_names[-1] if self._enclosing_func_names else None),
251
258
  )
252
259
  self._ta_call_sites.append(site)
253
260
  self._ta_member_names.add(site.member_name)
@@ -309,6 +316,27 @@ class CallHandlers:
309
316
 
310
317
  returns_tuple = isinstance(expr_node, TupleLiteral)
311
318
  tuple_size = len(expr_node.elements) if returns_tuple else 0
319
+ if not returns_tuple and isinstance(expr_node, FuncCall):
320
+ expr_func = None
321
+ expr_ns = None
322
+ if (isinstance(expr_node.callee, MemberAccess)
323
+ and isinstance(expr_node.callee.object, Identifier)):
324
+ expr_ns = expr_node.callee.object.name
325
+ expr_func = expr_node.callee.member
326
+ if expr_ns == "ta":
327
+ if expr_func == "vwap":
328
+ merged_v = list(expr_node.args)
329
+ for i, pname in enumerate(["source", "anchor", "stdev_mult"]):
330
+ if pname in expr_node.kwargs:
331
+ while len(merged_v) <= i:
332
+ merged_v.append(None)
333
+ if merged_v[i] is None:
334
+ merged_v[i] = expr_node.kwargs[pname]
335
+ if len(merged_v) >= 3:
336
+ expr_func = "vwap_bands"
337
+ if expr_func in TA_TUPLE_RETURNS:
338
+ returns_tuple = True
339
+ tuple_size = TA_TUPLE_ELEMENT_COUNTS.get(expr_func, 0)
312
340
 
313
341
  gaps_node = all_args[3] if len(all_args) > 3 else None
314
342
  lookahead_node = all_args[4] if len(all_args) > 4 else None
@@ -785,11 +813,18 @@ class CallHandlers:
785
813
  arg_type = self._visit(arg)
786
814
 
787
815
  self._fixnan_counter += 1
816
+ owner = self._enclosing_func_names[-1] if self._enclosing_func_names else None
788
817
  site = FixnanCallSite(
789
818
  member_name=f"_prev_fixnan_{self._fixnan_counter}",
790
819
  pine_type=arg_type,
820
+ node=node,
821
+ owner_func=owner,
791
822
  )
823
+ idx = len(self._fixnan_sites)
792
824
  self._fixnan_sites.append(site)
825
+ self._fixnan_member_names.add(site.member_name)
826
+ if owner is not None:
827
+ self._func_fixnan_indices.setdefault(owner, []).append(idx)
793
828
 
794
829
  return arg_type
795
830
 
@@ -817,13 +852,30 @@ class CallHandlers:
817
852
  return _is_arith(n.left) and _is_arith(n.right)
818
853
  if isinstance(n, UnaryOp):
819
854
  return _is_arith(n.operand)
855
+ if isinstance(n, Ternary):
856
+ # ``cond ? a : b`` — expand when both branches are arith.
857
+ # The condition may be a comparison/logical over arith leaves;
858
+ # rely on the codegen stability gate to reject series deps.
859
+ return (_is_arith(n.true_val) and _is_arith(n.false_val)
860
+ and _is_arith(n.condition))
861
+ if isinstance(n, MemberAccess):
862
+ # ``timeframe.*`` / ``syminfo.*`` / ``math.pi`` etc. — stable
863
+ # per-run scalars that may appear inside a function-local
864
+ # derived length. Let the codegen stability classifier decide.
865
+ return True
820
866
  if isinstance(n, FuncCall):
821
- # Allow math.* helpers (math.round/sqrt/...) over arith args.
822
867
  callee = n.callee
868
+ # Allow math.* helpers (math.round/sqrt/cos/...) over arith args.
823
869
  if (isinstance(callee, MemberAccess)
824
870
  and isinstance(callee.object, Identifier)
825
871
  and callee.object.name == "math"):
826
872
  return all(_is_arith(a) for a in n.args)
873
+ # Pine type-cast builtins ``int(x)`` / ``float(x)`` / ``bool(x)``
874
+ # / ``string(x)`` — transparent over arith args. Common in
875
+ # derived TA lengths (``int(math.round(2 / a))``).
876
+ if (isinstance(callee, Identifier)
877
+ and callee.name in ("int", "float", "bool", "string")):
878
+ return all(_is_arith(a) for a in n.args)
827
879
  return False
828
880
 
829
881
  reassigned: set[str] = set()
@@ -848,6 +900,170 @@ class CallHandlers:
848
900
  defs[stmt.name] = self._expr_to_str(stmt.value)
849
901
  return defs
850
902
 
903
+ def _materialize_user_func_call_site_state(
904
+ self, func_name: str, cs_idx: int, node: FuncCall,
905
+ *, reuse_existing_owner: str | None = None) -> None:
906
+ """Materialize TA/fixnan state for one UDF call-site variant.
907
+
908
+ Ordinary call sites are handled while walking the AST. A second class
909
+ is discovered only after that walk: a stateful helper reached through a
910
+ multi-call-site parent needs the parent's additional call-path indices
911
+ even though the helper has only one textual call. The late propagation
912
+ pass in ``Analyzer._propagate_call_site_counts`` calls this same helper
913
+ for those inherited variants so the exported count never references a
914
+ TA/fixnan clone that was not actually declared.
915
+
916
+ ``reuse_existing_owner`` is used only by late propagation. A
917
+ range-widened parent may already have materialized the default
918
+ ``{member}_cs{idx}`` clone for the borrowed callee site; when that clone
919
+ belongs to the parent currently being propagated, it is the desired
920
+ call-path state and must be reused rather than duplicated under a
921
+ disambiguated-but-unused name.
922
+ """
923
+ func_def = self._func_defs[func_name]
924
+
925
+ param_arg_map: dict[str, str] = {}
926
+ for p_idx, param_name in enumerate(func_def.params):
927
+ if p_idx < len(node.args):
928
+ param_arg_map[param_name] = self._expr_to_str(node.args[p_idx])
929
+
930
+ if func_name in self._func_ta_ranges:
931
+ start, end = self._func_ta_ranges[func_name]
932
+
933
+ # Map local derived length variables back to expressions over the
934
+ # function's parameters before substituting call-site arguments.
935
+ local_defs = self._func_local_length_defs(func_def)
936
+
937
+ def _subst_params(arg: str, pmap: dict[str, str]) -> str:
938
+ import re
939
+ result = arg
940
+ for param, value in sorted(
941
+ pmap.items(), key=lambda item: len(item[0]), reverse=True):
942
+ result = re.sub(rf'\b{re.escape(param)}\b', value, result)
943
+ return result
944
+
945
+ def _expand_locals(arg: str) -> str:
946
+ import re
947
+ if not local_defs:
948
+ return arg
949
+ for _ in range(32):
950
+ def _rep(match: re.Match) -> str:
951
+ name = match.group(0)
952
+ if name in local_defs:
953
+ return "(" + local_defs[name] + ")"
954
+ return name
955
+ expanded = re.sub(r"[A-Za-z_][A-Za-z_0-9]*", _rep, arg)
956
+ if expanded == arg:
957
+ break
958
+ arg = expanded
959
+ return arg
960
+
961
+ import re as _re
962
+ enclosing_params: set[str] = set()
963
+ for names in self._enclosing_func_params:
964
+ enclosing_params |= names
965
+
966
+ if cs_idx == 0:
967
+ # cs0 owns the source-level sites. Preserve their parameterized
968
+ # ctor args for every later direct or inherited clone.
969
+ for i in range(start, end):
970
+ site = self._ta_call_sites[i]
971
+ if not hasattr(site, '_orig_ctor_args'):
972
+ site._orig_ctor_args = [
973
+ _expand_locals(arg) for arg in site.ctor_args
974
+ ]
975
+ site.ctor_args = [
976
+ _subst_params(arg, param_arg_map)
977
+ for arg in site._orig_ctor_args
978
+ ]
979
+ # If a ctor is now expressed in an enclosing UDF's params,
980
+ # retain that expression so the enclosing call can resolve
981
+ # it and widen the enclosing TA range as before.
982
+ if enclosing_params and self._nested_ta_touched is not None:
983
+ for arg in site.ctor_args:
984
+ tokens = set(_re.findall(
985
+ r"[A-Za-z_][A-Za-z_0-9]*", arg))
986
+ if tokens & enclosing_params:
987
+ site._orig_ctor_args = list(site.ctor_args)
988
+ self._nested_ta_touched.add(i)
989
+ break
990
+ else:
991
+ clone_name_map: dict[str, str] = {}
992
+ for i in range(start, end):
993
+ orig = self._ta_call_sites[i]
994
+ orig_args = getattr(orig, '_orig_ctor_args', orig.ctor_args)
995
+ resolved_ctor = [
996
+ _subst_params(arg, param_arg_map) for arg in orig_args
997
+ ]
998
+ clone_name = f"{orig.member_name}_cs{cs_idx}"
999
+ existing = next(
1000
+ (site for site in self._ta_call_sites
1001
+ if site.member_name == clone_name),
1002
+ None,
1003
+ )
1004
+ if (reuse_existing_owner is not None
1005
+ and existing is not None
1006
+ and existing.owner_func == reuse_existing_owner):
1007
+ # The active parent's widened range already made the
1008
+ # exact member this inherited callee variant needs.
1009
+ continue
1010
+ if clone_name in self._ta_member_names:
1011
+ base = clone_name
1012
+ suffix = 2
1013
+ while clone_name in self._ta_member_names:
1014
+ clone_name = f"{base}_u{suffix}"
1015
+ suffix += 1
1016
+ clone_name_map[orig.member_name] = clone_name
1017
+ cloned = TACallSite(
1018
+ member_name=clone_name,
1019
+ class_name=orig.class_name,
1020
+ ctor_args=resolved_ctor,
1021
+ compute_args=orig.compute_args[:],
1022
+ returns_tuple=orig.returns_tuple,
1023
+ node=orig.node,
1024
+ is_static=orig.is_static,
1025
+ owner_func=func_name,
1026
+ )
1027
+ self._ta_call_sites.append(cloned)
1028
+ self._ta_member_names.add(clone_name)
1029
+ if clone_name_map:
1030
+ self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map
1031
+
1032
+ # fixnan is stateful for the same reason as a rolling TA reducer: each
1033
+ # emitted function variant needs its own previous-value member.
1034
+ fn_indices = self._func_fixnan_indices.get(func_name, [])
1035
+ if cs_idx > 0 and fn_indices:
1036
+ clone_map: dict[str, str] = {}
1037
+ for fi in fn_indices:
1038
+ orig = self._fixnan_sites[fi]
1039
+ clone_name = f"{orig.member_name}_cs{cs_idx}"
1040
+ existing = next(
1041
+ (site for site in self._fixnan_sites
1042
+ if site.member_name == clone_name),
1043
+ None,
1044
+ )
1045
+ if (reuse_existing_owner is not None
1046
+ and existing is not None
1047
+ and existing.owner_func == reuse_existing_owner):
1048
+ continue
1049
+ if clone_name in self._fixnan_member_names:
1050
+ base = clone_name
1051
+ suffix = 2
1052
+ while clone_name in self._fixnan_member_names:
1053
+ clone_name = f"{base}_u{suffix}"
1054
+ suffix += 1
1055
+ clone_map[orig.member_name] = clone_name
1056
+ cloned = FixnanCallSite(
1057
+ member_name=clone_name,
1058
+ pine_type=orig.pine_type,
1059
+ node=orig.node,
1060
+ owner_func=func_name,
1061
+ )
1062
+ self._fixnan_sites.append(cloned)
1063
+ self._fixnan_member_names.add(clone_name)
1064
+ if clone_map:
1065
+ self._func_cs_fixnan_clone_names[(func_name, cs_idx)] = clone_map
1066
+
851
1067
  def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
852
1068
  """Handle calls to user-defined functions."""
853
1069
  func_def = self._func_defs[func_name]
@@ -899,137 +1115,29 @@ class CallHandlers:
899
1115
  arg = node.args[p_idx]
900
1116
  if isinstance(arg, Identifier) and arg.name in BAR_FIELDS:
901
1117
  self._series_bar_fields.add(arg.name)
902
-
903
- # Per-call-site cloning: if this function has TA calls or series vars,
904
- # track call sites so codegen can create per-call-site variants.
905
- # This prevents shared state corruption when the function is called
906
- # multiple times per bar.
1118
+ elif isinstance(arg, Identifier):
1119
+ sym = self._symbols.resolve(arg.name)
1120
+ spec = getattr(sym, "type_spec", None) if sym is not None else None
1121
+ if spec is not None and spec.kind in ("array", "map", "matrix"):
1122
+ continue
1123
+ if sym is not None:
1124
+ sym.is_series = True
1125
+ if sym.scope and sym.scope.startswith("func_"):
1126
+ caller_name = sym.scope[5:]
1127
+ self._func_series_vars.setdefault(caller_name, set()).add(arg.name)
1128
+ else:
1129
+ self._series_vars.add(arg.name)
1130
+
1131
+ # Per-call-site cloning: TA, series/var, and fixnan state all advance
1132
+ # across bars/calls and therefore require isolated UDF variants.
907
1133
  has_ta = func_name in self._func_ta_ranges
908
1134
  has_series = func_name in self._func_series_vars or func_name in self._func_var_members
909
- if has_ta or has_series:
1135
+ has_fixnan = func_name in self._func_fixnan_indices
1136
+ if has_ta or has_series or has_fixnan:
910
1137
  cs_idx = self._func_call_site_count.get(func_name, 0)
911
1138
  self._func_call_site_count[func_name] = cs_idx + 1
912
1139
  self._func_call_cs_map[id(node)] = (func_name, cs_idx)
913
-
914
- # Build parameter -> call-site argument string mapping
915
- param_arg_map: dict[str, str] = {}
916
- for p_idx, param_name in enumerate(func_def.params):
917
- if p_idx < len(node.args):
918
- param_arg_map[param_name] = self._expr_to_str(node.args[p_idx])
919
-
920
- # Clone TA call sites (only if function has TA ranges)
921
- if has_ta:
922
- start, end = self._func_ta_ranges[func_name]
923
-
924
- # Map of this function's local (non-param, non-series) derived
925
- # length vars to their raw RHS expression strings, e.g.
926
- # ``qqeCalc`` => ``wp = sf * 2 - 1`` -> {"wp": "sf * 2 - 1"}.
927
- # A TA ctor arg captured as the bare local name ("wp") must be
928
- # expanded to its definition so the subsequent param-substitution
929
- # turns it into a class-scope expression ("rsiSmooth * 2 - 1")
930
- # rather than leaving a dangling local that degenerates to
931
- # period 1 in codegen.
932
- local_defs = self._func_local_length_defs(func_def)
933
-
934
- def _subst_params(arg: str, pmap: dict[str, str]) -> str:
935
- """Substitute parameter names in an expression string.
936
-
937
- Handles both exact matches ('len' -> 'len3') and parameter
938
- names within expressions ('len / 2' -> 'len3 / 2').
939
- """
940
- import re
941
- result = arg
942
- # Sort by length descending to avoid partial replacements
943
- for param, value in sorted(pmap.items(), key=lambda x: len(x[0]), reverse=True):
944
- result = re.sub(rf'\b{re.escape(param)}\b', value, result)
945
- return result
946
-
947
- def _expand_locals(arg: str) -> str:
948
- """Recursively expand function-local length vars to their RHS
949
- (parenthesized) so only params / class-scope names remain."""
950
- import re
951
- if not local_defs:
952
- return arg
953
- for _ in range(32):
954
- def _rep(m: re.Match) -> str:
955
- nm = m.group(0)
956
- if nm in local_defs:
957
- return "(" + local_defs[nm] + ")"
958
- return nm
959
- new = re.sub(r"[A-Za-z_][A-Za-z_0-9]*", _rep, arg)
960
- if new == arg:
961
- break
962
- arg = new
963
- return arg
964
-
965
- # Params of the function we are *currently inside* (if this is a
966
- # nested user-func call). Used to detect when a substituted ctor
967
- # arg becomes parameterized by the OUTER function, so the outer
968
- # call site can resolve it (f_bbwp's _bbwLen -> i_bbwLen reaches
969
- # f_basisMa's sites).
970
- import re as _re
971
- enclosing_params: set[str] = set()
972
- for s in self._enclosing_func_params:
973
- enclosing_params |= s
974
-
975
- if cs_idx == 0:
976
- # First call site: save original param-based ctor_args for future cloning,
977
- # then resolve to actual call-site values
978
- for i in range(start, end):
979
- site = self._ta_call_sites[i]
980
- if not hasattr(site, '_orig_ctor_args'):
981
- site._orig_ctor_args = [
982
- _expand_locals(a) for a in site.ctor_args
983
- ]
984
- site.ctor_args = [_subst_params(a, param_arg_map) for a in site._orig_ctor_args]
985
- # If a substituted arg is now expressed in terms of an
986
- # enclosing function's params, promote it to the original
987
- # so the enclosing call re-substitutes, and mark the site
988
- # so the enclosing function's TA range widens to cover it.
989
- if enclosing_params and self._nested_ta_touched is not None:
990
- for a in site.ctor_args:
991
- toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", a))
992
- if toks & enclosing_params:
993
- site._orig_ctor_args = list(site.ctor_args)
994
- self._nested_ta_touched.add(i)
995
- break
996
- else:
997
- # Subsequent call sites: clone using saved original param names,
998
- # substituted with this call site's arguments
999
- clone_name_map: dict[str, str] = {}
1000
- for i in range(start, end):
1001
- orig = self._ta_call_sites[i]
1002
- orig_args = getattr(orig, '_orig_ctor_args', orig.ctor_args)
1003
- resolved_ctor = [_subst_params(a, param_arg_map) for a in orig_args]
1004
- # Default name follows the ``{base}_cs{cs_idx}`` formula the
1005
- # codegen re-derives. But the SAME base TA site can be reached
1006
- # through more than one enclosing function (e.g. a helper cloned
1007
- # both via its own call sites AND via a range-widened outer
1008
- # function), so two distinct (func, cs_idx) namespaces can mint
1009
- # the same name. Detect that collision and fall back to a
1010
- # globally-unique name; record the chosen name so the codegen
1011
- # consumes it verbatim (see _func_cs_ta_clone_names).
1012
- clone_name = f"{orig.member_name}_cs{cs_idx}"
1013
- if clone_name in self._ta_member_names:
1014
- base = clone_name
1015
- n = 2
1016
- while clone_name in self._ta_member_names:
1017
- clone_name = f"{base}_u{n}"
1018
- n += 1
1019
- clone_name_map[orig.member_name] = clone_name
1020
- cloned = TACallSite(
1021
- member_name=clone_name,
1022
- class_name=orig.class_name,
1023
- ctor_args=resolved_ctor,
1024
- compute_args=orig.compute_args[:],
1025
- returns_tuple=orig.returns_tuple,
1026
- node=orig.node,
1027
- is_static=orig.is_static,
1028
- )
1029
- self._ta_call_sites.append(cloned)
1030
- self._ta_member_names.add(clone_name)
1031
- if clone_name_map:
1032
- self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map
1140
+ self._materialize_user_func_call_site_state(func_name, cs_idx, node)
1033
1141
 
1034
1142
  # Create or update FuncInfo
1035
1143
  is_tuple = self._func_returns_tuple.get(func_name, False)
@@ -1041,7 +1149,10 @@ class CallHandlers:
1041
1149
  # Per-param TypeSpec: declared hints are authoritative; for untyped
1042
1150
  # params, infer from the call-site argument's type_spec (so an untyped
1043
1151
  # ``s`` used as a string, or a UDT passed by value, emits correctly).
1044
- param_specs = self._param_type_specs_from_def(func_def)
1152
+ param_specs = list(
1153
+ getattr(self, "_func_param_type_specs", {}).get(func_name)
1154
+ or self._param_type_specs_from_def(func_def)
1155
+ )
1045
1156
  arg_specs = [self._type_spec_from_expr(arg) for arg in node.args]
1046
1157
  for i in range(len(param_specs)):
1047
1158
  if param_specs[i] is None and i < len(arg_specs):
@@ -37,6 +37,20 @@ class TACallSite:
37
37
  returns_tuple: bool # e.g., MACD, supertrend
38
38
  node: Any = None # the FuncCall AST node
39
39
  is_static: bool = False # true if global scope & arguments are recursively static
40
+ # Name of the user function that OWNS this site, or None for a top-level
41
+ # site. For an ORIGINAL site this is the function whose body textually
42
+ # contains the ``ta.*`` call. For a CLONE minted in
43
+ # ``_handle_user_func_call`` this is the CALLEE (the function being
44
+ # called, NOT the caller whose body visit triggered the clone) -- the
45
+ # clone belongs to the callee's per-call-site namespace.
46
+ #
47
+ # The codegen's dead-code pass keys off this rather than off
48
+ # ``func_ta_ranges`` slices, because a function's slice can include
49
+ # clones of ANOTHER (live) function's sites (minted while visiting a
50
+ # caller's body). Marking such a borrowed clone dead would leave the
51
+ # owning callee's emitted clone body referencing undeclared members
52
+ # (regression: quantbyboji-nq-hma-midday ``_ta_change_*_cs1``).
53
+ owner_func: str | None = None
40
54
 
41
55
 
42
56
  @dataclass
@@ -87,6 +101,13 @@ class FixnanCallSite:
87
101
  """Per-call-site state for ``fixnan(...)`` (one previous-value member each)."""
88
102
  member_name: str # e.g., "_prev_fixnan_1"
89
103
  pine_type: Any # PineType
104
+ node: Any = None # the FuncCall AST node (for variant-aware lookup)
105
+ # Name of the user function that OWNS this site, or None for a top-level
106
+ # site. Mirrors ``TACallSite.owner_func``: the codegen's dead-code pass
107
+ # and per-variant clone logic key off this so a fixnan site minted inside
108
+ # a dead caller's body but cloned for a live callee survives, and each
109
+ # emitted function variant references its OWN fixnan member.
110
+ owner_func: str | None = None
90
111
 
91
112
 
92
113
  @dataclass
@@ -164,6 +185,14 @@ class AnalyzerContext:
164
185
  var_members: list = field(default_factory=list) # [(name, PineType, init_expr_str)]
165
186
  func_infos: list = field(default_factory=list)
166
187
  fixnan_sites: list = field(default_factory=list)
188
+ # Per-function fixnan site ownership (func_name -> list of indices into
189
+ # ``fixnan_sites``). Used by the codegen to clone fixnan members per
190
+ # call-site variant and to skip fixnan state owned by dead functions.
191
+ func_fixnan_indices: dict = field(default_factory=dict)
192
+ # (func_name, cs_idx) -> {orig_member_name: cloned_member_name}. Like
193
+ # ``func_cs_ta_clone_names`` but for fixnan: populated only when the
194
+ # default ``{base}_cs{cs_idx}`` clone name collides.
195
+ func_cs_fixnan_clone_names: dict = field(default_factory=dict)
167
196
  strategy_params: dict = field(default_factory=dict)
168
197
  diagnostics: list = field(default_factory=list) # warnings
169
198
  filename: str = "<stdin>"
@@ -89,8 +89,22 @@ class DiagnosticsHelper:
89
89
  loc,
90
90
  )
91
91
 
92
+ # Non-atomic node kinds whose serialized infix form must be parenthesized
93
+ # when used as an operand, so the string re-parses to the SAME tree. A TA
94
+ # ctor-arg string produced here is later re-parsed and lowered by the
95
+ # codegen (``_runtime_ctor_arg_for_reset``); a flattened ``(a - b) / c``
96
+ # would otherwise silently reassociate under C++ precedence.
97
+ _NONATOMIC_EXPR_NODES = (BinOp, UnaryOp, Ternary)
98
+
99
+ def _operand_to_str(self, node: ASTNode) -> str:
100
+ s = self._expr_to_str(node)
101
+ if isinstance(node, self._NONATOMIC_EXPR_NODES):
102
+ return f"({s})"
103
+ return s
104
+
92
105
  def _expr_to_str(self, node: ASTNode) -> str:
93
- """Convert an expression node to a rough string representation."""
106
+ """Convert an expression node to a string that re-parses to the same
107
+ tree (grouping preserved for non-atomic operands)."""
94
108
  if isinstance(node, NumberLiteral):
95
109
  return str(node.value)
96
110
  if isinstance(node, StringLiteral):
@@ -104,9 +118,9 @@ class DiagnosticsHelper:
104
118
  if isinstance(node, MemberAccess):
105
119
  return f"{self._expr_to_str(node.object)}.{node.member}"
106
120
  if isinstance(node, BinOp):
107
- return f"{self._expr_to_str(node.left)} {node.op} {self._expr_to_str(node.right)}"
121
+ return f"{self._operand_to_str(node.left)} {node.op} {self._operand_to_str(node.right)}"
108
122
  if isinstance(node, UnaryOp):
109
- return f"{node.op}{self._expr_to_str(node.operand)}"
123
+ return f"{node.op}{self._operand_to_str(node.operand)}"
110
124
  if isinstance(node, FuncCall):
111
125
  args = ", ".join(self._expr_to_str(a) for a in node.args)
112
126
  callee_str = self._expr_to_str(node.callee)
@@ -114,5 +128,5 @@ class DiagnosticsHelper:
114
128
  if isinstance(node, Subscript):
115
129
  return f"{self._expr_to_str(node.object)}[{self._expr_to_str(node.index)}]"
116
130
  if isinstance(node, Ternary):
117
- return f"{self._expr_to_str(node.condition)} ? {self._expr_to_str(node.true_val)} : {self._expr_to_str(node.false_val)}"
131
+ return f"{self._operand_to_str(node.condition)} ? {self._operand_to_str(node.true_val)} : {self._operand_to_str(node.false_val)}"
118
132
  return "<?>"
@@ -110,7 +110,7 @@ TA_CLASS_MAP = {
110
110
  TA_PERIOD_ARG = {
111
111
  "sma": 1, "ema": 1, "rma": 1, "rsi": 1, "atr": 0,
112
112
  "highest": 1, "lowest": 1, "change": 1,
113
- "wma": 1, "hma": 1, "stdev": 1,
113
+ "wma": 1, "hma": 1,
114
114
  # Task 6
115
115
  "sum": 1,
116
116
  # Task 7 Batch 1
@@ -119,7 +119,7 @@ TA_PERIOD_ARG = {
119
119
  "mom": 1, "roc": 1, "rising": 1, "falling": 1, "cci": 1,
120
120
  # cum has no period arg — handled in TA_NO_CTOR
121
121
  # Task 7 Batch 3
122
- "variance": 1, "median": 1, "highestbars": 1, "lowestbars": 1,
122
+ "median": 1, "highestbars": 1, "lowestbars": 1,
123
123
  # Batch 4
124
124
  "cmo": 1, "cog": 1, "correlation": 2,
125
125
  "percentile_nearest_rank": 1, "percentile_linear_interpolation": 1,
@@ -130,6 +130,14 @@ TA_PERIOD_ARG = {
130
130
 
131
131
  # Functions that return tuples
132
132
  TA_TUPLE_RETURNS = {"macd", "supertrend", "dmi", "bb", "kc", "vwap_bands"}
133
+ TA_TUPLE_ELEMENT_COUNTS = {
134
+ "macd": 3,
135
+ "supertrend": 2,
136
+ "dmi": 3,
137
+ "bb": 3,
138
+ "kc": 3,
139
+ "vwap_bands": 3,
140
+ }
133
141
 
134
142
  # Functions with multiple constructor args
135
143
  TA_MULTI_CTOR = {
@@ -156,6 +164,16 @@ TA_MULTI_CTOR = {
156
164
  "bbw": [1, 2], # length, mult
157
165
  "kcw": [1, 2], # length, mult
158
166
  "tr": [0], # handle_na (compile-time bool)
167
+ "stdev": [1, 2], # length, biased
168
+ "variance": [1, 2], # length, biased
169
+ }
170
+
171
+ # Compute-arg indices: which positional args are forwarded to ``.compute()``.
172
+ # Entries here override the default analyzer behavior of forwarding every
173
+ # non-constructor argument.
174
+ TA_COMPUTE_ARGS = {
175
+ "stdev": [0],
176
+ "variance": [0],
159
177
  }
160
178
 
161
179
  # No-state functions (no constructor args, stateless or self-contained)