@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.
@@ -53,6 +53,7 @@ from .tables import (
53
53
  SKIP_VAR_TYPES,
54
54
  SYMINFO_MEMBER_MAP,
55
55
  COLOR_CONST_MAP,
56
+ ARRAY_NEW_CTORS,
56
57
  ARRAY_METHODS,
57
58
  MAP_METHODS,
58
59
  MATRIX_METHODS,
@@ -65,6 +66,15 @@ from .tables import (
65
66
  _merge_kwargs,
66
67
  )
67
68
 
69
+ TA_TUPLE_RESULT_TYPES = {
70
+ "macd": "ta::MACDResult",
71
+ "supertrend": "ta::SupertrendResult",
72
+ "dmi": "ta::DMIResult",
73
+ "bb": "ta::BBResult",
74
+ "kc": "ta::KCResult",
75
+ "vwap_bands": "ta::VWAPBandsResult",
76
+ }
77
+
68
78
  # (TA_IMPLICIT_COMPUTE / TA_COMPUTE_ARGS now imported from .tables above.)
69
79
 
70
80
  # (TA_IMPLICIT_COMPUTE_FULL / TA_IMPLICIT_APPEND / PINE_TYPE_TO_CPP /
@@ -181,6 +191,13 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
181
191
  self._func_cs_var_remap: dict[tuple[str, int], dict[str, str]] = {}
182
192
  # Active var name remap (set during per-call-site function emission)
183
193
  self._active_var_remap: dict[str, str] = {}
194
+ # When True (only while lowering a TA runtime-reset length expression
195
+ # through the expression visitor), an input-backed variable identifier
196
+ # renders as an override-aware ``get_input_*()`` read instead of its
197
+ # class member name. The reset can run in ``evaluate_security`` BEFORE
198
+ # the input members are initialised, so it must not depend on their
199
+ # init order. See ``_lower_reset_expr_via_visitor``.
200
+ self._reset_input_getter_mode: bool = False
184
201
  # Set of var/series member names that belong to user functions (need cloning)
185
202
  self._func_var_members_set: set[str] = set()
186
203
  # BUG C: function-local names emitted as ``UDT*`` pointer aliases (a UDT
@@ -190,6 +207,16 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
190
207
  # needed: names are function-unique and the value-copy fallback ignores
191
208
  # entries for inactive functions.
192
209
  self._udt_ptr_alias_locals: set[str] = set()
210
+ # Names of hoisted GLOBAL-scope UDT loop-locals bound from a UDT array
211
+ # element (``z = arr.get(i)``) and later field-mutated. Pine array
212
+ # elements of a user-defined type are references, so such a local must
213
+ # ALIAS the element, not value-copy — the mutation has to write back
214
+ # into the array. These are de-hoisted from the class-member value-copy
215
+ # to a fresh per-iteration ``UDT& z = arr[i];`` local reference (the same
216
+ # form the non-hoisted function-local alias path already emits). Read-only
217
+ # get-locals are NOT recorded (no field mutation) and keep value-copy
218
+ # semantics. Populated by _register_udt_array_get_ref_locals.
219
+ self._udt_array_get_ref_locals: set[str] = set()
193
220
  self._precalc_loop_active: bool = False
194
221
  # Names of ``var`` members that live in a FUNCTION scope (not global).
195
222
  # These are initialized once-per-function-variant on first call (a
@@ -328,14 +355,44 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
328
355
  self._instance_dispatch: dict[tuple[str | None, int], str] = {}
329
356
  self._fresh_instances: list[dict] = []
330
357
  self._fresh_var_members: list[tuple[str, str]] = []
358
+ # Fresh fixnan members for context-sensitive helper instances (nested
359
+ # helpers reached through >1 distinct call path). Each fresh instance
360
+ # gets its OWN previous-value member so two paths never share fixnan
361
+ # state. Populated by ``_build_func_instances``; declared in step 7.
362
+ self._fresh_fixnan_members: list[tuple[Any, str]] = []
331
363
  # NOTE: _build_func_instances() runs at the top of generate() (it needs
332
364
  # _all_member_names / _func_safe_name, which are populated later in __init__).
333
365
  # Build lookup: node id -> FixnanCallSite (counter-based)
334
366
  self._fixnan_counter = 0
367
+ # Per-call-site fixnan member remap for user functions (mirrors the TA
368
+ # remap): (func_name, cs_idx) -> {orig_member: cloned_member}.
369
+ self._func_cs_fixnan_remap: dict[tuple[str, int], dict[str, str]] = {}
370
+ # Active fixnan remap (set during per-call-site function emission).
371
+ self._active_fixnan_remap: dict[str, str] = {}
372
+ # node id -> original FixnanCallSite (the cs0 / source-level site).
373
+ self._fixnan_site_map: dict[int, Any] = {}
374
+ # Set of fixnan member names that belong to user functions (excluded
375
+ # from the site map so the active remap can dispatch per variant).
376
+ self._func_fixnan_members: set[str] = set()
377
+ # Dead fixnan site indices (owner is a dead user function). Skipped
378
+ # at declaration time so dead functions' fixnan state is not emitted.
379
+ self._dead_fixnan_indices: set[int] = set()
335
380
  self._switch_counter = 0
336
381
  self._security_inline_counter = 0
337
382
  self._random_call_counter = 0
338
383
  self._for_counter = 0
384
+ # Synthetic history buffers used by inline call-history and by scalar
385
+ # expressions passed to UDF series parameters. They are pre-registered
386
+ # at generate() time so declarations precede method emission, then
387
+ # addressed by (source node, emitted UDF variant). Each record is a
388
+ # real class-member Series and therefore joins _PFScriptState through
389
+ # the declaration-derived checkpoint inventory.
390
+ self._inline_history_members: list[dict] = []
391
+ self._inline_history_member_by_key: dict[tuple, str] = {}
392
+ # Unique lambda-local names used when an array lowering references its
393
+ # receiver more than once. The binding keeps temporary-producing or
394
+ # side-effectful receivers single-evaluation (see TypeInferer).
395
+ self._array_receiver_counter = 0
339
396
  # UDT / enum (needed before _collect_known_vars for input.enum)
340
397
  self._udt_defs: dict[str, list] = {}
341
398
  self._enum_defs: dict[str, list[str]] = {}
@@ -367,9 +424,25 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
367
424
  # ctor-init list still folds to the Pine-default literal via _resolve_known.
368
425
  self._derived_input_expr: dict[str, str] = {}
369
426
  self._timeframe_period_vars: set[str] = set()
427
+ # Names of class-scope vars whose value is a bar-invariant scalar —
428
+ # i.e. derived only from inputs, literals, ``timeframe.*`` members,
429
+ # ``math.*`` over stable args, and ternaries/casts/arithmetic over
430
+ # any of those. Such vars are safe to embed in a TA ctor runtime
431
+ # reset expression (they do not depend on per-bar series). Vars
432
+ # referencing series / ta.* results / history subscripts / strategy
433
+ # state are NOT here, so a TA length fed by them is still rejected
434
+ # by the constructor guard.
435
+ self._stable_runtime_vars: set[str] = set()
436
+ # ``_var_names`` (var/varip persistent-state members) is needed by the
437
+ # stability classifier during _collect_known_vars, so pre-seed it from
438
+ # the analyzer's var_members before that pass runs; the canonical
439
+ # assignment below preserves the existing initialization order.
440
+ self._var_names: set[str] = set()
441
+ for _vn, _, _ in ctx.var_members:
442
+ self._var_names.add(_vn)
370
443
  self._collect_known_vars()
371
444
  # Track var names
372
- self._var_names: set[str] = set()
445
+ self._var_names = set()
373
446
  for name, _, _ in ctx.var_members:
374
447
  self._var_names.add(name)
375
448
  # Every name bound ANYWHERE in the program (top-level, nested in
@@ -386,6 +459,87 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
386
459
  for fi in ctx.func_infos:
387
460
  self._func_names.add(fi.name)
388
461
  self._func_info_map[fi.name] = fi
462
+ # Dead-code user functions: those that contain TA call sites but are
463
+ # never called anywhere in the script (no call site registered them,
464
+ # so func_call_site_counts reports 0). Their OWN TA ctor args still
465
+ # carry bare parameter names (e.g. ``dirmov_short(len) => ta.rma(ta.tr, len)``)
466
+ # that can never be resolved to a concrete length, and since the
467
+ # function never runs its TA buffers would be dead weight anyway.
468
+ # Track the dead TA site indices and dead function names so emission
469
+ # can skip both — the ctor guard no longer hard-fails on the bare
470
+ # param and no dangling member/function body is emitted. A function
471
+ # with zero call sites but NO TA state is NOT dead-by-this-rule (it
472
+ # may still be emitted; harmless if truly unreferenced).
473
+ #
474
+ # IMPORTANT: dead-ness of a TA site is decided by the site's
475
+ # ``owner_func`` (set by the analyzer), NOT by which function's
476
+ # ``func_ta_ranges`` slice the site happens to fall in. A function's
477
+ # slice can include clones of ANOTHER (live) function's sites that
478
+ # were minted while visiting THIS function's body (a nested call to
479
+ # a live callee registers the callee's cs{N} clones in the caller's
480
+ # TA-range slice). Keying dead-ness off the slice would drop those
481
+ # borrowed clones' declarations, leaving the owning callee's emitted
482
+ # clone body referencing undeclared members. Regression:
483
+ # quantbyboji-nq-hma-midday (``_ta_change_*_cs1`` / ``_ta_rma_*_cs1``
484
+ # minted inside dead ``adx_short``'s body but owned by live ``dirmov``).
485
+ self._dead_func_names: set[str] = set()
486
+ self._dead_ta_indices: set[int] = set()
487
+ for _fn in (ctx.func_ta_ranges or {}):
488
+ if (ctx.func_call_site_counts or {}).get(_fn, 0) > 0:
489
+ continue
490
+ # Only treat plain user functions (not UDT methods) as skippable
491
+ # dead code; methods are dispatched through the UDT and their
492
+ # call-site tracking is handled separately.
493
+ fi = self._func_info_map.get(_fn)
494
+ if fi is not None and getattr(fi, "is_udt_method", False):
495
+ continue
496
+ self._dead_func_names.add(_fn)
497
+ # Mark TA sites dead ONLY when their owner is a dead function. A
498
+ # site with ``owner_func=None`` (top-level) or whose owner is a
499
+ # live function survives -- even if it sits inside a dead
500
+ # function's TA-range slice (it's a borrowed clone).
501
+ for _i, _site in enumerate(ctx.ta_call_sites):
502
+ _owner = getattr(_site, "owner_func", None)
503
+ if _owner is not None and _owner in self._dead_func_names:
504
+ self._dead_ta_indices.add(_i)
505
+ # Build per-call-site fixnan remap + site map (mirrors TA remap above).
506
+ # Dead fixnan sites (owner is a dead function) are skipped at decl time.
507
+ clone_fn_names = getattr(ctx, "func_cs_fixnan_clone_names", {})
508
+ for _i, _fsite in enumerate(ctx.fixnan_sites):
509
+ _fowner = getattr(_fsite, "owner_func", None)
510
+ if _fowner is not None and _fowner in self._dead_func_names:
511
+ self._dead_fixnan_indices.add(_i)
512
+ # cs0 fixnan remap is identity (originals). Build originals per func.
513
+ func_fixnan_originals: dict[str, list[str]] = {}
514
+ for _fname, _idxs in (ctx.func_fixnan_indices or {}).items():
515
+ origs = [ctx.fixnan_sites[i].member_name for i in _idxs
516
+ if i not in self._dead_fixnan_indices]
517
+ if origs:
518
+ func_fixnan_originals[_fname] = origs
519
+ self._func_cs_fixnan_remap[(_fname, 0)] = {m: m for m in origs}
520
+ self._func_fixnan_members.update(origs)
521
+ # cs > 0 remap uses the ``{orig}_cs{cs_idx}`` formula (or the
522
+ # analyzer's disambiguated name from func_cs_fixnan_clone_names).
523
+ for _fname, _origs in func_fixnan_originals.items():
524
+ _total_cs = ctx.func_call_site_counts.get(_fname, 1)
525
+ for _cs_idx in range(1, _total_cs):
526
+ _overrides = clone_fn_names.get((_fname, _cs_idx), {})
527
+ _remap = {}
528
+ for _orig in _origs:
529
+ _remap[_orig] = _overrides.get(_orig, f"{_orig}_cs{_cs_idx}")
530
+ self._func_cs_fixnan_remap[(_fname, _cs_idx)] = _remap
531
+ self._func_fixnan_members.update(_remap.values())
532
+ # Site map: node id -> original site (cs0). Skip dead sites and
533
+ # function-local originals (the active remap dispatches variants).
534
+ for _i, _fsite in enumerate(ctx.fixnan_sites):
535
+ if _i in self._dead_fixnan_indices:
536
+ continue
537
+ if _fsite.node is None:
538
+ continue
539
+ if _fsite.member_name not in self._func_fixnan_members:
540
+ self._fixnan_site_map[id(_fsite.node)] = _fsite
541
+ elif id(_fsite.node) not in self._fixnan_site_map:
542
+ self._fixnan_site_map[id(_fsite.node)] = _fsite
389
543
  # Track strategy series vars (e.g., strategy.closedtrades[1])
390
544
  self._strategy_series_vars: set[str] = set()
391
545
  # Track global-scope non-var declarations (emitted as class members)
@@ -424,12 +578,15 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
424
578
  self._security_calls: list[dict] = [self._normalize_security_call(item) for item in ctx.security_calls]
425
579
  # Current function parameter types (set during _emit_func_def)
426
580
  self._current_func_param_types: dict[str, str] = {}
581
+ self._current_func_param_specs: dict[str, "TypeSpec"] = {}
427
582
  # Current function params that are series (const Series<double>&)
428
583
  self._current_func_series_params: set[str] = set()
429
584
  # Locals declared in the function currently being emitted (symbol table loses them after analysis)
430
585
  self._current_func_locals: set[str] = set()
586
+ self._current_func_local_types: dict[str, str] = {}
431
587
  # for-in loop iterator names (must resolve member access, not enum fallback)
432
588
  self._current_loop_vars: set[str] = set()
589
+ self._current_loop_var_specs: dict[str, "TypeSpec"] = {}
433
590
  # Track array variables for codegen
434
591
  self._array_vars: set[str] = set()
435
592
  # Track map variables for codegen
@@ -553,6 +710,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
553
710
  self._all_member_names.add(self._safe_name(name))
554
711
 
555
712
  self._register_global_aggregate_member_types()
713
+ self._register_udt_array_get_ref_locals()
556
714
  self._uses_matrix = self._detect_matrix_usage()
557
715
  # Drawing-objects-as-data: gate all new emission (drawing.hpp include +
558
716
  # the per-type arenas) on this flag so non-drawing strategies stay
@@ -645,7 +803,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
645
803
  ctx = self.ctx
646
804
  stateful = (set(ctx.func_ta_ranges.keys())
647
805
  | set(ctx.func_var_members.keys())
648
- | set(ctx.func_series_vars.keys()))
806
+ | set(ctx.func_series_vars.keys())
807
+ | set(ctx.func_fixnan_indices.keys())
808
+ | set(ctx.func_security_clone_only))
649
809
  if not stateful:
650
810
  return
651
811
 
@@ -661,8 +821,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
661
821
  def var_originals(fname: str) -> list[str]:
662
822
  return [self._safe_name(n) for n, _, _ in ctx.func_var_members.get(fname, [])]
663
823
 
824
+ def fixnan_originals(fname: str) -> list[str]:
825
+ return list(self._func_cs_fixnan_remap.get((fname, 0), {}).keys())
826
+
664
827
  def natural_name(fname: str, cs_idx: int) -> str:
665
- return f"{self._func_safe_name(fname)}_cs{cs_idx}"
828
+ return f"{self._func_cpp_base_name(fname)}_cs{cs_idx}"
666
829
 
667
830
  interned: dict[tuple, dict] = {}
668
831
  worklist: list[dict] = []
@@ -670,7 +833,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
670
833
  fresh_counter = 0
671
834
 
672
835
  # Seed with the natural clones the flat emission loop produces.
673
- for fname in stateful:
836
+ for fname in sorted(stateful):
674
837
  if fname not in func_bodies:
675
838
  continue
676
839
  total_cs = ctx.func_call_site_counts.get(fname, 0)
@@ -681,13 +844,15 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
681
844
  "name": natural_name(fname, k),
682
845
  "ta_remap": self._func_cs_ta_remap.get((fname, k), {}),
683
846
  "var_remap": self._func_cs_var_remap.get((fname, k), {}),
847
+ "fixnan_remap": self._func_cs_fixnan_remap.get((fname, k), {}),
684
848
  })
685
849
  else:
686
850
  worklist.append({
687
851
  "fname": fname,
688
- "name": self._func_safe_name(fname),
852
+ "name": self._func_cpp_base_name(fname),
689
853
  "ta_remap": {},
690
854
  "var_remap": {},
855
+ "fixnan_remap": {},
691
856
  })
692
857
 
693
858
  while worklist:
@@ -699,6 +864,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
699
864
  if not body:
700
865
  continue
701
866
  active_ta = inst["ta_remap"]
867
+ active_fixnan = inst.get("fixnan_remap", {})
702
868
  for callnode in self._iter_func_calls(body):
703
869
  cs_info = ctx.func_call_cs_map.get(id(callnode))
704
870
  if cs_info is None:
@@ -711,26 +877,49 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
711
877
  for m in ta_originals(g_name):
712
878
  mid = natural_ta.get(m, m)
713
879
  composed_ta[m] = active_ta.get(mid, mid)
714
- if composed_ta == natural_ta:
880
+ natural_fixnan = self._func_cs_fixnan_remap.get((g_name, j), {})
881
+ composed_fixnan = {}
882
+ for m in fixnan_originals(g_name):
883
+ mid = natural_fixnan.get(m, m)
884
+ composed_fixnan[m] = active_fixnan.get(mid, mid)
885
+ if composed_ta == natural_ta and composed_fixnan == natural_fixnan:
715
886
  # Path resolves to the callee's own cs{j} clone — reuse it.
716
887
  self._instance_dispatch[(inst["name"], id(callnode))] = \
717
888
  natural_name(g_name, j)
718
889
  continue
719
- key = (g_name, frozenset(composed_ta.items()))
890
+ key = (g_name, frozenset(composed_ta.items()),
891
+ frozenset(composed_fixnan.items()))
720
892
  ginst = interned.get(key)
721
893
  if ginst is None:
722
894
  fresh_counter += 1
723
- inst_name = f"{self._func_safe_name(g_name)}__ni{fresh_counter}"
895
+ inst_name = f"{self._func_cpp_base_name(g_name)}__ni{fresh_counter}"
724
896
  fvar_remap: dict[str, str] = {}
725
897
  for v in var_originals(g_name):
726
898
  fresh_member = f"{v}__ni{fresh_counter}"
727
899
  fvar_remap[v] = fresh_member
728
900
  self._fresh_var_members.append((v, fresh_member))
901
+ # Fresh fixnan members: each path gets its OWN previous-
902
+ # value member so two call paths never share fixnan state.
903
+ ffixnan_remap: dict[str, str] = {}
904
+ for orig_fn_member in fixnan_originals(g_name):
905
+ fresh_fn_member = f"{orig_fn_member}__ni{fresh_counter}"
906
+ ffixnan_remap[orig_fn_member] = fresh_fn_member
907
+ # Find the original FixnanCallSite to carry its type.
908
+ orig_fn_site = None
909
+ for _fs in ctx.fixnan_sites:
910
+ if _fs.member_name == orig_fn_member:
911
+ orig_fn_site = _fs
912
+ break
913
+ if orig_fn_site is not None:
914
+ self._fresh_fixnan_members.append(
915
+ (orig_fn_site, fresh_fn_member)
916
+ )
729
917
  ginst = {
730
918
  "fname": g_name,
731
919
  "name": inst_name,
732
920
  "ta_remap": composed_ta,
733
921
  "var_remap": fvar_remap,
922
+ "fixnan_remap": ffixnan_remap,
734
923
  }
735
924
  interned[key] = ginst
736
925
  self._fresh_instances.append(ginst)
@@ -854,31 +1043,33 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
854
1043
  spec = self._matrix_specs.get(recv_name) or TypeSpec.matrix(TypeSpec.primitive("float"))
855
1044
  self._matrix_specs[name] = spec
856
1045
  self._collection_types[name] = spec
857
- elif ns == "array" and fn in (
858
- "new",
859
- "new_float",
860
- "new_int",
861
- "new_bool",
862
- "new_string",
863
- "from",
864
- ):
1046
+ elif ns == "array" and fn in ({"new", "from"} | set(ARRAY_NEW_CTORS)):
865
1047
  self._array_vars.add(name)
1048
+ spec = self._type_spec_from_expr(expr) or self._array_spec_for_name(name)
1049
+ self._collection_types[name] = spec
866
1050
  elif ns == "map" and fn == "new":
867
1051
  self._map_vars.add(name)
868
1052
 
869
- # Also register var/varip matrix members from AST nodes so that
870
- # the typed-matrix gate checks see the correct element spec.
1053
+ # Also register var/varip aggregate members from AST nodes so that
1054
+ # class-member declarations see the precise collection type before
1055
+ # on_bar emits the initializer. This is required for unannotated
1056
+ # drawing arrays such as ``var boxes = array.new_box()``.
871
1057
  var_decl_map: dict[str, FuncCall] = {}
872
1058
  for stmt in (self.ctx.ast.body if hasattr(self.ctx, "ast") else []):
873
1059
  if isinstance(stmt, VarDecl) and isinstance(stmt.value, FuncCall):
874
1060
  var_decl_map[stmt.name] = stmt.value
875
1061
  for name, _ptype, _init_str in self.ctx.var_members:
876
- if name in self._matrix_specs:
877
- continue
878
1062
  expr = var_decl_map.get(name)
879
1063
  if expr is None:
880
1064
  continue
881
1065
  fn2, ns2 = self._resolve_callee(expr.callee)
1066
+ if ns2 == "array" and fn2 in ({"new", "from"} | set(ARRAY_NEW_CTORS)):
1067
+ self._array_vars.add(name)
1068
+ spec2 = self._type_spec_from_expr(expr) or self._array_spec_for_name(name)
1069
+ self._collection_types[name] = spec2
1070
+ continue
1071
+ if name in self._matrix_specs:
1072
+ continue
882
1073
  if ns2 == "matrix" and fn2 == "new":
883
1074
  targs2 = self._template_args_from_call(expr) if hasattr(expr, "annotations") else []
884
1075
  elem_spec2 = self._type_spec_from_hint_name(targs2[0]) if targs2 else TypeSpec.primitive("float")
@@ -901,6 +1092,68 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
901
1092
  self._matrix_specs[name] = spec2
902
1093
  self._collection_types[name] = spec2
903
1094
 
1095
+ def _walk_global_scope_with_loopflag(self, stmts, in_loop):
1096
+ """Yield ``(stmt, in_loop)`` for every statement in global scope,
1097
+ recursing into control-flow bodies (if/for/while/switch) but NOT into
1098
+ nested function definitions — a function-local of the same name lives in
1099
+ a separate scope and must not be attributed to a global member. The
1100
+ ``in_loop`` flag is True once inside any for/while loop body."""
1101
+ for s in stmts:
1102
+ if isinstance(s, FuncDef):
1103
+ continue
1104
+ yield s, in_loop
1105
+ child_in_loop = in_loop or isinstance(s, (ForStmt, ForInStmt, WhileStmt))
1106
+ for attr in ("body", "else_body", "default_body"):
1107
+ child = getattr(s, attr, None)
1108
+ if isinstance(child, list):
1109
+ yield from self._walk_global_scope_with_loopflag(child, child_in_loop)
1110
+ cases = getattr(s, "cases", None)
1111
+ if isinstance(cases, list):
1112
+ for _case_expr, case_stmts in cases:
1113
+ if isinstance(case_stmts, list):
1114
+ yield from self._walk_global_scope_with_loopflag(case_stmts, child_in_loop)
1115
+
1116
+ def _register_udt_array_get_ref_locals(self) -> None:
1117
+ """Detect global-scope UDT loop-locals that alias a UDT array element and
1118
+ are later field-mutated (Pine array elements of a user-defined type are
1119
+ references — ``z = arr.get(i)`` then ``z.f := v`` MUST write back into
1120
+ ``arr``).
1121
+
1122
+ A non-``var`` global-scope ``UDT z = arr.get(i)`` (or ``.first`` /
1123
+ ``.last``) nested inside a for/while loop mis-lowers to a value copy: a
1124
+ global ``while`` loop hoists ``z`` to a class member whose in-loop init
1125
+ becomes a value-copy assignment, and a global ``for`` loop keeps ``z`` a
1126
+ true local but the function-local alias path (``_udt_local_alias_kind``)
1127
+ no-ops at global scope (``_current_func_body`` is None) — both silently
1128
+ drop the field mutation. We record exactly this shape so the (possible)
1129
+ class member is suppressed and the in-loop VarDecl is emitted as a fresh
1130
+ per-iteration ``UDT& z = arr[i];`` reference instead (the same alias form
1131
+ the non-hoisted function-local path already produces). Strictly gated:
1132
+ the RHS must be a UDT-array-element lvalue AND the name must be field-
1133
+ mutated at global scope AND the declaration must be loop-nested. A
1134
+ read-only get-local is never recorded, so its value-copy output is
1135
+ unchanged. Function-local get-locals are excluded (the walker skips
1136
+ function bodies) — those keep using the existing alias path."""
1137
+ pairs = list(self._walk_global_scope_with_loopflag(self.ctx.ast.body, False))
1138
+ field_mutated: set[str] = set()
1139
+ for s, _in_loop in pairs:
1140
+ if (isinstance(s, Assignment)
1141
+ and isinstance(s.target, MemberAccess)
1142
+ and isinstance(s.target.object, Identifier)):
1143
+ field_mutated.add(s.target.object.name)
1144
+ for s, in_loop in pairs:
1145
+ if not isinstance(s, VarDecl) or s.is_var or s.is_varip:
1146
+ continue
1147
+ if not in_loop:
1148
+ continue
1149
+ if not isinstance(s.value, FuncCall):
1150
+ continue
1151
+ if self._is_udt_lvalue(s.value) is None:
1152
+ continue
1153
+ if s.name not in field_mutated:
1154
+ continue
1155
+ self._udt_array_get_ref_locals.add(s.name)
1156
+
904
1157
  def _extract_receiver_name(self, call_node) -> str | None:
905
1158
  """Extract receiver Identifier name from m.method(...) or matrix.method(m, ...).
906
1159
 
@@ -1012,6 +1265,134 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1012
1265
  for stmt in self.ctx.ast.body:
1013
1266
  if isinstance(stmt, VarDecl) and stmt.name not in reassigned:
1014
1267
  self._collect_known_var(stmt)
1268
+ # A second pass handles the stable-reassigned-scalar pattern: a
1269
+ # class-scope scalar initialized from a stable expr and reassigned
1270
+ # ONLY inside top-level if/elif chains whose conditions and assigned
1271
+ # values are themselves stable (inputs / timeframe.* / math.*). Such
1272
+ # a var is a bar-invariant scalar and may feed a TA ctor length with
1273
+ # a runtime reset that reproduces the conditional logic. Series-
1274
+ # dependent reassignments are left untracked (rejected by the guard).
1275
+ self._collect_reassigned_stable_scalars(reassigned)
1276
+
1277
+ def _collect_reassigned_stable_scalars(self, reassigned: set[str]) -> None:
1278
+ """Track class-scope scalars that are reassigned but only along stable
1279
+ if/elif paths (see ``test_stable_reassigned_class_scope_length``).
1280
+
1281
+ For each top-level ``v = <init>`` whose name is reassigned, build the
1282
+ final value as a nested ternary by folding subsequent top-level
1283
+ IfStmts / direct Assignments. If every condition and every assigned
1284
+ RHS is stable (and renderable), record the ternary in
1285
+ ``_derived_input_expr`` and add ``v`` to ``_stable_runtime_vars`` so
1286
+ the TA ctor reset path can expand it. Anything non-stable (a ta.*
1287
+ result, a bar field, a series var) leaves the var untracked, so the
1288
+ ctor guard still rejects it loudly.
1289
+ """
1290
+ from ..ast_nodes import IfStmt, Assignment
1291
+ body = self.ctx.ast.body or []
1292
+ # Pre-resolve each reassigned var's initial VarDecl.
1293
+ inits: dict[str, object] = {}
1294
+ for stmt in body:
1295
+ if (isinstance(stmt, VarDecl) and stmt.name in reassigned
1296
+ and not stmt.is_var and not stmt.is_varip):
1297
+ # Only consider vars whose initial value is itself stable;
1298
+ # an unstable init cannot become a stable scalar via later
1299
+ # reassignment.
1300
+ if stmt.value is not None and self._expr_is_stable(stmt.value):
1301
+ inits[stmt.name] = stmt.value
1302
+ if not inits:
1303
+ return
1304
+
1305
+ def _value_after(stmts, fallback: str | None) -> str | None:
1306
+ """Fold a statement list into the final value expression for the
1307
+ target var, given ``fallback`` as the value on entry. Returns None
1308
+ if any condition / assignment is non-stable or unrenderable."""
1309
+ current = fallback
1310
+ for s in stmts or []:
1311
+ if isinstance(s, Assignment) and isinstance(s.target, Identifier):
1312
+ if s.target.name != target_name:
1313
+ continue
1314
+ if s.op != ":=":
1315
+ return None # compound assignment — not a stable fold
1316
+ if not self._expr_is_stable(s.value):
1317
+ return None
1318
+ rhs = self._arith_expr_to_str(s.value)
1319
+ if rhs is None:
1320
+ return None
1321
+ current = rhs
1322
+ elif isinstance(s, IfStmt):
1323
+ # Only model IfStmts that actually reassign the target var;
1324
+ # an unrelated IfStmt (e.g. entry/exit logic with a series
1325
+ # condition) must NOT abort the fold — the var simply keeps
1326
+ # its current value through it.
1327
+ if not _reassigns(s, target_name):
1328
+ continue
1329
+ if not self._expr_is_stable(s.condition):
1330
+ return None
1331
+ cond = self._arith_expr_to_str(s.condition)
1332
+ if cond is None:
1333
+ return None
1334
+ then_val = _value_after(s.body, current)
1335
+ if then_val is None:
1336
+ return None
1337
+ else_val = _value_after(s.else_body, current)
1338
+ if else_val is None:
1339
+ return None
1340
+ current = f"({cond} ? {then_val} : {else_val})"
1341
+ # Other statement shapes (for/while/switch/var decls of
1342
+ # other vars) are ignored for this var's value fold; they
1343
+ # do not reassign ``target_name`` in a way we model.
1344
+ return current
1345
+
1346
+ def _reassigns(node, name: str) -> bool:
1347
+ """True if any ``:=`` assignment to ``name`` occurs within node."""
1348
+ from ..ast_nodes import IfStmt as _If, Assignment as _Asg
1349
+ if isinstance(node, _Asg) and isinstance(node.target, Identifier):
1350
+ return node.target.name == name
1351
+ if isinstance(node, _If):
1352
+ if any(_reassigns(c, name) for c in (node.body or [])):
1353
+ return True
1354
+ if any(_reassigns(c, name) for c in (node.else_body or [])):
1355
+ return True
1356
+ return False
1357
+ for attr in ("body", "else_body", "cases", "default_body"):
1358
+ sub = getattr(node, attr, None)
1359
+ if isinstance(sub, list):
1360
+ if any(_reassigns(c, name) for c in sub):
1361
+ return True
1362
+ return False
1363
+
1364
+ for target_name, init_node in inits.items():
1365
+ init_str = self._arith_expr_to_str(init_node)
1366
+ if init_str is None:
1367
+ continue
1368
+ final = _value_after(body, init_str)
1369
+ if final is None:
1370
+ continue
1371
+ # Sanity: the fold must actually differ from the bare init,
1372
+ # otherwise there were no stable reassignments and the var is
1373
+ # already covered (or rejected) by the main pass.
1374
+ if final == init_str:
1375
+ continue
1376
+ # Fold to a compile-time literal when possible (so the ctor-init
1377
+ # list can use it directly); otherwise record the raw expression
1378
+ # for the runtime reset path to expand.
1379
+ folded = self._resolve_known(final)
1380
+ if self._is_compile_time_value(folded):
1381
+ try:
1382
+ num = float(folded)
1383
+ self._known_vars[target_name] = (
1384
+ int(num) if num == int(num) else num
1385
+ )
1386
+ except ValueError:
1387
+ pass
1388
+ self._derived_input_expr[target_name] = final
1389
+ self._stable_runtime_vars.add(target_name)
1390
+ # Mark input-backed iff the expression references an input so the
1391
+ # override-aware get_input_*() reads are emitted on the reset path.
1392
+ import re as _re
1393
+ toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", final))
1394
+ if any(t in self._input_backed_vars for t in toks):
1395
+ self._input_backed_vars.add(target_name)
1015
1396
 
1016
1397
  def _find_reassigned_vars(self) -> set[str]:
1017
1398
  """Scan AST to find all variable names that are targets of := or compound assignment."""
@@ -1038,11 +1419,139 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1038
1419
  walk(stmt)
1039
1420
  return reassigned
1040
1421
 
1422
+ # ``math.*`` members that are pure functions over their (stable) args, or
1423
+ # stable constants. Anything outside this set (e.g. ``math.random``) is
1424
+ # treated as non-stable. Used by ``_expr_is_stable``.
1425
+ _MATH_STABLE_MEMBERS: frozenset[str] = frozenset({
1426
+ "pi", "e", "phi", "rphi",
1427
+ "abs", "max", "min", "round", "floor", "ceil",
1428
+ "sqrt", "log", "log10", "exp", "pow",
1429
+ "sin", "cos", "tan", "asin", "acos", "atan", "sign",
1430
+ "sum", "avg", "to_precision", "round_to_mintick",
1431
+ })
1432
+
1433
+ # ``timeframe.*`` members that are constant for the lifetime of a run —
1434
+ # they reflect the script's resolution, not a per-bar value.
1435
+ _TF_STABLE_MEMBERS: frozenset[str] = frozenset({
1436
+ "period", "main_period", "multiplier",
1437
+ "isintraday", "isminutes", "isdaily", "isweekly",
1438
+ "ismonthly", "isdwm", "isseconds", "in_seconds", "isticks",
1439
+ })
1440
+
1441
+ def _expr_is_stable(self, node) -> bool:
1442
+ """True iff ``node``'s value is a bar-invariant scalar.
1443
+
1444
+ A stable expression depends only on: literals, ``input.*`` values,
1445
+ previously-tracked stable runtime vars, known compile-time consts,
1446
+ ``timeframe.*`` members (constant per run), ``syminfo.*`` (constant
1447
+ per instrument), and ``math.*`` functions/consts over stable
1448
+ sub-expressions, combined with arithmetic / comparison / logical
1449
+ ops, ternaries, and the ``int/float/bool/string`` casts.
1450
+
1451
+ Returns False (i.e. "series") for any node that references a per-bar
1452
+ value: bar fields (close/open/...), series vars, history subscripts,
1453
+ ``ta.*`` results, strategy.* state, or any unrecognised construct.
1454
+ The conservative False keeps the TA-ctor guard intact for genuinely
1455
+ dynamic lengths.
1456
+ """
1457
+ if node is None:
1458
+ return False
1459
+ if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral)):
1460
+ return True
1461
+ if isinstance(node, NaLiteral):
1462
+ return True
1463
+ if isinstance(node, Identifier):
1464
+ name = node.name
1465
+ if name in self._known_vars:
1466
+ return True
1467
+ if name in self._stable_runtime_vars:
1468
+ return True
1469
+ if name in self._input_backed_vars:
1470
+ return True
1471
+ if name in self.ctx.series_vars:
1472
+ return False
1473
+ if name in self._var_names:
1474
+ # var/varip persistent state — mutable across bars.
1475
+ return False
1476
+ if name in BAR_FIELDS or name in BAR_BUILTINS:
1477
+ return False
1478
+ # Unrecognised bare identifier: be conservative so we never
1479
+ # silently allow an undeclared / dynamic length through.
1480
+ return False
1481
+ if isinstance(node, MemberAccess):
1482
+ if isinstance(node.object, Identifier):
1483
+ ns = node.object.name
1484
+ if ns == "timeframe":
1485
+ return node.member in self._TF_STABLE_MEMBERS
1486
+ if ns == "math":
1487
+ return node.member in self._MATH_STABLE_MEMBERS
1488
+ if ns == "syminfo":
1489
+ # syminfo.* (mintick, pointvalue, tickerid, ...) is
1490
+ # constant for the run — safe as a stable scalar.
1491
+ return True
1492
+ # bar.* / request.* / any other member access reads per-bar or
1493
+ # dynamic state.
1494
+ return False
1495
+ if isinstance(node, Subscript):
1496
+ # History read (``close[1]``) or indexed access — per-bar.
1497
+ return False
1498
+ if isinstance(node, Ternary):
1499
+ return (self._expr_is_stable(node.condition)
1500
+ and self._expr_is_stable(node.true_val)
1501
+ and self._expr_is_stable(node.false_val))
1502
+ if isinstance(node, BinOp):
1503
+ return self._expr_is_stable(node.left) and self._expr_is_stable(node.right)
1504
+ if isinstance(node, UnaryOp):
1505
+ return self._expr_is_stable(node.operand)
1506
+ if isinstance(node, FuncCall):
1507
+ func_name, namespace = self._resolve_callee(node.callee)
1508
+ if namespace == "ta":
1509
+ return False
1510
+ if namespace == "math":
1511
+ if func_name not in self._MATH_STABLE_MEMBERS:
1512
+ return False
1513
+ return all(self._expr_is_stable(a) for a in node.args)
1514
+ if namespace == "timeframe":
1515
+ # ``timeframe.in_seconds()`` (and any other function-form
1516
+ # timeframe member) is a stable per-run scalar — it reflects
1517
+ # the script's resolution, not a per-bar value.
1518
+ if func_name not in self._TF_STABLE_MEMBERS:
1519
+ return False
1520
+ return all(self._expr_is_stable(a) for a in node.args)
1521
+ if namespace == "input":
1522
+ return True
1523
+ if namespace is None and func_name in ("int", "float", "bool", "string"):
1524
+ return all(self._expr_is_stable(a) for a in node.args)
1525
+ # Any other call (user functions, str.*, array.*, ...) — series
1526
+ # by default; the conservative answer keeps the guard honest.
1527
+ return False
1528
+ return False
1529
+
1530
+ # AST node kinds whose serialized form is self-delimiting (a literal, a
1531
+ # name, a member read, or a ``name(...)`` call). Non-atomic kinds (BinOp /
1532
+ # UnaryOp / Ternary) MUST be parenthesized when they appear as an operand,
1533
+ # otherwise re-parsing the flattened infix string silently reassociates the
1534
+ # tree: Pine grouping ``(a - b) / (c - d)`` degrades to ``a - b / c - d``
1535
+ # under C++ precedence. See ``_runtime_ctor_arg_for_reset`` (the string is
1536
+ # re-parsed and lowered through the expression visitor).
1537
+ _ATOMIC_ARITH_NODES = (NumberLiteral, Identifier, MemberAccess, FuncCall)
1538
+
1539
+ def _arith_operand_to_str(self, node) -> str | None:
1540
+ """Serialize ``node`` for use as an operand: parenthesize it unless its
1541
+ serialized form is already self-delimiting, so grouping survives a
1542
+ round-trip through the parser."""
1543
+ s = self._arith_expr_to_str(node)
1544
+ if s is None:
1545
+ return None
1546
+ if isinstance(node, self._ATOMIC_ARITH_NODES):
1547
+ return s
1548
+ return f"({s})"
1549
+
1041
1550
  def _arith_expr_to_str(self, node) -> str | None:
1042
1551
  """Render a numeric arithmetic-over-identifiers expression to a string
1043
- whose token spelling matches ``_resolve_known`` / ``_runtime_ctor_arg_for_reset``
1044
- (e.g. ``rsiLen * 2 - 1``). Returns None for any node shape we don't fold
1045
- (series subscripts, ternaries, etc.) so the caller leaves the var untracked.
1552
+ that re-parses to the SAME tree (grouping preserved via
1553
+ ``_arith_operand_to_str``). Returns None for any node shape we don't
1554
+ fold (series subscripts, etc.) so the caller leaves the var untracked.
1046
1555
  """
1047
1556
  if isinstance(node, NumberLiteral):
1048
1557
  v = node.value
@@ -1054,16 +1563,23 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1054
1563
  if isinstance(node, MemberAccess) and isinstance(node.object, Identifier):
1055
1564
  return f"{node.object.name}.{node.member}"
1056
1565
  if isinstance(node, BinOp):
1057
- l = self._arith_expr_to_str(node.left)
1058
- r = self._arith_expr_to_str(node.right)
1566
+ l = self._arith_operand_to_str(node.left)
1567
+ r = self._arith_operand_to_str(node.right)
1059
1568
  if l is None or r is None:
1060
1569
  return None
1061
1570
  return f"{l} {node.op} {r}"
1062
1571
  if isinstance(node, UnaryOp):
1063
- o = self._arith_expr_to_str(node.operand)
1572
+ o = self._arith_operand_to_str(node.operand)
1064
1573
  if o is None:
1065
1574
  return None
1066
1575
  return f"{node.op}{o}"
1576
+ if isinstance(node, Ternary):
1577
+ c = self._arith_operand_to_str(node.condition)
1578
+ t = self._arith_operand_to_str(node.true_val)
1579
+ f = self._arith_operand_to_str(node.false_val)
1580
+ if c is None or t is None or f is None:
1581
+ return None
1582
+ return f"{c} ? {t} : {f}"
1067
1583
  if isinstance(node, FuncCall):
1068
1584
  callee = self._arith_expr_to_str(node.callee)
1069
1585
  if callee is None:
@@ -1129,36 +1645,56 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1129
1645
  if stored:
1130
1646
  self._input_backed_vars.add(node.name)
1131
1647
  self._input_var_to_call[node.name] = node.value
1132
- # Class-scope arithmetic over known/input-backed vars
1133
- # (``wilderLen = rsiLen * 2 - 1``, ``n = math.round(len / 2)``).
1648
+ # Class-scope arithmetic / ternaries / casts over known, input-backed,
1649
+ # timeframe.*, or math.* operands
1650
+ # (``wilderLen = rsiLen * 2 - 1``, ``fastPeriod = isM5 ? ... : ...``,
1651
+ # ``filterLen = math.max(1, int(math.round(2 / a)))``).
1134
1652
  # Without this branch the derived name is untracked, the TA ctor arg
1135
1653
  # never folds, and the runtime-reset path silently degenerates to a
1136
- # period of 1. We (a) fold to a literal for the ctor-init list and
1137
- # (b) record the raw expression so the reset path can re-expand any
1138
- # input-backed operand to its get_input_*() runtime read.
1139
- elif isinstance(node.value, (BinOp, UnaryOp, FuncCall)):
1654
+ # period of 1. We (a) fold to a literal for the ctor-init list when
1655
+ # possible and (b) record the raw expression so the reset path can
1656
+ # re-expand any input-backed operand to its get_input_*() runtime read
1657
+ # and render timeframe.* / math.* fragments to valid C++.
1658
+ #
1659
+ # The ``_expr_is_stable`` gate is what separates a faithful stable
1660
+ # scalar (inputs + constants + timeframe + math) from a series-derived
1661
+ # value: a length that depends on a ta.* result, a history subscript,
1662
+ # or a bar field stays untracked and is therefore rejected by the TA
1663
+ # ctor guard — preserving the guardrail for genuine dynamic lengths.
1664
+ elif isinstance(node.value, (BinOp, UnaryOp, FuncCall, Ternary)):
1140
1665
  expr_str = self._arith_expr_to_str(node.value)
1141
- if expr_str is not None:
1666
+ if expr_str is not None and self._expr_is_stable(node.value):
1142
1667
  import re as _re
1143
1668
  tokens = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", expr_str))
1144
- refs_known = any(t in self._known_vars for t in tokens)
1145
1669
  refs_input = any(t in self._input_backed_vars for t in tokens)
1146
1670
  refs_derived = any(t in self._derived_input_expr for t in tokens)
1147
- if refs_known or refs_input or refs_derived:
1148
- folded = self._resolve_known(expr_str)
1149
- if self._is_compile_time_value(folded):
1150
- try:
1151
- num = float(folded)
1152
- self._known_vars[node.name] = (
1153
- int(num) if num == int(num) else num
1154
- )
1155
- except ValueError:
1156
- pass
1157
- if refs_input or refs_derived:
1158
- # Track as input-backed so use-sites are not inlined and
1159
- # the runtime-reset path emits the override-aware expr.
1160
- self._derived_input_expr[node.name] = expr_str
1161
- self._input_backed_vars.add(node.name)
1671
+ # The stability classifier already proved this expression is a
1672
+ # bar-invariant scalar (inputs / constants / timeframe.* /
1673
+ # math.* / syminfo.* only). Track it unconditionally so later
1674
+ # stable exprs (and the TA reset path) can reference / expand
1675
+ # it — e.g. ``pi = math.asin(1) * 2`` feeds ``beta`` feeds
1676
+ # ``alpha`` feeds a function-local ``filterLen``.
1677
+ folded = self._resolve_known(expr_str)
1678
+ if self._is_compile_time_value(folded):
1679
+ try:
1680
+ num = float(folded)
1681
+ self._known_vars[node.name] = (
1682
+ int(num) if num == int(num) else num
1683
+ )
1684
+ except ValueError:
1685
+ pass
1686
+ # Record the raw expression so the runtime-reset path can
1687
+ # re-expand operands. Always record for stable derived exprs
1688
+ # (even pure-math / pure-timeframe ones with no input) so the
1689
+ # reset can render them.
1690
+ self._derived_input_expr[node.name] = expr_str
1691
+ self._stable_runtime_vars.add(node.name)
1692
+ # Mark input-backed so use-sites are not inlined and the
1693
+ # override-aware get_input_*() reads are emitted on the reset
1694
+ # path. Pure-math / pure-timeframe exprs (no input) stay out
1695
+ # of this set, which is fine — they have no override to honor.
1696
+ if refs_input or refs_derived:
1697
+ self._input_backed_vars.add(node.name)
1162
1698
 
1163
1699
  # ------------------------------------------------------------------
1164
1700
  # Public entry point
@@ -1215,17 +1751,172 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1215
1751
  walk(v)
1216
1752
  walk(self.ctx.ast)
1217
1753
 
1754
+ def _func_cpp_base_name(self, fname: str) -> str:
1755
+ """Return the actual emitted C++ base name for a UDF or UDT method."""
1756
+ fi = self._func_info_map.get(fname)
1757
+ if fi is not None and getattr(fi, "is_udt_method", False):
1758
+ return self._emit_udt_method_cpp_name(fi)
1759
+ return self._func_safe_name(fname)
1760
+
1761
+ def _inline_history_contexts_for_owner(self, owner: str | None) -> list[str | None]:
1762
+ """Return every method-emission context that owns one source AST site.
1763
+
1764
+ ``None`` denotes top-level/on_bar or a function emitted exactly once.
1765
+ Stateful UDF clones and fresh nested-helper instances use the same names
1766
+ assigned to ``_current_instance_name`` by ``_emit_func_def`` so lookup
1767
+ while visiting a body is deterministic and cannot collapse call sites.
1768
+ """
1769
+ if owner is None:
1770
+ return [None]
1771
+ if owner in self._dead_func_names:
1772
+ return []
1773
+
1774
+ total_cs = self.ctx.func_call_site_counts.get(owner, 0)
1775
+ cloned = (
1776
+ owner in self.ctx.func_ta_ranges
1777
+ or owner in self.ctx.func_series_vars
1778
+ or owner in self.ctx.func_var_members
1779
+ or owner in self.ctx.func_security_clone_only
1780
+ ) and total_cs > 0
1781
+ if cloned:
1782
+ contexts: list[str | None] = [
1783
+ f"{self._func_cpp_base_name(owner)}_cs{idx}"
1784
+ for idx in range(total_cs)
1785
+ ]
1786
+ else:
1787
+ contexts = [None]
1788
+
1789
+ for inst in self._fresh_instances:
1790
+ if inst["fname"] == owner and inst["name"] not in contexts:
1791
+ contexts.append(inst["name"])
1792
+ return contexts
1793
+
1794
+ def _prepare_inline_history_members(self) -> None:
1795
+ """Pre-register every generated temporary-Series class member.
1796
+
1797
+ Member declarations and the declaration-derived rollback aggregate are
1798
+ emitted before function/on_bar bodies. A source-order AST pass therefore
1799
+ reserves stable names up front. The key includes an emitted UDF context
1800
+ because the same body AST is rendered once per stateful call-site clone.
1801
+ """
1802
+ self._inline_history_members = []
1803
+ self._inline_history_member_by_key = {}
1804
+ counters = {"hist_call": 0, "series_arg": 0}
1805
+
1806
+ def walk_nodes(value):
1807
+ """Yield AST nodes in stable field order, including tuple elements.
1808
+
1809
+ NamingHelper._walk_ast predates several AST containers and is
1810
+ intentionally a best-effort utility. Member pre-registration must
1811
+ be exhaustive because a missed node becomes an undeclared C++
1812
+ member, so use the dataclass field graph directly here.
1813
+ """
1814
+ if isinstance(value, ASTNode):
1815
+ yield value
1816
+ for child in vars(value).values():
1817
+ yield from walk_nodes(child)
1818
+ return
1819
+ if isinstance(value, (list, tuple)):
1820
+ for child in value:
1821
+ yield from walk_nodes(child)
1822
+ return
1823
+ if isinstance(value, dict):
1824
+ for child in value.values():
1825
+ yield from walk_nodes(child)
1826
+ return
1827
+ if isinstance(value, TypeField) and value.default is not None:
1828
+ yield from walk_nodes(value.default)
1829
+
1830
+ owner_by_node: dict[int, str] = {}
1831
+ for fi in self.ctx.func_infos:
1832
+ if fi.node is None:
1833
+ continue
1834
+ for child in walk_nodes(fi.node):
1835
+ owner_by_node[id(child)] = fi.name
1836
+
1837
+ def register(kind: str, source_key: tuple, cpp_type: str,
1838
+ owner: str | None) -> None:
1839
+ if cpp_type not in ("double", "int", "bool"):
1840
+ cpp_type = "double"
1841
+ for context in self._inline_history_contexts_for_owner(owner):
1842
+ key = (kind, *source_key, context)
1843
+ if key in self._inline_history_member_by_key:
1844
+ continue
1845
+ counters[kind] += 1
1846
+ member_name = f"_{kind}_{counters[kind]}"
1847
+ self._inline_history_member_by_key[key] = member_name
1848
+ self._inline_history_members.append({
1849
+ "kind": kind,
1850
+ "member_name": member_name,
1851
+ "cpp_type": cpp_type,
1852
+ "context": context,
1853
+ })
1854
+
1855
+ def actual_args_for(call: FuncCall, params: list[str]) -> list:
1856
+ if call.kwargs:
1857
+ return _merge_kwargs(call.args, call.kwargs, params, lambda arg: arg)
1858
+ return list(call.args)
1859
+
1860
+ for node in walk_nodes(self.ctx.ast):
1861
+ owner = owner_by_node.get(id(node))
1862
+ if isinstance(node, Subscript) and isinstance(node.object, FuncCall):
1863
+ register(
1864
+ "hist_call", (id(node),), self._infer_type(node.object), owner
1865
+ )
1866
+
1867
+ if not isinstance(node, FuncCall):
1868
+ continue
1869
+ func_name, _ = self._resolve_callee(node.callee)
1870
+ fi = self._func_info_map.get(func_name)
1871
+ if fi is None or fi.node is None:
1872
+ continue
1873
+ func_sv = self.ctx.func_series_vars.get(fi.name, set())
1874
+ series_param_indices = {
1875
+ idx for idx, name in enumerate(fi.node.params) if name in func_sv
1876
+ }
1877
+ if not series_param_indices:
1878
+ continue
1879
+ args = actual_args_for(node, list(fi.node.params))
1880
+ for idx, arg in enumerate(args):
1881
+ if idx not in series_param_indices:
1882
+ continue
1883
+ if isinstance(arg, Identifier):
1884
+ if arg.name in BAR_FIELDS or arg.name in BAR_SERIES_PUSH:
1885
+ continue
1886
+ if arg.name in self.ctx.series_vars:
1887
+ continue
1888
+ register(
1889
+ "series_arg", (id(node), idx), self._infer_type(arg), owner
1890
+ )
1891
+
1892
+ def _inline_history_member(self, kind: str, node: ASTNode,
1893
+ arg_idx: int | None = None) -> str:
1894
+ source_key = (id(node),) if arg_idx is None else (id(node), arg_idx)
1895
+ key = (kind, *source_key, self._current_instance_name)
1896
+ member = self._inline_history_member_by_key.get(key)
1897
+ if member is None:
1898
+ raise AssertionError(
1899
+ "missing pre-registered inline history member for "
1900
+ f"{kind} at {getattr(node, 'loc', None)} in context "
1901
+ f"{self._current_instance_name!r}"
1902
+ )
1903
+ return member
1904
+
1218
1905
  def generate(self) -> str:
1219
1906
  """Generate C++ source from the AnalyzerContext."""
1220
1907
  # Context-sensitive instance pre-pass (needs the naming helpers populated
1221
1908
  # in __init__). Computes nested stateful-helper dispatch + fresh instances.
1222
1909
  self._build_func_instances()
1910
+ self._prepare_inline_history_members()
1223
1911
  # Pre-scan for strategy series vars
1224
1912
  self._prescan_strategy_series()
1225
1913
  self._security_ohlc_hist_fields_by_sec: dict[int, set[str]] = {}
1226
1914
  # request.security TA call-sites read at a history offset (``ta.ema(...)[k>=1]``).
1227
1915
  # Maps sec_id -> set of TA call-site indices needing an HTF history Series.
1228
1916
  self._security_ta_hist_idx_by_sec: dict[int, set[int]] = {}
1917
+ # request.security helper-call results read at a history offset
1918
+ # (``myHelper()[k]``). Maps (sec_id, node-id) -> backing Series metadata.
1919
+ self._security_expr_hist_by_node: dict[tuple[int, int], dict] = {}
1229
1920
 
1230
1921
  lines: list[str] = []
1231
1922
 
@@ -1290,6 +1981,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1290
1981
  # 2. Open class
1291
1982
  lines.append("class GeneratedStrategy : public BacktestEngine {")
1292
1983
  lines.append("public:")
1984
+ _script_state_decl_start = len(lines)
1293
1985
 
1294
1986
  # request.security state
1295
1987
  for item in self._security_calls:
@@ -1318,19 +2010,26 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1318
2010
  for field in sorted(
1319
2011
  self._security_ohlc_hist_fields_by_sec.get(sec_id, ())
1320
2012
  ):
2013
+ ctype = self._security_bar_hist_type(field)
1321
2014
  lines.append(
1322
- f" Series<double> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
2015
+ f" Series<{ctype}> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
1323
2016
  )
1324
2017
  self._security_ta_hist_idx_by_sec[sec_id] = (
1325
2018
  self._collect_security_ta_hist_indices(expr_node)
1326
2019
  )
1327
2020
  for name in self._security_ta_hist_series_names(sec_id):
1328
2021
  lines.append(f" Series<double> {name}{_mbb};")
2022
+ self._emit_security_expr_hist_members(sec_id, expr_node, lines, _mbb)
1329
2023
  continue
1330
2024
  if returns_tuple and tuple_size and tuple_size > 0 and isinstance(expr_node, TupleLiteral):
1331
2025
  hist_fields: set[str] = set()
1332
2026
  for el in expr_node.elements:
1333
2027
  hist_fields |= self._collect_security_ohlc_hist_fields(el)
2028
+ for name in item.get("mutable_globals", []) or []:
2029
+ info = self._global_mutable_infos.get(name)
2030
+ if info is not None:
2031
+ for stmt in getattr(info, "source_stmts", []) or []:
2032
+ hist_fields |= self._collect_security_ohlc_hist_fields(stmt)
1334
2033
  self._security_ohlc_hist_fields_by_sec[sec_id] = hist_fields
1335
2034
  for i, el in enumerate(expr_node.elements):
1336
2035
  ctype = self._infer_cpp_type_for_security_elem(el)
@@ -1338,20 +2037,31 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1338
2037
  lines.append(f" {ctype} _req_sec_{sec_id}_{i}{{}};")
1339
2038
  else:
1340
2039
  lines.append(f" {ctype} _req_sec_{sec_id}_{i} = na<double>();")
2040
+ elif returns_tuple and tuple_size and tuple_size > 0:
2041
+ self._security_ohlc_hist_fields_by_sec[sec_id] = (
2042
+ self._collect_security_ohlc_hist_fields_for_call(item)
2043
+ )
2044
+ site = self._get_ta_site(expr_node)
2045
+ ta_name = self._ta_name_from_site(site) if site is not None else ""
2046
+ ctype = TA_TUPLE_RESULT_TYPES.get(ta_name, "std::tuple<double, double>")
2047
+ default = self._security_tuple_result_default(ctype, tuple_size)
2048
+ lines.append(f" {ctype} _req_sec_{sec_id} = {default};")
1341
2049
  else:
1342
- self._security_ohlc_hist_fields_by_sec[sec_id] = self._collect_security_ohlc_hist_fields(
1343
- expr_node
2050
+ self._security_ohlc_hist_fields_by_sec[sec_id] = (
2051
+ self._collect_security_ohlc_hist_fields_for_call(item)
1344
2052
  )
1345
2053
  lines.append(f" double _req_sec_{sec_id} = na<double>();")
1346
2054
  for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
2055
+ ctype = self._security_bar_hist_type(field)
1347
2056
  lines.append(
1348
- f" Series<double> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
2057
+ f" Series<{ctype}> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
1349
2058
  )
1350
2059
  self._security_ta_hist_idx_by_sec[sec_id] = (
1351
2060
  self._collect_security_ta_hist_indices(expr_node)
1352
2061
  )
1353
2062
  for name in self._security_ta_hist_series_names(sec_id):
1354
2063
  lines.append(f" Series<double> {name}{_mbb};")
2064
+ self._emit_security_expr_hist_members(sec_id, expr_node, lines, _mbb)
1355
2065
 
1356
2066
  if self._security_calls:
1357
2067
  lines.append(' std::unordered_map<std::string, Series<double>> _security_helper_series_;')
@@ -1375,9 +2085,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1375
2085
  )
1376
2086
 
1377
2087
  # 3. TA members
1378
- for site in self.ctx.ta_call_sites:
2088
+ for _ta_idx, site in enumerate(self.ctx.ta_call_sites):
2089
+ if _ta_idx in self._dead_ta_indices:
2090
+ continue
1379
2091
  lines.append(f" {site.class_name} {site.member_name};")
1380
- if getattr(site, "is_static", False):
2092
+ if self._ta_site_uses_precalc(site):
1381
2093
  vtype = self._ta_return_type(site)
1382
2094
  lines.append(f" std::vector<{vtype}> _precalc_{site.member_name};")
1383
2095
  lines.append(" bool _use_precalc = false;")
@@ -1485,7 +2197,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1485
2197
  lines.append(f" Series<{cpp_type}> {safe}{_mbb};")
1486
2198
 
1487
2199
  # 7. Fixnan members
1488
- for site in self.ctx.fixnan_sites:
2200
+ for _fi_idx, site in enumerate(self.ctx.fixnan_sites):
2201
+ if _fi_idx in self._dead_fixnan_indices:
2202
+ continue
1489
2203
  cpp_type = PINE_TYPE_TO_CPP.get(site.pine_type, "double")
1490
2204
  lines.append(f" {cpp_type} {site.member_name} = na<{cpp_type}>();")
1491
2205
 
@@ -1499,12 +2213,26 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1499
2213
  else:
1500
2214
  lines.append(f" Series<double> {svar}{_mbb};")
1501
2215
 
2216
+ # 8a. Synthetic temporary history. Unlike the legacy function-local
2217
+ # static buffers, these members are value-copyable rollback state and
2218
+ # have one identity per source site / emitted UDF variant.
2219
+ for info in self._inline_history_members:
2220
+ lines.append(
2221
+ f" Series<{info['cpp_type']}> {info['member_name']}{_mbb};"
2222
+ )
2223
+
1502
2224
  # 8b. Global-scope non-var declarations as class members
1503
2225
  # (so user-defined functions can reference them)
1504
2226
  seen_global = set()
1505
2227
  for name, ptype in self.ctx.global_var_decls:
1506
2228
  if name in seen_global or name in self.ctx.series_vars or name in self._var_names:
1507
2229
  continue
2230
+ # De-hoisted UDT array-element alias (Pine reference semantics): the
2231
+ # in-loop VarDecl is emitted as a fresh ``UDT& z = arr[i];`` local
2232
+ # reference each iteration, so there is no persistent class member.
2233
+ if name in self._udt_array_get_ref_locals:
2234
+ seen_global.add(name)
2235
+ continue
1508
2236
  seen_global.add(name)
1509
2237
  safe = self._safe_name(name)
1510
2238
 
@@ -1558,6 +2286,16 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1558
2286
  emitted_clones.add(fresh_safe)
1559
2287
  self._emit_cloned_var_decl(orig_safe, fresh_safe, _mbb, lines)
1560
2288
 
2289
+ # 8c3. Fresh fixnan members for context-sensitive helper instances.
2290
+ # Each fresh instance gets its OWN previous-value member so two
2291
+ # call paths never share fixnan state (mirrors 8c2 for vars).
2292
+ for orig_site, fresh_safe in self._fresh_fixnan_members:
2293
+ if fresh_safe in emitted_clones:
2294
+ continue
2295
+ emitted_clones.add(fresh_safe)
2296
+ cpp_type = PINE_TYPE_TO_CPP.get(orig_site.pine_type, "double")
2297
+ lines.append(f" {cpp_type} {fresh_safe} = na<{cpp_type}>();")
2298
+
1561
2299
  # 8d. Drawing-objects-as-data arenas (gated on _uses_drawing so
1562
2300
  # non-drawing strategies emit byte-identical C++). Each arena is a
1563
2301
  # per-strategy member -> reset-per-run is automatic. Caps come from
@@ -1605,6 +2343,16 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1605
2343
 
1606
2344
  lines.append("")
1607
2345
 
2346
+ # 9d. Historical execution rollback checkpoint. Derive the member
2347
+ # inventory from the declarations above so every future generated
2348
+ # state category is captured automatically (or generation fails loudly
2349
+ # if it introduces an unfamiliar declaration form).
2350
+ _script_state_members = self._collect_script_state_members(
2351
+ lines[_script_state_decl_start:-1]
2352
+ )
2353
+ self._emit_script_state_hooks(lines, _script_state_members)
2354
+ lines.append("")
2355
+
1608
2356
  # 9. Constructor with TA initializer list
1609
2357
  self._emit_constructor(lines)
1610
2358
  lines.append("")
@@ -1612,6 +2360,12 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1612
2360
  # 10. User-defined functions (with per-call-site variants for functions
1613
2361
  # containing TA calls OR series variables that need isolation)
1614
2362
  for fi in self.ctx.func_infos:
2363
+ # Dead-code user functions (defined but never called, with TA
2364
+ # state whose ctor args can't be sized) are skipped entirely —
2365
+ # their bodies reference TA members we no longer emit, and the
2366
+ # functions never run anyway.
2367
+ if fi.name in self._dead_func_names:
2368
+ continue
1615
2369
  total_cs = self.ctx.func_call_site_counts.get(fi.name, 0)
1616
2370
  has_ta = fi.name in self.ctx.func_ta_ranges
1617
2371
  has_series = fi.name in self.ctx.func_series_vars or fi.name in self.ctx.func_var_members
@@ -1762,7 +2516,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1762
2516
  if self._is_chart_point_callee(node.callee):
1763
2517
  return False
1764
2518
  func_name, namespace = self._resolve_callee(node.callee)
1765
- if func_name in SKIP_FUNC_NAMES:
2519
+ if namespace is None and func_name in SKIP_FUNC_NAMES:
1766
2520
  return True
1767
2521
  if namespace in SKIP_NAMESPACES:
1768
2522
  return True
@@ -1810,77 +2564,335 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1810
2564
  # _infer_type / _infer_tuple_types live on TypeInferer — see codegen/types.py.
1811
2565
  # _is_compile_time_value lives on TaSiteHelper — see codegen/ta.py.
1812
2566
 
2567
+ # Pine ``timeframe.<member>`` -> C++ runtime expression. Mirrors the
2568
+ # mapping in ``visit_expr._visit_member_access`` so a stable timeframe
2569
+ # fragment embedded in a TA ctor reset renders to the same C++ the
2570
+ # expression visitor would emit for a direct ``timeframe.*`` read.
2571
+ _TIMEFRAME_MEMBER_CPP: dict[str, str] = {
2572
+ "period": "script_tf_",
2573
+ "main_period": "main_period()",
2574
+ "multiplier": "tf_multiplier(script_tf_)",
2575
+ "isintraday": "tf_is_intraday(script_tf_)",
2576
+ "isminutes": "(tf_is_intraday(script_tf_) && !tf_is_seconds(script_tf_))",
2577
+ "isdaily": "tf_is_daily(script_tf_)",
2578
+ "isweekly": "tf_is_weekly(script_tf_)",
2579
+ "ismonthly": "tf_is_monthly(script_tf_)",
2580
+ "isdwm": "(tf_is_daily(script_tf_) || tf_is_weekly(script_tf_) || tf_is_monthly(script_tf_))",
2581
+ "isseconds": "tf_is_seconds(script_tf_)",
2582
+ "in_seconds": "tf_to_seconds(script_tf_)",
2583
+ "isticks": "false",
2584
+ }
2585
+
2586
+ # Pine ``math.<member>`` -> C++ form. Function members map to ``std::*``;
2587
+ # constants map to their engine-side macro / literal.
2588
+ _MATH_MEMBER_CPP: dict[str, str] = {
2589
+ "pi": "M_PI", "e": "M_E", "phi": "1.618033988749895",
2590
+ "rphi": "0.6180339887498949",
2591
+ "abs": "std::abs", "max": "std::max", "min": "std::min",
2592
+ "round": "std::round", "floor": "std::floor", "ceil": "std::ceil",
2593
+ "sqrt": "std::sqrt", "log": "std::log", "log10": "std::log10",
2594
+ "exp": "std::exp", "pow": "std::pow",
2595
+ "sin": "std::sin", "cos": "std::cos", "tan": "std::tan",
2596
+ "asin": "std::asin", "acos": "std::acos", "atan": "std::atan",
2597
+ "sign": "(double)([] (double _v) { return (_v>0) - (_v<0); })",
2598
+ }
2599
+
2600
+ # Pine logical operators (word form) -> C++ operator, used when rendering
2601
+ # a stable runtime expression. Matched with word boundaries.
2602
+ _PINE_LOGICAL_OPS: dict[str, str] = {"and": "&&", "or": "||", "not": "!"}
2603
+
2604
+ def _render_inline_input_calls(self, expr_str: str) -> tuple[str, bool]:
2605
+ """Render inline ``input(...)`` / ``input.<type>(...)`` calls in a TA
2606
+ ctor-arg expression string to override-aware ``get_input_*()`` reads.
2607
+
2608
+ A bare input expression passed straight as a length argument
2609
+ (``adx(input(15), input(15))``) reaches the reset path as the raw call
2610
+ spelling ``input(15)`` because the analyzer's param-substitution has no
2611
+ intermediate variable to record in ``_input_backed_vars``. This helper
2612
+ finds each such call (balanced parens, ``input`` optionally followed by
2613
+ ``.<type>``), re-parses it into a FuncCall, and renders it via the same
2614
+ ``_render_input_value`` used for ordinary input var reads.
2615
+
2616
+ Returns ``(rewritten_str, found_any)``. When no inline input call is
2617
+ present, the string is returned unchanged with ``found_any=False``.
2618
+ """
2619
+ import re
2620
+ # Locate ``input`` (as a word, not a substring of get_input_int etc.)
2621
+ # optionally followed by ``.<member>``, then a ``(`` opening a balanced
2622
+ # argument list.
2623
+ out = expr_str
2624
+ found = False
2625
+ idx = 0
2626
+ while idx < len(out):
2627
+ m = re.search(r"\binput\b", out[idx:])
2628
+ if m is None:
2629
+ break
2630
+ start = idx + m.start()
2631
+ # Reject a match that is part of a longer identifier
2632
+ # (e.g. ``get_input_int``) — the \b guard above already handles
2633
+ # alphanumerics, but be defensive.
2634
+ if start > 0 and (out[start - 1].isalnum() or out[start - 1] == "_"):
2635
+ idx = start + len("input")
2636
+ continue
2637
+ j = start + len("input")
2638
+ # Optional ``.<member>`` for the typed form ``input.int(...)``.
2639
+ member = None
2640
+ if j < len(out) and out[j] == ".":
2641
+ k = j + 1
2642
+ nm_start = k
2643
+ while k < len(out) and (out[k].isalnum() or out[k] == "_"):
2644
+ k += 1
2645
+ if k > nm_start:
2646
+ member = out[nm_start:k]
2647
+ j = k
2648
+ # Must be followed by ``(`` to be a call.
2649
+ if j >= len(out) or out[j] != "(":
2650
+ idx = j
2651
+ continue
2652
+ # Walk the balanced parens to extract the call substring.
2653
+ depth = 0
2654
+ k = j
2655
+ while k < len(out):
2656
+ ch = out[k]
2657
+ if ch == "(":
2658
+ depth += 1
2659
+ elif ch == ")":
2660
+ depth -= 1
2661
+ if depth == 0:
2662
+ k += 1
2663
+ break
2664
+ k += 1
2665
+ if depth != 0:
2666
+ # Unbalanced — bail on this match.
2667
+ idx = j + 1
2668
+ continue
2669
+ call_src = out[start:k]
2670
+ try:
2671
+ from ..lexer import Lexer
2672
+ from ..parser import Parser
2673
+ tokens = Lexer(call_src).tokenize()
2674
+ node = Parser(tokens, source=call_src)._parse_expression()
2675
+ if not self._is_input_call(node):
2676
+ idx = k
2677
+ continue
2678
+ func_name_i, namespace_i = self._resolve_callee(node.callee)
2679
+ # Inline inputs have no enclosing var; reuse the default
2680
+ # value as a synthetic title key so distinct defaults get
2681
+ # distinct input controls (and identical defaults collapse,
2682
+ # which is correct since they resolve to the same value).
2683
+ default_node = self._get_input_default(node)
2684
+ synth_title = self._visit_expr(default_node) if default_node is not None else ""
2685
+ title = self._get_input_title(node, var_name=None)
2686
+ if not title:
2687
+ title = synth_title
2688
+ rendered = self._render_input_value(node, func_name_i, namespace_i, title)
2689
+ except Exception:
2690
+ idx = k
2691
+ continue
2692
+ out = out[:start] + rendered + out[k:]
2693
+ found = True
2694
+ idx = start + len(rendered)
2695
+ return out, found
2696
+
1813
2697
  def _runtime_ctor_arg_for_reset(self, arg_str: str) -> str | None:
1814
- """Convert a TA ctor-arg string into its runtime C++ expression when
1815
- the source expression references an input-backed variable. Returns the
1816
- runtime expression (e.g. ``get_input_int("MACD Fast", 12)``) when the
1817
- ctor arg depends on an input value; returns None for pure literals or
1818
- expressions that do not contain any input-backed identifier, so the
1819
- caller can decide to skip emitting a reset for that site.
2698
+ """Convert a TA ctor-arg string into its runtime C++ expression.
2699
+
2700
+ Returns the runtime expression (e.g.
2701
+ ``get_input_int("MACD Fast", 12)`` or a ternary / math expression
2702
+ over such reads and ``timeframe.*`` members) when the ctor arg
2703
+ depends on a stable runtime scalar — an input-backed variable, a
2704
+ ``timeframe.*`` member, or arithmetic / ternaries / casts over
2705
+ those. Returns None for pure literals or expressions that contain
2706
+ any unrecognised (potentially series) identifier, so the caller
2707
+ (the TA ctor guard) rejects them loudly instead of silently
2708
+ emitting period 1.
1820
2709
  """
1821
2710
  import re
1822
2711
  ident_re = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
1823
2712
 
1824
- # Expand class-scope derived vars (``wilderLen`` -> ``(rsiLen * 2 - 1)``)
1825
- # to their raw RHS so the input-backed leaves become get_input_*() reads
1826
- # below. Recursive (bounded) to handle chains of derived vars; guards
1827
- # against cycles.
2713
+ # Expand class-scope derived vars (``wilderLen`` -> ``(rsiLen * 2 - 1)``,
2714
+ # ``fastPeriod`` -> ``(isM5 ? ... : ...)``) to their raw RHS so input
2715
+ # leaves become get_input_*() reads below. Recursive (bounded) to
2716
+ # handle chains of derived vars; guards against cycles.
1828
2717
  def _expand_derived(s: str, seen: frozenset = frozenset(), depth: int = 0) -> str:
1829
2718
  if depth > 32:
1830
2719
  return s
1831
- def _rep(m: re.Match) -> str:
1832
- nm = m.group(0)
2720
+ def _rep(p: re.Match) -> str:
2721
+ nm = p.group(0)
1833
2722
  if nm in self._derived_input_expr and nm not in seen:
1834
2723
  inner = self._derived_input_expr[nm]
1835
2724
  return "(" + _expand_derived(inner, seen | {nm}, depth + 1) + ")"
1836
2725
  return nm
1837
2726
  return ident_re.sub(_rep, s)
1838
2727
 
1839
- arg_str = _expand_derived(arg_str)
2728
+ expanded = _expand_derived(arg_str)
2729
+
2730
+ tokens = set(ident_re.findall(expanded))
2731
+
2732
+ # Gate: every identifier token must be renderable. If any token is an
2733
+ # unrecognised bare identifier (not an input, not a known const, not
2734
+ # a structural keyword / namespace prefix, not a stable tracked var
2735
+ # that we already expanded), we conservatively refuse — that identifier
2736
+ # would otherwise leak through as an undeclared C++ symbol, or worse,
2737
+ # a series var that should have been rejected by the ctor guard.
2738
+ structural = (set(self._PINE_LOGICAL_OPS)
2739
+ | {"timeframe", "math", "syminfo",
2740
+ "int", "float", "bool", "string",
2741
+ "true", "false", "na"})
2742
+ member_tokens = (set(self._TIMEFRAME_MEMBER_CPP)
2743
+ | set(self._MATH_MEMBER_CPP))
2744
+ renderable = (self._input_backed_vars
2745
+ | self._stable_runtime_vars
2746
+ | structural
2747
+ | member_tokens)
2748
+ leftover = tokens - renderable
2749
+ # Known compile-time consts that survived expansion (e.g. ``pi`` was
2750
+ # NOT tracked as a Python value but its name token is a stable var
2751
+ # already; pure numeric names are in _known_vars and covered above).
2752
+ leftover = {t for t in leftover if t not in self._known_vars}
2753
+ # Inline ``input(...)`` / ``input.<t>(...)`` calls (a bare input
2754
+ # expression passed straight as a length arg, e.g.
2755
+ # ``adx(input(15), input(15))``) are re-parsed and rendered below,
2756
+ # after the gate. ``input`` is the only token they contribute, so
2757
+ # allow it through the gate here.
2758
+ leftover.discard("input")
2759
+ if leftover:
2760
+ return None
1840
2761
 
1841
- tokens = ident_re.findall(arg_str)
1842
- input_tokens = [t for t in tokens if t in self._input_backed_vars]
1843
- if not input_tokens:
2762
+ # Must depend on at least one runtime component (input-backed var, a
2763
+ # timeframe reference, or an inline input() call); otherwise it's a
2764
+ # pure compile-time expr and no reset is needed (the ctor-init literal
2765
+ # is correct).
2766
+ has_input = any(t in self._input_backed_vars for t in tokens)
2767
+ has_timeframe = "timeframe" in tokens
2768
+ has_inline_input = "input" in tokens
2769
+ if not (has_input or has_timeframe or has_inline_input):
1844
2770
  return None
1845
2771
 
1846
- # Pine math.* → C++ std::* (must run before identifier substitution so
1847
- # we don't treat `math.round` etc. as a bare identifier). We wrap the
1848
- # whole expression in (int) below because TA ctors want integer lengths.
1849
- expr = arg_str
1850
- math_map = {
1851
- "math.round": "std::round",
1852
- "math.sqrt": "std::sqrt",
1853
- "math.ceil": "std::ceil",
1854
- "math.floor": "std::floor",
1855
- "math.abs": "std::abs",
1856
- "math.max": "std::max",
1857
- "math.min": "std::min",
1858
- "math.log": "std::log",
1859
- "math.exp": "std::exp",
1860
- "math.pow": "std::pow",
1861
- }
1862
- for pine_fn, cpp_fn in math_map.items():
1863
- expr = expr.replace(pine_fn, cpp_fn)
1864
-
1865
- def _sub(match: re.Match) -> str:
1866
- name = match.group(0)
1867
- if name not in self._input_backed_vars:
1868
- return name
1869
- call_node = self._input_var_to_call.get(name)
1870
- if call_node is None:
1871
- return name
1872
- func_name_i, namespace_i = self._resolve_callee(call_node.callee)
1873
- title = self._get_input_title(call_node, var_name=name)
1874
- return self._render_input_value(call_node, func_name_i, namespace_i, title)
2772
+ # Preferred path: re-parse the (gate-approved, grouping-faithful)
2773
+ # expression and lower it through the SAME expression visitor the
2774
+ # statement path uses. Operator grouping and Pine numeric typing
2775
+ # (``/`` always yields float; ``(double)`` coercion) are then correct
2776
+ # by construction — reused from ``_visit_binop``, not re-derived here.
2777
+ # Falls back to the legacy token-substitution renderer below only if
2778
+ # re-parse / lowering unexpectedly fails, so a working site is never
2779
+ # worse off than before this change.
2780
+ lowered = self._lower_reset_expr_via_visitor(expanded)
2781
+ if lowered is not None:
2782
+ return lowered
2783
+
2784
+ # Render inline input() calls to override-aware get_input_*() reads
2785
+ # now that the gate has accepted the expression. Done after the gate
2786
+ # so the rendered getter tokens (get_input_int, ...) do not have to
2787
+ # be added to ``renderable``.
2788
+ if has_inline_input:
2789
+ expanded, _ = self._render_inline_input_calls(expanded)
2790
+
2791
+ expr = expanded
2792
+
2793
+ # 1) timeframe.<member> -> C++ (before ident substitution so the
2794
+ # member names don't get caught by the identifier pass). Use a
2795
+ # targeted regex so e.g. ``isminutes`` is not confused with
2796
+ # ``ismonthly``.
2797
+ def _tf_rep(p: re.Match) -> str:
2798
+ mem = p.group(1)
2799
+ return self._TIMEFRAME_MEMBER_CPP.get(mem, p.group(0))
2800
+ # ``timeframe.in_seconds()`` is a function-form member in Pine (the
2801
+ # only one in the table); its C++ form ``tf_to_seconds(script_tf_)``
2802
+ # is already a complete call, so consume the Pine ``()`` to avoid a
2803
+ # double-call ``tf_to_seconds(script_tf_)()``. Property-form members
2804
+ # (``timeframe.isdaily``) never carry ``()`` so the optional group is
2805
+ # a no-op for them.
2806
+ expr = re.sub(r"\btimeframe\.(\w+)(?:\(\))?", _tf_rep, expr)
2807
+
2808
+ # 2) math.<member> -> C++ (constants + std::* functions).
2809
+ def _math_rep(p: re.Match) -> str:
2810
+ mem = p.group(1)
2811
+ return self._MATH_MEMBER_CPP.get(mem, p.group(0))
2812
+ expr = re.sub(r"\bmath\.(\w+)", _math_rep, expr)
2813
+
2814
+ # 3) Pine word-logical operators -> C++ operators (after timeframe/math
2815
+ # substitution so we don't rewrite inside their C++ expansions).
2816
+ for pine_op, cpp_op in self._PINE_LOGICAL_OPS.items():
2817
+ expr = re.sub(rf"\b{pine_op}\b", cpp_op, expr)
2818
+
2819
+ # 4) Substitute input-backed vars with override-aware get_input_*()
2820
+ # reads, and inline known compile-time consts (non-input) as literals.
2821
+ def _sub(p: re.Match) -> str:
2822
+ name = p.group(0)
2823
+ if name in self._input_backed_vars:
2824
+ call_node = self._input_var_to_call.get(name)
2825
+ if call_node is None:
2826
+ return name
2827
+ func_name_i, namespace_i = self._resolve_callee(call_node.callee)
2828
+ title = self._get_input_title(call_node, var_name=name)
2829
+ return self._render_input_value(call_node, func_name_i, namespace_i, title)
2830
+ if name in self._known_vars and name not in self._input_backed_vars:
2831
+ val = self._known_vars[name]
2832
+ if isinstance(val, bool):
2833
+ return "true" if val else "false"
2834
+ if isinstance(val, (int, float)):
2835
+ return str(val)
2836
+ return f'std::string("{self._cpp_string_escape(val)}")'
2837
+ return name
1875
2838
 
1876
2839
  rewritten = ident_re.sub(_sub, expr)
1877
- # Pine auto-converts floats to ints for TA lengths; C++ does not, so
1878
- # wrap the whole expression in an explicit int cast when any math.*
1879
- # function appears (they return doubles).
1880
- if any(m in arg_str for m in math_map):
2840
+
2841
+ # 5) Pine auto-converts floats to ints for TA lengths; C++ does not.
2842
+ # If any math.* function appears (returns double) OR a timeframe.*
2843
+ # boolean is part of a ternary whose branches are doubles, wrap the
2844
+ # whole expression in an explicit int cast so the TA ctor gets an
2845
+ # integer length.
2846
+ had_math = "std::" in rewritten or bool(re.search(r"\btimeframe\b", expanded))
2847
+ if had_math:
1881
2848
  return f"(int)({rewritten})"
1882
2849
  return rewritten
1883
2850
 
2851
+ def _lower_reset_expr_via_visitor(self, expanded: str) -> str | None:
2852
+ """Re-parse a gate-approved, fully-expanded TA-length expression and
2853
+ lower it through the SAME expression visitor the statement path uses,
2854
+ so operator grouping and Pine numeric typing are preserved identically
2855
+ (Pine ``/`` always yields float; ``math.*`` returns double; branches are
2856
+ parenthesized). Reuses ``_visit_binop`` etc.; nothing is re-typed here.
2857
+
2858
+ Input-backed variables render as override-aware ``get_input_*()`` reads
2859
+ (not member refs) via ``_reset_input_getter_mode`` — the reset can run
2860
+ in ``evaluate_security`` before the input members are initialised, so it
2861
+ must not depend on their init order. Inline ``input(...)`` calls and
2862
+ ``math.*`` / ``timeframe.*`` members lower through the visitor's own
2863
+ handlers (same C++ the statement path would emit).
2864
+
2865
+ Returns None if re-parse / lowering fails, so the caller falls back to
2866
+ the legacy token-substitution renderer (never worse than before)."""
2867
+ try:
2868
+ from ..lexer import Lexer
2869
+ from ..parser import Parser
2870
+ tokens = Lexer(expanded).tokenize()
2871
+ node = Parser(tokens, source=expanded)._parse_expression()
2872
+ except Exception:
2873
+ return None
2874
+ prev = self._reset_input_getter_mode
2875
+ self._reset_input_getter_mode = True
2876
+ try:
2877
+ rendered = self._visit_expr(node)
2878
+ except Exception:
2879
+ return None
2880
+ finally:
2881
+ self._reset_input_getter_mode = prev
2882
+ if not rendered or "/* " in rendered:
2883
+ # Unknown/unhandled node leaked a placeholder — defer to legacy.
2884
+ return None
2885
+ # A TA length must be int. Truncate when the lowered form is
2886
+ # float-typed (a ``std::*`` call, a ``(double)`` division coercion, or a
2887
+ # ``timeframe.*`` helper — all timeframe helpers reference script_tf_).
2888
+ # A bare int / identifier length carries none of these and is left
2889
+ # unwrapped, so simple sites stay byte-identical to the legacy output.
2890
+ if ("std::" in rendered or "(double)" in rendered
2891
+ or "script_tf_" in rendered):
2892
+ return f"(int)({rendered})"
2893
+ return rendered
2894
+
2895
+
1884
2896
  def _collect_ta_runtime_resets(self) -> list[str]:
1885
2897
  """Collect reassignment statements for every TA object whose ctor args
1886
2898
  depend on an input-backed variable. Returned strings are raw C++
@@ -1892,7 +2904,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1892
2904
  resets: list[str] = []
1893
2905
 
1894
2906
  # Main-context TA objects
1895
- for site in self.ctx.ta_call_sites:
2907
+ for _ta_idx, site in enumerate(self.ctx.ta_call_sites):
2908
+ if _ta_idx in self._dead_ta_indices:
2909
+ continue
1896
2910
  if not site.ctor_args:
1897
2911
  continue
1898
2912
  runtime_args: list[str] = []
@@ -1910,15 +2924,38 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1910
2924
  f"{site.member_name} = {site.class_name}({', '.join(runtime_args)});"
1911
2925
  )
1912
2926
 
1913
- # Security-context TA copies (same ctor args as their main-context site)
2927
+ # Security-context TA copies. Normally these share the ctor args of
2928
+ # their main-context site, but a request.security nested in a helper
2929
+ # called at several sites is cloned per call site (distinct sec_id +
2930
+ # callsite_idx) while the shared TA site's ``ctor_args`` were resolved
2931
+ # ONCE (against the first call site). Every clone would then size its
2932
+ # indicator from call site 0's argument (e.g. four EMAs all pinned to
2933
+ # crossFastLen instead of the per-site fast/slow lengths). Resolve each
2934
+ # sec's ctor args against ITS call site by reusing the per-call-site
2935
+ # function-clone TA remap (identity for cs0 / non-clones, so all other
2936
+ # output stays byte-identical).
2937
+ sec_call_by_id = {it["sec_id"]: it for it in self._security_calls}
2938
+ ta_site_by_member = {s.member_name: s for s in self.ctx.ta_call_sites}
1914
2939
  for info in self._security_eval_info:
2940
+ sec_item = sec_call_by_id.get(info["sec_id"])
2941
+ sec_containing = (sec_item or {}).get("containing_func") or ""
2942
+ sec_cs_idx = (sec_item or {}).get("callsite_idx")
1915
2943
  for idx, variants in (info.get("ta_variants") or {}).items():
1916
2944
  site = self.ctx.ta_call_sites[idx]
1917
- if not site.ctor_args:
2945
+ ctor_site = site
2946
+ if sec_containing and sec_cs_idx is not None:
2947
+ remap = self._func_cs_ta_remap.get((sec_containing, sec_cs_idx))
2948
+ if remap:
2949
+ cloned_name = remap.get(site.member_name)
2950
+ if cloned_name and cloned_name != site.member_name:
2951
+ cand = ta_site_by_member.get(cloned_name)
2952
+ if cand is not None:
2953
+ ctor_site = cand
2954
+ if not ctor_site.ctor_args:
1918
2955
  continue
1919
2956
  runtime_args = []
1920
2957
  any_runtime = False
1921
- for a in site.ctor_args:
2958
+ for a in ctor_site.ctor_args:
1922
2959
  rt = self._runtime_ctor_arg_for_reset(a)
1923
2960
  if rt is not None:
1924
2961
  runtime_args.append(rt)