@pineforge/codegen-pyodide 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -1009,9 +1262,184 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1009
1262
  # First, find all variables that are reassigned anywhere in the AST.
1010
1263
  # These cannot be inlined as constants since their value changes at runtime.
1011
1264
  reassigned = self._find_reassigned_vars()
1265
+ # Register never-reassigned ``var``/``varip`` scalars with a stable
1266
+ # init FIRST — a later derived var (or a UDF body) may reference such a
1267
+ # scalar as a stable length component, so it must be classified stable
1268
+ # before those exprs are evaluated below.
1269
+ self._collect_stable_var_scalars(reassigned)
1012
1270
  for stmt in self.ctx.ast.body:
1013
1271
  if isinstance(stmt, VarDecl) and stmt.name not in reassigned:
1014
1272
  self._collect_known_var(stmt)
1273
+ # A second pass handles the stable-reassigned-scalar pattern: a
1274
+ # class-scope scalar initialized from a stable expr and reassigned
1275
+ # ONLY inside top-level if/elif chains whose conditions and assigned
1276
+ # values are themselves stable (inputs / timeframe.* / math.*). Such
1277
+ # a var is a bar-invariant scalar and may feed a TA ctor length with
1278
+ # a runtime reset that reproduces the conditional logic. Series-
1279
+ # dependent reassignments are left untracked (rejected by the guard).
1280
+ self._collect_reassigned_stable_scalars(reassigned)
1281
+
1282
+ def _collect_stable_var_scalars(self, reassigned: set[str]) -> None:
1283
+ """Track top-level ``var``/``varip`` scalars declared exactly once (never
1284
+ reassigned) from a stable init expression.
1285
+
1286
+ A ``var`` scalar's one-shot initializer runs once and the value never
1287
+ changes across bars, so a never-reassigned ``var`` over a stable init
1288
+ (``var int _tfSec = timeframe.in_seconds()``) is a bar-invariant scalar —
1289
+ safe to embed in a TA ctor runtime-reset expression. Recording it in
1290
+ ``_derived_input_expr`` + ``_stable_runtime_vars`` lets the reset path
1291
+ expand the name and lets ``_expr_is_stable`` classify it (the
1292
+ ``_stable_runtime_vars`` check precedes the ``_var_names`` rejection).
1293
+
1294
+ A ``var`` that is reassigned anywhere (``:=`` in ``reassigned``), is a
1295
+ series var, or is initialized from a non-stable value stays untracked —
1296
+ so a TA length fed by genuinely-mutable persistent state is still
1297
+ rejected by the constructor guard. Names are NOT folded into
1298
+ ``_known_vars`` (no use-site inlining): only the length-analysis path is
1299
+ affected, and the ``var`` member still emits and initializes normally.
1300
+ """
1301
+ for stmt in (self.ctx.ast.body or []):
1302
+ if not isinstance(stmt, VarDecl):
1303
+ continue
1304
+ if not (stmt.is_var or stmt.is_varip):
1305
+ continue
1306
+ if stmt.name in reassigned:
1307
+ continue
1308
+ if stmt.name in self.ctx.series_vars:
1309
+ continue
1310
+ if stmt.value is None or not self._expr_is_stable(stmt.value):
1311
+ continue
1312
+ expr_str = self._arith_expr_to_str(stmt.value)
1313
+ if expr_str is None:
1314
+ continue
1315
+ self._derived_input_expr[stmt.name] = expr_str
1316
+ self._stable_runtime_vars.add(stmt.name)
1317
+ # Mark input-backed iff the init references an input, so the reset
1318
+ # emits override-aware get_input_*() reads for it.
1319
+ import re as _re
1320
+ toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", expr_str))
1321
+ if any(t in self._input_backed_vars for t in toks):
1322
+ self._input_backed_vars.add(stmt.name)
1323
+
1324
+ def _collect_reassigned_stable_scalars(self, reassigned: set[str]) -> None:
1325
+ """Track class-scope scalars that are reassigned but only along stable
1326
+ if/elif paths (see ``test_stable_reassigned_class_scope_length``).
1327
+
1328
+ For each top-level ``v = <init>`` whose name is reassigned, build the
1329
+ final value as a nested ternary by folding subsequent top-level
1330
+ IfStmts / direct Assignments. If every condition and every assigned
1331
+ RHS is stable (and renderable), record the ternary in
1332
+ ``_derived_input_expr`` and add ``v`` to ``_stable_runtime_vars`` so
1333
+ the TA ctor reset path can expand it. Anything non-stable (a ta.*
1334
+ result, a bar field, a series var) leaves the var untracked, so the
1335
+ ctor guard still rejects it loudly.
1336
+ """
1337
+ from ..ast_nodes import IfStmt, Assignment
1338
+ body = self.ctx.ast.body or []
1339
+ # Pre-resolve each reassigned var's initial VarDecl.
1340
+ inits: dict[str, object] = {}
1341
+ for stmt in body:
1342
+ if (isinstance(stmt, VarDecl) and stmt.name in reassigned
1343
+ and not stmt.is_var and not stmt.is_varip):
1344
+ # Only consider vars whose initial value is itself stable;
1345
+ # an unstable init cannot become a stable scalar via later
1346
+ # reassignment.
1347
+ if stmt.value is not None and self._expr_is_stable(stmt.value):
1348
+ inits[stmt.name] = stmt.value
1349
+ if not inits:
1350
+ return
1351
+
1352
+ def _value_after(stmts, fallback: str | None) -> str | None:
1353
+ """Fold a statement list into the final value expression for the
1354
+ target var, given ``fallback`` as the value on entry. Returns None
1355
+ if any condition / assignment is non-stable or unrenderable."""
1356
+ current = fallback
1357
+ for s in stmts or []:
1358
+ if isinstance(s, Assignment) and isinstance(s.target, Identifier):
1359
+ if s.target.name != target_name:
1360
+ continue
1361
+ if s.op != ":=":
1362
+ return None # compound assignment — not a stable fold
1363
+ if not self._expr_is_stable(s.value):
1364
+ return None
1365
+ rhs = self._arith_expr_to_str(s.value)
1366
+ if rhs is None:
1367
+ return None
1368
+ current = rhs
1369
+ elif isinstance(s, IfStmt):
1370
+ # Only model IfStmts that actually reassign the target var;
1371
+ # an unrelated IfStmt (e.g. entry/exit logic with a series
1372
+ # condition) must NOT abort the fold — the var simply keeps
1373
+ # its current value through it.
1374
+ if not _reassigns(s, target_name):
1375
+ continue
1376
+ if not self._expr_is_stable(s.condition):
1377
+ return None
1378
+ cond = self._arith_expr_to_str(s.condition)
1379
+ if cond is None:
1380
+ return None
1381
+ then_val = _value_after(s.body, current)
1382
+ if then_val is None:
1383
+ return None
1384
+ else_val = _value_after(s.else_body, current)
1385
+ if else_val is None:
1386
+ return None
1387
+ current = f"({cond} ? {then_val} : {else_val})"
1388
+ # Other statement shapes (for/while/switch/var decls of
1389
+ # other vars) are ignored for this var's value fold; they
1390
+ # do not reassign ``target_name`` in a way we model.
1391
+ return current
1392
+
1393
+ def _reassigns(node, name: str) -> bool:
1394
+ """True if any ``:=`` assignment to ``name`` occurs within node."""
1395
+ from ..ast_nodes import IfStmt as _If, Assignment as _Asg
1396
+ if isinstance(node, _Asg) and isinstance(node.target, Identifier):
1397
+ return node.target.name == name
1398
+ if isinstance(node, _If):
1399
+ if any(_reassigns(c, name) for c in (node.body or [])):
1400
+ return True
1401
+ if any(_reassigns(c, name) for c in (node.else_body or [])):
1402
+ return True
1403
+ return False
1404
+ for attr in ("body", "else_body", "cases", "default_body"):
1405
+ sub = getattr(node, attr, None)
1406
+ if isinstance(sub, list):
1407
+ if any(_reassigns(c, name) for c in sub):
1408
+ return True
1409
+ return False
1410
+
1411
+ for target_name, init_node in inits.items():
1412
+ init_str = self._arith_expr_to_str(init_node)
1413
+ if init_str is None:
1414
+ continue
1415
+ final = _value_after(body, init_str)
1416
+ if final is None:
1417
+ continue
1418
+ # Sanity: the fold must actually differ from the bare init,
1419
+ # otherwise there were no stable reassignments and the var is
1420
+ # already covered (or rejected) by the main pass.
1421
+ if final == init_str:
1422
+ continue
1423
+ # Fold to a compile-time literal when possible (so the ctor-init
1424
+ # list can use it directly); otherwise record the raw expression
1425
+ # for the runtime reset path to expand.
1426
+ folded = self._resolve_known(final)
1427
+ if self._is_compile_time_value(folded):
1428
+ try:
1429
+ num = float(folded)
1430
+ self._known_vars[target_name] = (
1431
+ int(num) if num == int(num) else num
1432
+ )
1433
+ except ValueError:
1434
+ pass
1435
+ self._derived_input_expr[target_name] = final
1436
+ self._stable_runtime_vars.add(target_name)
1437
+ # Mark input-backed iff the expression references an input so the
1438
+ # override-aware get_input_*() reads are emitted on the reset path.
1439
+ import re as _re
1440
+ toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", final))
1441
+ if any(t in self._input_backed_vars for t in toks):
1442
+ self._input_backed_vars.add(target_name)
1015
1443
 
1016
1444
  def _find_reassigned_vars(self) -> set[str]:
1017
1445
  """Scan AST to find all variable names that are targets of := or compound assignment."""
@@ -1038,11 +1466,276 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1038
1466
  walk(stmt)
1039
1467
  return reassigned
1040
1468
 
1041
- def _arith_expr_to_str(self, node) -> str | None:
1469
+ # ``math.*`` members that are pure functions over their (stable) args, or
1470
+ # stable constants. Anything outside this set (e.g. ``math.random``) is
1471
+ # treated as non-stable. Used by ``_expr_is_stable``.
1472
+ _MATH_STABLE_MEMBERS: frozenset[str] = frozenset({
1473
+ "pi", "e", "phi", "rphi",
1474
+ "abs", "max", "min", "round", "floor", "ceil",
1475
+ "sqrt", "log", "log10", "exp", "pow",
1476
+ "sin", "cos", "tan", "asin", "acos", "atan", "sign",
1477
+ "sum", "avg", "to_precision", "round_to_mintick",
1478
+ })
1479
+
1480
+ # ``timeframe.*`` members that are constant for the lifetime of a run —
1481
+ # they reflect the script's resolution, not a per-bar value.
1482
+ _TF_STABLE_MEMBERS: frozenset[str] = frozenset({
1483
+ "period", "main_period", "multiplier",
1484
+ "isintraday", "isminutes", "isdaily", "isweekly",
1485
+ "ismonthly", "isdwm", "isseconds", "in_seconds", "isticks",
1486
+ })
1487
+
1488
+ # Depth ceiling for inlining nested single-expression user functions while
1489
+ # classifying a TA length's stability. Pine forbids recursion, so any real
1490
+ # chain is shallow; the cap is a backstop against pathological input and is
1491
+ # enforced together with a name-stack cycle guard.
1492
+ _UDF_INLINE_MAX_DEPTH = 16
1493
+
1494
+ def _get_udf_def(self, name: str):
1495
+ """Return the top-level single-name ``FuncDef`` for ``name`` (or None).
1496
+
1497
+ Built lazily and cached. UDT ``MethodDef``s are intentionally excluded —
1498
+ only a free function can appear as a bare-name TA length call.
1499
+ """
1500
+ cache = getattr(self, "_udf_def_cache", None)
1501
+ if cache is None:
1502
+ cache = {}
1503
+ for stmt in (self.ctx.ast.body or []):
1504
+ if isinstance(stmt, FuncDef):
1505
+ # Last definition wins; a name map is all the length path needs.
1506
+ cache[stmt.name] = stmt
1507
+ self._udf_def_cache = cache
1508
+ return cache.get(name)
1509
+
1510
+ def _inline_single_expr_udf(self, node, _udf_stack: frozenset = frozenset(),
1511
+ _depth: int = 0):
1512
+ """If ``node`` is a call to a user-defined SINGLE-EXPRESSION function,
1513
+ return its body expression with each parameter substituted by the
1514
+ corresponding call-argument node. Returns None when the call is not such
1515
+ a function, the arity/kwargs do not match, the body is not a single
1516
+ expression, the body contains a shape we do not clone, or the call would
1517
+ recurse (cycle / depth-limit).
1518
+
1519
+ Purely structural: it does NOT judge stability (the caller does, via
1520
+ ``_expr_is_stable`` on the returned node). The conservative None keeps
1521
+ the TA-ctor guard intact.
1522
+ """
1523
+ if not isinstance(node, FuncCall):
1524
+ return None
1525
+ func_name, namespace = self._resolve_callee(node.callee)
1526
+ if namespace is not None or func_name is None:
1527
+ return None
1528
+ if func_name in _udf_stack or _depth >= self._UDF_INLINE_MAX_DEPTH:
1529
+ return None
1530
+ fdef = self._get_udf_def(func_name)
1531
+ if fdef is None:
1532
+ return None
1533
+ # A body that is exactly ONE expression statement is inlinable, whether
1534
+ # written inline after ``=>`` (``is_single_expr=True``) or as a one-line
1535
+ # indented block (``f(x) =>`` then a single indented expr, which the
1536
+ # parser records as ``is_single_expr=False`` with a one-ExprStmt body —
1537
+ # the gonzowiththewind-sisyphus ``f_bars`` shape). A multi-statement
1538
+ # body (len != 1, or a non-ExprStmt) is conservatively refused.
1539
+ body = fdef.body
1540
+ if not body or len(body) != 1 or not isinstance(body[0], ExprStmt):
1541
+ return None
1542
+ # Require a plain positional call: one arg per param, no kwargs, no
1543
+ # default-parameter fill-in — anything else is conservatively refused.
1544
+ if node.kwargs or len(node.args) != len(fdef.params):
1545
+ return None
1546
+ subst = dict(zip(fdef.params, node.args))
1547
+ return self._subst_params(body[0].expr, subst)
1548
+
1549
+ def _subst_params(self, node, subst: dict):
1550
+ """Return a copy of ``node`` with every ``Identifier`` whose name is a
1551
+ key in ``subst`` replaced by the mapped argument node. Returns None for
1552
+ any node outside the small arithmetic/call grammar we fold (an
1553
+ unrecognised construct — e.g. a history ``Subscript`` — conservatively
1554
+ aborts the inline). ``dataclasses.replace`` preserves ``loc``/
1555
+ ``annotations`` so diagnostics still point at real source spans."""
1556
+ import dataclasses as _dc
1557
+ if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral, NaLiteral)):
1558
+ return node
1559
+ if isinstance(node, Identifier):
1560
+ return subst.get(node.name, node)
1561
+ if isinstance(node, MemberAccess):
1562
+ obj = self._subst_params(node.object, subst)
1563
+ if obj is None:
1564
+ return None
1565
+ return _dc.replace(node, object=obj)
1566
+ if isinstance(node, Ternary):
1567
+ c = self._subst_params(node.condition, subst)
1568
+ t = self._subst_params(node.true_val, subst)
1569
+ f = self._subst_params(node.false_val, subst)
1570
+ if c is None or t is None or f is None:
1571
+ return None
1572
+ return _dc.replace(node, condition=c, true_val=t, false_val=f)
1573
+ if isinstance(node, BinOp):
1574
+ l = self._subst_params(node.left, subst)
1575
+ r = self._subst_params(node.right, subst)
1576
+ if l is None or r is None:
1577
+ return None
1578
+ return _dc.replace(node, left=l, right=r)
1579
+ if isinstance(node, UnaryOp):
1580
+ o = self._subst_params(node.operand, subst)
1581
+ if o is None:
1582
+ return None
1583
+ return _dc.replace(node, operand=o)
1584
+ if isinstance(node, FuncCall):
1585
+ if node.kwargs:
1586
+ return None
1587
+ new_args = []
1588
+ for a in node.args:
1589
+ sa = self._subst_params(a, subst)
1590
+ if sa is None:
1591
+ return None
1592
+ new_args.append(sa)
1593
+ return _dc.replace(node, args=new_args)
1594
+ # Subscript (history read) and anything else: not a stable-length shape.
1595
+ return None
1596
+
1597
+ def _expr_is_stable(self, node, _udf_stack: frozenset = frozenset(),
1598
+ _depth: int = 0) -> bool:
1599
+ """True iff ``node``'s value is a bar-invariant scalar.
1600
+
1601
+ A stable expression depends only on: literals, ``input.*`` values,
1602
+ previously-tracked stable runtime vars, known compile-time consts,
1603
+ ``timeframe.*`` members (constant per run), ``syminfo.*`` (constant
1604
+ per instrument), and ``math.*`` functions/consts over stable
1605
+ sub-expressions, combined with arithmetic / comparison / logical
1606
+ ops, ternaries, and the ``int/float/bool/string`` casts.
1607
+
1608
+ Returns False (i.e. "series") for any node that references a per-bar
1609
+ value: bar fields (close/open/...), series vars, history subscripts,
1610
+ ``ta.*`` results, strategy.* state, or any unrecognised construct.
1611
+ The conservative False keeps the TA-ctor guard intact for genuinely
1612
+ dynamic lengths.
1613
+ """
1614
+ if node is None:
1615
+ return False
1616
+ if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral)):
1617
+ return True
1618
+ if isinstance(node, NaLiteral):
1619
+ return True
1620
+ if isinstance(node, Identifier):
1621
+ name = node.name
1622
+ if name in self._known_vars:
1623
+ return True
1624
+ if name in self._stable_runtime_vars:
1625
+ return True
1626
+ if name in self._input_backed_vars:
1627
+ return True
1628
+ if name in self.ctx.series_vars:
1629
+ return False
1630
+ if name in self._var_names:
1631
+ # var/varip persistent state — mutable across bars.
1632
+ return False
1633
+ if name in BAR_FIELDS or name in BAR_BUILTINS:
1634
+ return False
1635
+ # Unrecognised bare identifier: be conservative so we never
1636
+ # silently allow an undeclared / dynamic length through.
1637
+ return False
1638
+ if isinstance(node, MemberAccess):
1639
+ if isinstance(node.object, Identifier):
1640
+ ns = node.object.name
1641
+ if ns == "timeframe":
1642
+ return node.member in self._TF_STABLE_MEMBERS
1643
+ if ns == "math":
1644
+ return node.member in self._MATH_STABLE_MEMBERS
1645
+ if ns == "syminfo":
1646
+ # syminfo.* (mintick, pointvalue, tickerid, ...) is
1647
+ # constant for the run — safe as a stable scalar.
1648
+ return True
1649
+ # bar.* / request.* / any other member access reads per-bar or
1650
+ # dynamic state.
1651
+ return False
1652
+ if isinstance(node, Subscript):
1653
+ # History read (``close[1]``) or indexed access — per-bar.
1654
+ return False
1655
+ if isinstance(node, Ternary):
1656
+ return (self._expr_is_stable(node.condition, _udf_stack, _depth)
1657
+ and self._expr_is_stable(node.true_val, _udf_stack, _depth)
1658
+ and self._expr_is_stable(node.false_val, _udf_stack, _depth))
1659
+ if isinstance(node, BinOp):
1660
+ return (self._expr_is_stable(node.left, _udf_stack, _depth)
1661
+ and self._expr_is_stable(node.right, _udf_stack, _depth))
1662
+ if isinstance(node, UnaryOp):
1663
+ return self._expr_is_stable(node.operand, _udf_stack, _depth)
1664
+ if isinstance(node, FuncCall):
1665
+ func_name, namespace = self._resolve_callee(node.callee)
1666
+ if namespace == "ta":
1667
+ return False
1668
+ if namespace == "math":
1669
+ if func_name not in self._MATH_STABLE_MEMBERS:
1670
+ return False
1671
+ return all(self._expr_is_stable(a, _udf_stack, _depth)
1672
+ for a in node.args)
1673
+ if namespace == "timeframe":
1674
+ # ``timeframe.in_seconds()`` (and any other function-form
1675
+ # timeframe member) is a stable per-run scalar — it reflects
1676
+ # the script's resolution, not a per-bar value.
1677
+ if func_name not in self._TF_STABLE_MEMBERS:
1678
+ return False
1679
+ return all(self._expr_is_stable(a, _udf_stack, _depth)
1680
+ for a in node.args)
1681
+ if namespace == "input":
1682
+ return True
1683
+ if namespace is None and func_name in ("int", "float", "bool", "string"):
1684
+ return all(self._expr_is_stable(a, _udf_stack, _depth)
1685
+ for a in node.args)
1686
+ # A user-defined single-expression function is stable iff every
1687
+ # argument is stable AND its body (with the params bound to those
1688
+ # args) is stable — i.e. the body references only stable scalars
1689
+ # (inputs / consts / timeframe.* / math.* / never-reassigned var
1690
+ # scalars) and no series / strategy state / ta.* results. Inlining
1691
+ # the body (params substituted by the arg nodes) lets the ordinary
1692
+ # classifier decide; the name-stack + depth guard refuses recursion
1693
+ # so a cyclic / malformed UDF is rejected, not looped forever.
1694
+ if namespace is None and func_name is not None:
1695
+ inlined = self._inline_single_expr_udf(node, _udf_stack, _depth)
1696
+ if inlined is not None:
1697
+ if not all(self._expr_is_stable(a, _udf_stack, _depth)
1698
+ for a in node.args):
1699
+ return False
1700
+ return self._expr_is_stable(
1701
+ inlined, _udf_stack | {func_name}, _depth + 1)
1702
+ # Any other call (multi-statement user functions, str.*, array.*,
1703
+ # ...) — series by default; the conservative answer keeps the guard
1704
+ # honest.
1705
+ return False
1706
+ return False
1707
+
1708
+ # AST node kinds whose serialized form is self-delimiting (a literal, a
1709
+ # name, a member read, or a ``name(...)`` call). Non-atomic kinds (BinOp /
1710
+ # UnaryOp / Ternary) MUST be parenthesized when they appear as an operand,
1711
+ # otherwise re-parsing the flattened infix string silently reassociates the
1712
+ # tree: Pine grouping ``(a - b) / (c - d)`` degrades to ``a - b / c - d``
1713
+ # under C++ precedence. See ``_runtime_ctor_arg_for_reset`` (the string is
1714
+ # re-parsed and lowered through the expression visitor).
1715
+ _ATOMIC_ARITH_NODES = (NumberLiteral, Identifier, MemberAccess, FuncCall)
1716
+
1717
+ def _arith_operand_to_str(self, node, _udf_stack: frozenset = frozenset(),
1718
+ _depth: int = 0) -> str | None:
1719
+ """Serialize ``node`` for use as an operand: parenthesize it unless its
1720
+ serialized form is already self-delimiting, so grouping survives a
1721
+ round-trip through the parser."""
1722
+ s = self._arith_expr_to_str(node, _udf_stack, _depth)
1723
+ if s is None:
1724
+ return None
1725
+ if isinstance(node, self._ATOMIC_ARITH_NODES):
1726
+ return s
1727
+ return f"({s})"
1728
+
1729
+ def _arith_expr_to_str(self, node, _udf_stack: frozenset = frozenset(),
1730
+ _depth: int = 0) -> str | None:
1042
1731
  """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.
1732
+ that re-parses to the SAME tree (grouping preserved via
1733
+ ``_arith_operand_to_str``). Returns None for any node shape we don't
1734
+ fold (series subscripts, etc.) so the caller leaves the var untracked.
1735
+
1736
+ ``_udf_stack``/``_depth`` guard the single-expression-UDF inlining below
1737
+ against recursion cycles (Pine forbids recursion, but a malformed source
1738
+ must be refused, not looped forever).
1046
1739
  """
1047
1740
  if isinstance(node, NumberLiteral):
1048
1741
  v = node.value
@@ -1054,23 +1747,44 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1054
1747
  if isinstance(node, MemberAccess) and isinstance(node.object, Identifier):
1055
1748
  return f"{node.object.name}.{node.member}"
1056
1749
  if isinstance(node, BinOp):
1057
- l = self._arith_expr_to_str(node.left)
1058
- r = self._arith_expr_to_str(node.right)
1750
+ l = self._arith_operand_to_str(node.left, _udf_stack, _depth)
1751
+ r = self._arith_operand_to_str(node.right, _udf_stack, _depth)
1059
1752
  if l is None or r is None:
1060
1753
  return None
1061
1754
  return f"{l} {node.op} {r}"
1062
1755
  if isinstance(node, UnaryOp):
1063
- o = self._arith_expr_to_str(node.operand)
1756
+ o = self._arith_operand_to_str(node.operand, _udf_stack, _depth)
1064
1757
  if o is None:
1065
1758
  return None
1066
1759
  return f"{node.op}{o}"
1760
+ if isinstance(node, Ternary):
1761
+ c = self._arith_operand_to_str(node.condition, _udf_stack, _depth)
1762
+ t = self._arith_operand_to_str(node.true_val, _udf_stack, _depth)
1763
+ f = self._arith_operand_to_str(node.false_val, _udf_stack, _depth)
1764
+ if c is None or t is None or f is None:
1765
+ return None
1766
+ return f"{c} ? {t} : {f}"
1067
1767
  if isinstance(node, FuncCall):
1068
- callee = self._arith_expr_to_str(node.callee)
1768
+ # A bare-name call to a single-expression user function has no C++
1769
+ # counterpart at class scope — inline its body (params substituted
1770
+ # by the arg expressions) so the rendered string is pure
1771
+ # math/timeframe/input arithmetic the ctor-reset path can expand.
1772
+ # Namespaced calls (math.*/timeframe.*/int(...)) fall through to the
1773
+ # ordinary ``callee(args)`` rendering below. The stack/depth guard
1774
+ # refuses a recursive UDF (returns None -> caller leaves it untracked
1775
+ # -> the ctor guard rejects it loudly) instead of recursing forever.
1776
+ fn, ns = self._resolve_callee(node.callee)
1777
+ if ns is None and fn is not None and self._get_udf_def(fn) is not None:
1778
+ inlined = self._inline_single_expr_udf(node, _udf_stack, _depth)
1779
+ if inlined is None:
1780
+ return None
1781
+ return self._arith_expr_to_str(inlined, _udf_stack | {fn}, _depth + 1)
1782
+ callee = self._arith_expr_to_str(node.callee, _udf_stack, _depth)
1069
1783
  if callee is None:
1070
1784
  return None
1071
1785
  parts = []
1072
1786
  for a in node.args:
1073
- s = self._arith_expr_to_str(a)
1787
+ s = self._arith_expr_to_str(a, _udf_stack, _depth)
1074
1788
  if s is None:
1075
1789
  return None
1076
1790
  parts.append(s)
@@ -1129,36 +1843,56 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1129
1843
  if stored:
1130
1844
  self._input_backed_vars.add(node.name)
1131
1845
  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)``).
1846
+ # Class-scope arithmetic / ternaries / casts over known, input-backed,
1847
+ # timeframe.*, or math.* operands
1848
+ # (``wilderLen = rsiLen * 2 - 1``, ``fastPeriod = isM5 ? ... : ...``,
1849
+ # ``filterLen = math.max(1, int(math.round(2 / a)))``).
1134
1850
  # Without this branch the derived name is untracked, the TA ctor arg
1135
1851
  # 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)):
1852
+ # period of 1. We (a) fold to a literal for the ctor-init list when
1853
+ # possible and (b) record the raw expression so the reset path can
1854
+ # re-expand any input-backed operand to its get_input_*() runtime read
1855
+ # and render timeframe.* / math.* fragments to valid C++.
1856
+ #
1857
+ # The ``_expr_is_stable`` gate is what separates a faithful stable
1858
+ # scalar (inputs + constants + timeframe + math) from a series-derived
1859
+ # value: a length that depends on a ta.* result, a history subscript,
1860
+ # or a bar field stays untracked and is therefore rejected by the TA
1861
+ # ctor guard — preserving the guardrail for genuine dynamic lengths.
1862
+ elif isinstance(node.value, (BinOp, UnaryOp, FuncCall, Ternary)):
1140
1863
  expr_str = self._arith_expr_to_str(node.value)
1141
- if expr_str is not None:
1864
+ if expr_str is not None and self._expr_is_stable(node.value):
1142
1865
  import re as _re
1143
1866
  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
1867
  refs_input = any(t in self._input_backed_vars for t in tokens)
1146
1868
  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)
1869
+ # The stability classifier already proved this expression is a
1870
+ # bar-invariant scalar (inputs / constants / timeframe.* /
1871
+ # math.* / syminfo.* only). Track it unconditionally so later
1872
+ # stable exprs (and the TA reset path) can reference / expand
1873
+ # it — e.g. ``pi = math.asin(1) * 2`` feeds ``beta`` feeds
1874
+ # ``alpha`` feeds a function-local ``filterLen``.
1875
+ folded = self._resolve_known(expr_str)
1876
+ if self._is_compile_time_value(folded):
1877
+ try:
1878
+ num = float(folded)
1879
+ self._known_vars[node.name] = (
1880
+ int(num) if num == int(num) else num
1881
+ )
1882
+ except ValueError:
1883
+ pass
1884
+ # Record the raw expression so the runtime-reset path can
1885
+ # re-expand operands. Always record for stable derived exprs
1886
+ # (even pure-math / pure-timeframe ones with no input) so the
1887
+ # reset can render them.
1888
+ self._derived_input_expr[node.name] = expr_str
1889
+ self._stable_runtime_vars.add(node.name)
1890
+ # Mark input-backed so use-sites are not inlined and the
1891
+ # override-aware get_input_*() reads are emitted on the reset
1892
+ # path. Pure-math / pure-timeframe exprs (no input) stay out
1893
+ # of this set, which is fine — they have no override to honor.
1894
+ if refs_input or refs_derived:
1895
+ self._input_backed_vars.add(node.name)
1162
1896
 
1163
1897
  # ------------------------------------------------------------------
1164
1898
  # Public entry point
@@ -1215,17 +1949,172 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1215
1949
  walk(v)
1216
1950
  walk(self.ctx.ast)
1217
1951
 
1952
+ def _func_cpp_base_name(self, fname: str) -> str:
1953
+ """Return the actual emitted C++ base name for a UDF or UDT method."""
1954
+ fi = self._func_info_map.get(fname)
1955
+ if fi is not None and getattr(fi, "is_udt_method", False):
1956
+ return self._emit_udt_method_cpp_name(fi)
1957
+ return self._func_safe_name(fname)
1958
+
1959
+ def _inline_history_contexts_for_owner(self, owner: str | None) -> list[str | None]:
1960
+ """Return every method-emission context that owns one source AST site.
1961
+
1962
+ ``None`` denotes top-level/on_bar or a function emitted exactly once.
1963
+ Stateful UDF clones and fresh nested-helper instances use the same names
1964
+ assigned to ``_current_instance_name`` by ``_emit_func_def`` so lookup
1965
+ while visiting a body is deterministic and cannot collapse call sites.
1966
+ """
1967
+ if owner is None:
1968
+ return [None]
1969
+ if owner in self._dead_func_names:
1970
+ return []
1971
+
1972
+ total_cs = self.ctx.func_call_site_counts.get(owner, 0)
1973
+ cloned = (
1974
+ owner in self.ctx.func_ta_ranges
1975
+ or owner in self.ctx.func_series_vars
1976
+ or owner in self.ctx.func_var_members
1977
+ or owner in self.ctx.func_security_clone_only
1978
+ ) and total_cs > 0
1979
+ if cloned:
1980
+ contexts: list[str | None] = [
1981
+ f"{self._func_cpp_base_name(owner)}_cs{idx}"
1982
+ for idx in range(total_cs)
1983
+ ]
1984
+ else:
1985
+ contexts = [None]
1986
+
1987
+ for inst in self._fresh_instances:
1988
+ if inst["fname"] == owner and inst["name"] not in contexts:
1989
+ contexts.append(inst["name"])
1990
+ return contexts
1991
+
1992
+ def _prepare_inline_history_members(self) -> None:
1993
+ """Pre-register every generated temporary-Series class member.
1994
+
1995
+ Member declarations and the declaration-derived rollback aggregate are
1996
+ emitted before function/on_bar bodies. A source-order AST pass therefore
1997
+ reserves stable names up front. The key includes an emitted UDF context
1998
+ because the same body AST is rendered once per stateful call-site clone.
1999
+ """
2000
+ self._inline_history_members = []
2001
+ self._inline_history_member_by_key = {}
2002
+ counters = {"hist_call": 0, "series_arg": 0}
2003
+
2004
+ def walk_nodes(value):
2005
+ """Yield AST nodes in stable field order, including tuple elements.
2006
+
2007
+ NamingHelper._walk_ast predates several AST containers and is
2008
+ intentionally a best-effort utility. Member pre-registration must
2009
+ be exhaustive because a missed node becomes an undeclared C++
2010
+ member, so use the dataclass field graph directly here.
2011
+ """
2012
+ if isinstance(value, ASTNode):
2013
+ yield value
2014
+ for child in vars(value).values():
2015
+ yield from walk_nodes(child)
2016
+ return
2017
+ if isinstance(value, (list, tuple)):
2018
+ for child in value:
2019
+ yield from walk_nodes(child)
2020
+ return
2021
+ if isinstance(value, dict):
2022
+ for child in value.values():
2023
+ yield from walk_nodes(child)
2024
+ return
2025
+ if isinstance(value, TypeField) and value.default is not None:
2026
+ yield from walk_nodes(value.default)
2027
+
2028
+ owner_by_node: dict[int, str] = {}
2029
+ for fi in self.ctx.func_infos:
2030
+ if fi.node is None:
2031
+ continue
2032
+ for child in walk_nodes(fi.node):
2033
+ owner_by_node[id(child)] = fi.name
2034
+
2035
+ def register(kind: str, source_key: tuple, cpp_type: str,
2036
+ owner: str | None) -> None:
2037
+ if cpp_type not in ("double", "int", "bool"):
2038
+ cpp_type = "double"
2039
+ for context in self._inline_history_contexts_for_owner(owner):
2040
+ key = (kind, *source_key, context)
2041
+ if key in self._inline_history_member_by_key:
2042
+ continue
2043
+ counters[kind] += 1
2044
+ member_name = f"_{kind}_{counters[kind]}"
2045
+ self._inline_history_member_by_key[key] = member_name
2046
+ self._inline_history_members.append({
2047
+ "kind": kind,
2048
+ "member_name": member_name,
2049
+ "cpp_type": cpp_type,
2050
+ "context": context,
2051
+ })
2052
+
2053
+ def actual_args_for(call: FuncCall, params: list[str]) -> list:
2054
+ if call.kwargs:
2055
+ return _merge_kwargs(call.args, call.kwargs, params, lambda arg: arg)
2056
+ return list(call.args)
2057
+
2058
+ for node in walk_nodes(self.ctx.ast):
2059
+ owner = owner_by_node.get(id(node))
2060
+ if isinstance(node, Subscript) and isinstance(node.object, FuncCall):
2061
+ register(
2062
+ "hist_call", (id(node),), self._infer_type(node.object), owner
2063
+ )
2064
+
2065
+ if not isinstance(node, FuncCall):
2066
+ continue
2067
+ func_name, _ = self._resolve_callee(node.callee)
2068
+ fi = self._func_info_map.get(func_name)
2069
+ if fi is None or fi.node is None:
2070
+ continue
2071
+ func_sv = self.ctx.func_series_vars.get(fi.name, set())
2072
+ series_param_indices = {
2073
+ idx for idx, name in enumerate(fi.node.params) if name in func_sv
2074
+ }
2075
+ if not series_param_indices:
2076
+ continue
2077
+ args = actual_args_for(node, list(fi.node.params))
2078
+ for idx, arg in enumerate(args):
2079
+ if idx not in series_param_indices:
2080
+ continue
2081
+ if isinstance(arg, Identifier):
2082
+ if arg.name in BAR_FIELDS or arg.name in BAR_SERIES_PUSH:
2083
+ continue
2084
+ if arg.name in self.ctx.series_vars:
2085
+ continue
2086
+ register(
2087
+ "series_arg", (id(node), idx), self._infer_type(arg), owner
2088
+ )
2089
+
2090
+ def _inline_history_member(self, kind: str, node: ASTNode,
2091
+ arg_idx: int | None = None) -> str:
2092
+ source_key = (id(node),) if arg_idx is None else (id(node), arg_idx)
2093
+ key = (kind, *source_key, self._current_instance_name)
2094
+ member = self._inline_history_member_by_key.get(key)
2095
+ if member is None:
2096
+ raise AssertionError(
2097
+ "missing pre-registered inline history member for "
2098
+ f"{kind} at {getattr(node, 'loc', None)} in context "
2099
+ f"{self._current_instance_name!r}"
2100
+ )
2101
+ return member
2102
+
1218
2103
  def generate(self) -> str:
1219
2104
  """Generate C++ source from the AnalyzerContext."""
1220
2105
  # Context-sensitive instance pre-pass (needs the naming helpers populated
1221
2106
  # in __init__). Computes nested stateful-helper dispatch + fresh instances.
1222
2107
  self._build_func_instances()
2108
+ self._prepare_inline_history_members()
1223
2109
  # Pre-scan for strategy series vars
1224
2110
  self._prescan_strategy_series()
1225
2111
  self._security_ohlc_hist_fields_by_sec: dict[int, set[str]] = {}
1226
2112
  # request.security TA call-sites read at a history offset (``ta.ema(...)[k>=1]``).
1227
2113
  # Maps sec_id -> set of TA call-site indices needing an HTF history Series.
1228
2114
  self._security_ta_hist_idx_by_sec: dict[int, set[int]] = {}
2115
+ # request.security helper-call results read at a history offset
2116
+ # (``myHelper()[k]``). Maps (sec_id, node-id) -> backing Series metadata.
2117
+ self._security_expr_hist_by_node: dict[tuple[int, int], dict] = {}
1229
2118
 
1230
2119
  lines: list[str] = []
1231
2120
 
@@ -1290,6 +2179,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1290
2179
  # 2. Open class
1291
2180
  lines.append("class GeneratedStrategy : public BacktestEngine {")
1292
2181
  lines.append("public:")
2182
+ _script_state_decl_start = len(lines)
1293
2183
 
1294
2184
  # request.security state
1295
2185
  for item in self._security_calls:
@@ -1318,19 +2208,26 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1318
2208
  for field in sorted(
1319
2209
  self._security_ohlc_hist_fields_by_sec.get(sec_id, ())
1320
2210
  ):
2211
+ ctype = self._security_bar_hist_type(field)
1321
2212
  lines.append(
1322
- f" Series<double> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
2213
+ f" Series<{ctype}> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
1323
2214
  )
1324
2215
  self._security_ta_hist_idx_by_sec[sec_id] = (
1325
2216
  self._collect_security_ta_hist_indices(expr_node)
1326
2217
  )
1327
2218
  for name in self._security_ta_hist_series_names(sec_id):
1328
2219
  lines.append(f" Series<double> {name}{_mbb};")
2220
+ self._emit_security_expr_hist_members(sec_id, expr_node, lines, _mbb)
1329
2221
  continue
1330
2222
  if returns_tuple and tuple_size and tuple_size > 0 and isinstance(expr_node, TupleLiteral):
1331
2223
  hist_fields: set[str] = set()
1332
2224
  for el in expr_node.elements:
1333
2225
  hist_fields |= self._collect_security_ohlc_hist_fields(el)
2226
+ for name in item.get("mutable_globals", []) or []:
2227
+ info = self._global_mutable_infos.get(name)
2228
+ if info is not None:
2229
+ for stmt in getattr(info, "source_stmts", []) or []:
2230
+ hist_fields |= self._collect_security_ohlc_hist_fields(stmt)
1334
2231
  self._security_ohlc_hist_fields_by_sec[sec_id] = hist_fields
1335
2232
  for i, el in enumerate(expr_node.elements):
1336
2233
  ctype = self._infer_cpp_type_for_security_elem(el)
@@ -1338,20 +2235,31 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1338
2235
  lines.append(f" {ctype} _req_sec_{sec_id}_{i}{{}};")
1339
2236
  else:
1340
2237
  lines.append(f" {ctype} _req_sec_{sec_id}_{i} = na<double>();")
2238
+ elif returns_tuple and tuple_size and tuple_size > 0:
2239
+ self._security_ohlc_hist_fields_by_sec[sec_id] = (
2240
+ self._collect_security_ohlc_hist_fields_for_call(item)
2241
+ )
2242
+ site = self._get_ta_site(expr_node)
2243
+ ta_name = self._ta_name_from_site(site) if site is not None else ""
2244
+ ctype = TA_TUPLE_RESULT_TYPES.get(ta_name, "std::tuple<double, double>")
2245
+ default = self._security_tuple_result_default(ctype, tuple_size)
2246
+ lines.append(f" {ctype} _req_sec_{sec_id} = {default};")
1341
2247
  else:
1342
- self._security_ohlc_hist_fields_by_sec[sec_id] = self._collect_security_ohlc_hist_fields(
1343
- expr_node
2248
+ self._security_ohlc_hist_fields_by_sec[sec_id] = (
2249
+ self._collect_security_ohlc_hist_fields_for_call(item)
1344
2250
  )
1345
2251
  lines.append(f" double _req_sec_{sec_id} = na<double>();")
1346
2252
  for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
2253
+ ctype = self._security_bar_hist_type(field)
1347
2254
  lines.append(
1348
- f" Series<double> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
2255
+ f" Series<{ctype}> {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};"
1349
2256
  )
1350
2257
  self._security_ta_hist_idx_by_sec[sec_id] = (
1351
2258
  self._collect_security_ta_hist_indices(expr_node)
1352
2259
  )
1353
2260
  for name in self._security_ta_hist_series_names(sec_id):
1354
2261
  lines.append(f" Series<double> {name}{_mbb};")
2262
+ self._emit_security_expr_hist_members(sec_id, expr_node, lines, _mbb)
1355
2263
 
1356
2264
  if self._security_calls:
1357
2265
  lines.append(' std::unordered_map<std::string, Series<double>> _security_helper_series_;')
@@ -1375,9 +2283,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1375
2283
  )
1376
2284
 
1377
2285
  # 3. TA members
1378
- for site in self.ctx.ta_call_sites:
2286
+ for _ta_idx, site in enumerate(self.ctx.ta_call_sites):
2287
+ if _ta_idx in self._dead_ta_indices:
2288
+ continue
1379
2289
  lines.append(f" {site.class_name} {site.member_name};")
1380
- if getattr(site, "is_static", False):
2290
+ if self._ta_site_uses_precalc(site):
1381
2291
  vtype = self._ta_return_type(site)
1382
2292
  lines.append(f" std::vector<{vtype}> _precalc_{site.member_name};")
1383
2293
  lines.append(" bool _use_precalc = false;")
@@ -1485,7 +2395,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1485
2395
  lines.append(f" Series<{cpp_type}> {safe}{_mbb};")
1486
2396
 
1487
2397
  # 7. Fixnan members
1488
- for site in self.ctx.fixnan_sites:
2398
+ for _fi_idx, site in enumerate(self.ctx.fixnan_sites):
2399
+ if _fi_idx in self._dead_fixnan_indices:
2400
+ continue
1489
2401
  cpp_type = PINE_TYPE_TO_CPP.get(site.pine_type, "double")
1490
2402
  lines.append(f" {cpp_type} {site.member_name} = na<{cpp_type}>();")
1491
2403
 
@@ -1499,12 +2411,26 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1499
2411
  else:
1500
2412
  lines.append(f" Series<double> {svar}{_mbb};")
1501
2413
 
2414
+ # 8a. Synthetic temporary history. Unlike the legacy function-local
2415
+ # static buffers, these members are value-copyable rollback state and
2416
+ # have one identity per source site / emitted UDF variant.
2417
+ for info in self._inline_history_members:
2418
+ lines.append(
2419
+ f" Series<{info['cpp_type']}> {info['member_name']}{_mbb};"
2420
+ )
2421
+
1502
2422
  # 8b. Global-scope non-var declarations as class members
1503
2423
  # (so user-defined functions can reference them)
1504
2424
  seen_global = set()
1505
2425
  for name, ptype in self.ctx.global_var_decls:
1506
2426
  if name in seen_global or name in self.ctx.series_vars or name in self._var_names:
1507
2427
  continue
2428
+ # De-hoisted UDT array-element alias (Pine reference semantics): the
2429
+ # in-loop VarDecl is emitted as a fresh ``UDT& z = arr[i];`` local
2430
+ # reference each iteration, so there is no persistent class member.
2431
+ if name in self._udt_array_get_ref_locals:
2432
+ seen_global.add(name)
2433
+ continue
1508
2434
  seen_global.add(name)
1509
2435
  safe = self._safe_name(name)
1510
2436
 
@@ -1558,6 +2484,16 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1558
2484
  emitted_clones.add(fresh_safe)
1559
2485
  self._emit_cloned_var_decl(orig_safe, fresh_safe, _mbb, lines)
1560
2486
 
2487
+ # 8c3. Fresh fixnan members for context-sensitive helper instances.
2488
+ # Each fresh instance gets its OWN previous-value member so two
2489
+ # call paths never share fixnan state (mirrors 8c2 for vars).
2490
+ for orig_site, fresh_safe in self._fresh_fixnan_members:
2491
+ if fresh_safe in emitted_clones:
2492
+ continue
2493
+ emitted_clones.add(fresh_safe)
2494
+ cpp_type = PINE_TYPE_TO_CPP.get(orig_site.pine_type, "double")
2495
+ lines.append(f" {cpp_type} {fresh_safe} = na<{cpp_type}>();")
2496
+
1561
2497
  # 8d. Drawing-objects-as-data arenas (gated on _uses_drawing so
1562
2498
  # non-drawing strategies emit byte-identical C++). Each arena is a
1563
2499
  # per-strategy member -> reset-per-run is automatic. Caps come from
@@ -1605,6 +2541,16 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1605
2541
 
1606
2542
  lines.append("")
1607
2543
 
2544
+ # 9d. Historical execution rollback checkpoint. Derive the member
2545
+ # inventory from the declarations above so every future generated
2546
+ # state category is captured automatically (or generation fails loudly
2547
+ # if it introduces an unfamiliar declaration form).
2548
+ _script_state_members = self._collect_script_state_members(
2549
+ lines[_script_state_decl_start:-1]
2550
+ )
2551
+ self._emit_script_state_hooks(lines, _script_state_members)
2552
+ lines.append("")
2553
+
1608
2554
  # 9. Constructor with TA initializer list
1609
2555
  self._emit_constructor(lines)
1610
2556
  lines.append("")
@@ -1612,6 +2558,12 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1612
2558
  # 10. User-defined functions (with per-call-site variants for functions
1613
2559
  # containing TA calls OR series variables that need isolation)
1614
2560
  for fi in self.ctx.func_infos:
2561
+ # Dead-code user functions (defined but never called, with TA
2562
+ # state whose ctor args can't be sized) are skipped entirely —
2563
+ # their bodies reference TA members we no longer emit, and the
2564
+ # functions never run anyway.
2565
+ if fi.name in self._dead_func_names:
2566
+ continue
1615
2567
  total_cs = self.ctx.func_call_site_counts.get(fi.name, 0)
1616
2568
  has_ta = fi.name in self.ctx.func_ta_ranges
1617
2569
  has_series = fi.name in self.ctx.func_series_vars or fi.name in self.ctx.func_var_members
@@ -1762,7 +2714,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1762
2714
  if self._is_chart_point_callee(node.callee):
1763
2715
  return False
1764
2716
  func_name, namespace = self._resolve_callee(node.callee)
1765
- if func_name in SKIP_FUNC_NAMES:
2717
+ if namespace is None and func_name in SKIP_FUNC_NAMES:
1766
2718
  return True
1767
2719
  if namespace in SKIP_NAMESPACES:
1768
2720
  return True
@@ -1810,77 +2762,335 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1810
2762
  # _infer_type / _infer_tuple_types live on TypeInferer — see codegen/types.py.
1811
2763
  # _is_compile_time_value lives on TaSiteHelper — see codegen/ta.py.
1812
2764
 
2765
+ # Pine ``timeframe.<member>`` -> C++ runtime expression. Mirrors the
2766
+ # mapping in ``visit_expr._visit_member_access`` so a stable timeframe
2767
+ # fragment embedded in a TA ctor reset renders to the same C++ the
2768
+ # expression visitor would emit for a direct ``timeframe.*`` read.
2769
+ _TIMEFRAME_MEMBER_CPP: dict[str, str] = {
2770
+ "period": "script_tf_",
2771
+ "main_period": "main_period()",
2772
+ "multiplier": "tf_multiplier(script_tf_)",
2773
+ "isintraday": "tf_is_intraday(script_tf_)",
2774
+ "isminutes": "(tf_is_intraday(script_tf_) && !tf_is_seconds(script_tf_))",
2775
+ "isdaily": "tf_is_daily(script_tf_)",
2776
+ "isweekly": "tf_is_weekly(script_tf_)",
2777
+ "ismonthly": "tf_is_monthly(script_tf_)",
2778
+ "isdwm": "(tf_is_daily(script_tf_) || tf_is_weekly(script_tf_) || tf_is_monthly(script_tf_))",
2779
+ "isseconds": "tf_is_seconds(script_tf_)",
2780
+ "in_seconds": "tf_to_seconds(script_tf_)",
2781
+ "isticks": "false",
2782
+ }
2783
+
2784
+ # Pine ``math.<member>`` -> C++ form. Function members map to ``std::*``;
2785
+ # constants map to their engine-side macro / literal.
2786
+ _MATH_MEMBER_CPP: dict[str, str] = {
2787
+ "pi": "M_PI", "e": "M_E", "phi": "1.618033988749895",
2788
+ "rphi": "0.6180339887498949",
2789
+ "abs": "std::abs", "max": "std::max", "min": "std::min",
2790
+ "round": "std::round", "floor": "std::floor", "ceil": "std::ceil",
2791
+ "sqrt": "std::sqrt", "log": "std::log", "log10": "std::log10",
2792
+ "exp": "std::exp", "pow": "std::pow",
2793
+ "sin": "std::sin", "cos": "std::cos", "tan": "std::tan",
2794
+ "asin": "std::asin", "acos": "std::acos", "atan": "std::atan",
2795
+ "sign": "(double)([] (double _v) { return (_v>0) - (_v<0); })",
2796
+ }
2797
+
2798
+ # Pine logical operators (word form) -> C++ operator, used when rendering
2799
+ # a stable runtime expression. Matched with word boundaries.
2800
+ _PINE_LOGICAL_OPS: dict[str, str] = {"and": "&&", "or": "||", "not": "!"}
2801
+
2802
+ def _render_inline_input_calls(self, expr_str: str) -> tuple[str, bool]:
2803
+ """Render inline ``input(...)`` / ``input.<type>(...)`` calls in a TA
2804
+ ctor-arg expression string to override-aware ``get_input_*()`` reads.
2805
+
2806
+ A bare input expression passed straight as a length argument
2807
+ (``adx(input(15), input(15))``) reaches the reset path as the raw call
2808
+ spelling ``input(15)`` because the analyzer's param-substitution has no
2809
+ intermediate variable to record in ``_input_backed_vars``. This helper
2810
+ finds each such call (balanced parens, ``input`` optionally followed by
2811
+ ``.<type>``), re-parses it into a FuncCall, and renders it via the same
2812
+ ``_render_input_value`` used for ordinary input var reads.
2813
+
2814
+ Returns ``(rewritten_str, found_any)``. When no inline input call is
2815
+ present, the string is returned unchanged with ``found_any=False``.
2816
+ """
2817
+ import re
2818
+ # Locate ``input`` (as a word, not a substring of get_input_int etc.)
2819
+ # optionally followed by ``.<member>``, then a ``(`` opening a balanced
2820
+ # argument list.
2821
+ out = expr_str
2822
+ found = False
2823
+ idx = 0
2824
+ while idx < len(out):
2825
+ m = re.search(r"\binput\b", out[idx:])
2826
+ if m is None:
2827
+ break
2828
+ start = idx + m.start()
2829
+ # Reject a match that is part of a longer identifier
2830
+ # (e.g. ``get_input_int``) — the \b guard above already handles
2831
+ # alphanumerics, but be defensive.
2832
+ if start > 0 and (out[start - 1].isalnum() or out[start - 1] == "_"):
2833
+ idx = start + len("input")
2834
+ continue
2835
+ j = start + len("input")
2836
+ # Optional ``.<member>`` for the typed form ``input.int(...)``.
2837
+ member = None
2838
+ if j < len(out) and out[j] == ".":
2839
+ k = j + 1
2840
+ nm_start = k
2841
+ while k < len(out) and (out[k].isalnum() or out[k] == "_"):
2842
+ k += 1
2843
+ if k > nm_start:
2844
+ member = out[nm_start:k]
2845
+ j = k
2846
+ # Must be followed by ``(`` to be a call.
2847
+ if j >= len(out) or out[j] != "(":
2848
+ idx = j
2849
+ continue
2850
+ # Walk the balanced parens to extract the call substring.
2851
+ depth = 0
2852
+ k = j
2853
+ while k < len(out):
2854
+ ch = out[k]
2855
+ if ch == "(":
2856
+ depth += 1
2857
+ elif ch == ")":
2858
+ depth -= 1
2859
+ if depth == 0:
2860
+ k += 1
2861
+ break
2862
+ k += 1
2863
+ if depth != 0:
2864
+ # Unbalanced — bail on this match.
2865
+ idx = j + 1
2866
+ continue
2867
+ call_src = out[start:k]
2868
+ try:
2869
+ from ..lexer import Lexer
2870
+ from ..parser import Parser
2871
+ tokens = Lexer(call_src).tokenize()
2872
+ node = Parser(tokens, source=call_src)._parse_expression()
2873
+ if not self._is_input_call(node):
2874
+ idx = k
2875
+ continue
2876
+ func_name_i, namespace_i = self._resolve_callee(node.callee)
2877
+ # Inline inputs have no enclosing var; reuse the default
2878
+ # value as a synthetic title key so distinct defaults get
2879
+ # distinct input controls (and identical defaults collapse,
2880
+ # which is correct since they resolve to the same value).
2881
+ default_node = self._get_input_default(node)
2882
+ synth_title = self._visit_expr(default_node) if default_node is not None else ""
2883
+ title = self._get_input_title(node, var_name=None)
2884
+ if not title:
2885
+ title = synth_title
2886
+ rendered = self._render_input_value(node, func_name_i, namespace_i, title)
2887
+ except Exception:
2888
+ idx = k
2889
+ continue
2890
+ out = out[:start] + rendered + out[k:]
2891
+ found = True
2892
+ idx = start + len(rendered)
2893
+ return out, found
2894
+
1813
2895
  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.
2896
+ """Convert a TA ctor-arg string into its runtime C++ expression.
2897
+
2898
+ Returns the runtime expression (e.g.
2899
+ ``get_input_int("MACD Fast", 12)`` or a ternary / math expression
2900
+ over such reads and ``timeframe.*`` members) when the ctor arg
2901
+ depends on a stable runtime scalar — an input-backed variable, a
2902
+ ``timeframe.*`` member, or arithmetic / ternaries / casts over
2903
+ those. Returns None for pure literals or expressions that contain
2904
+ any unrecognised (potentially series) identifier, so the caller
2905
+ (the TA ctor guard) rejects them loudly instead of silently
2906
+ emitting period 1.
1820
2907
  """
1821
2908
  import re
1822
2909
  ident_re = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
1823
2910
 
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.
2911
+ # Expand class-scope derived vars (``wilderLen`` -> ``(rsiLen * 2 - 1)``,
2912
+ # ``fastPeriod`` -> ``(isM5 ? ... : ...)``) to their raw RHS so input
2913
+ # leaves become get_input_*() reads below. Recursive (bounded) to
2914
+ # handle chains of derived vars; guards against cycles.
1828
2915
  def _expand_derived(s: str, seen: frozenset = frozenset(), depth: int = 0) -> str:
1829
2916
  if depth > 32:
1830
2917
  return s
1831
- def _rep(m: re.Match) -> str:
1832
- nm = m.group(0)
2918
+ def _rep(p: re.Match) -> str:
2919
+ nm = p.group(0)
1833
2920
  if nm in self._derived_input_expr and nm not in seen:
1834
2921
  inner = self._derived_input_expr[nm]
1835
2922
  return "(" + _expand_derived(inner, seen | {nm}, depth + 1) + ")"
1836
2923
  return nm
1837
2924
  return ident_re.sub(_rep, s)
1838
2925
 
1839
- arg_str = _expand_derived(arg_str)
2926
+ expanded = _expand_derived(arg_str)
2927
+
2928
+ tokens = set(ident_re.findall(expanded))
2929
+
2930
+ # Gate: every identifier token must be renderable. If any token is an
2931
+ # unrecognised bare identifier (not an input, not a known const, not
2932
+ # a structural keyword / namespace prefix, not a stable tracked var
2933
+ # that we already expanded), we conservatively refuse — that identifier
2934
+ # would otherwise leak through as an undeclared C++ symbol, or worse,
2935
+ # a series var that should have been rejected by the ctor guard.
2936
+ structural = (set(self._PINE_LOGICAL_OPS)
2937
+ | {"timeframe", "math", "syminfo",
2938
+ "int", "float", "bool", "string",
2939
+ "true", "false", "na"})
2940
+ member_tokens = (set(self._TIMEFRAME_MEMBER_CPP)
2941
+ | set(self._MATH_MEMBER_CPP))
2942
+ renderable = (self._input_backed_vars
2943
+ | self._stable_runtime_vars
2944
+ | structural
2945
+ | member_tokens)
2946
+ leftover = tokens - renderable
2947
+ # Known compile-time consts that survived expansion (e.g. ``pi`` was
2948
+ # NOT tracked as a Python value but its name token is a stable var
2949
+ # already; pure numeric names are in _known_vars and covered above).
2950
+ leftover = {t for t in leftover if t not in self._known_vars}
2951
+ # Inline ``input(...)`` / ``input.<t>(...)`` calls (a bare input
2952
+ # expression passed straight as a length arg, e.g.
2953
+ # ``adx(input(15), input(15))``) are re-parsed and rendered below,
2954
+ # after the gate. ``input`` is the only token they contribute, so
2955
+ # allow it through the gate here.
2956
+ leftover.discard("input")
2957
+ if leftover:
2958
+ return None
1840
2959
 
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:
2960
+ # Must depend on at least one runtime component (input-backed var, a
2961
+ # timeframe reference, or an inline input() call); otherwise it's a
2962
+ # pure compile-time expr and no reset is needed (the ctor-init literal
2963
+ # is correct).
2964
+ has_input = any(t in self._input_backed_vars for t in tokens)
2965
+ has_timeframe = "timeframe" in tokens
2966
+ has_inline_input = "input" in tokens
2967
+ if not (has_input or has_timeframe or has_inline_input):
1844
2968
  return None
1845
2969
 
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)
2970
+ # Preferred path: re-parse the (gate-approved, grouping-faithful)
2971
+ # expression and lower it through the SAME expression visitor the
2972
+ # statement path uses. Operator grouping and Pine numeric typing
2973
+ # (``/`` always yields float; ``(double)`` coercion) are then correct
2974
+ # by construction — reused from ``_visit_binop``, not re-derived here.
2975
+ # Falls back to the legacy token-substitution renderer below only if
2976
+ # re-parse / lowering unexpectedly fails, so a working site is never
2977
+ # worse off than before this change.
2978
+ lowered = self._lower_reset_expr_via_visitor(expanded)
2979
+ if lowered is not None:
2980
+ return lowered
2981
+
2982
+ # Render inline input() calls to override-aware get_input_*() reads
2983
+ # now that the gate has accepted the expression. Done after the gate
2984
+ # so the rendered getter tokens (get_input_int, ...) do not have to
2985
+ # be added to ``renderable``.
2986
+ if has_inline_input:
2987
+ expanded, _ = self._render_inline_input_calls(expanded)
2988
+
2989
+ expr = expanded
2990
+
2991
+ # 1) timeframe.<member> -> C++ (before ident substitution so the
2992
+ # member names don't get caught by the identifier pass). Use a
2993
+ # targeted regex so e.g. ``isminutes`` is not confused with
2994
+ # ``ismonthly``.
2995
+ def _tf_rep(p: re.Match) -> str:
2996
+ mem = p.group(1)
2997
+ return self._TIMEFRAME_MEMBER_CPP.get(mem, p.group(0))
2998
+ # ``timeframe.in_seconds()`` is a function-form member in Pine (the
2999
+ # only one in the table); its C++ form ``tf_to_seconds(script_tf_)``
3000
+ # is already a complete call, so consume the Pine ``()`` to avoid a
3001
+ # double-call ``tf_to_seconds(script_tf_)()``. Property-form members
3002
+ # (``timeframe.isdaily``) never carry ``()`` so the optional group is
3003
+ # a no-op for them.
3004
+ expr = re.sub(r"\btimeframe\.(\w+)(?:\(\))?", _tf_rep, expr)
3005
+
3006
+ # 2) math.<member> -> C++ (constants + std::* functions).
3007
+ def _math_rep(p: re.Match) -> str:
3008
+ mem = p.group(1)
3009
+ return self._MATH_MEMBER_CPP.get(mem, p.group(0))
3010
+ expr = re.sub(r"\bmath\.(\w+)", _math_rep, expr)
3011
+
3012
+ # 3) Pine word-logical operators -> C++ operators (after timeframe/math
3013
+ # substitution so we don't rewrite inside their C++ expansions).
3014
+ for pine_op, cpp_op in self._PINE_LOGICAL_OPS.items():
3015
+ expr = re.sub(rf"\b{pine_op}\b", cpp_op, expr)
3016
+
3017
+ # 4) Substitute input-backed vars with override-aware get_input_*()
3018
+ # reads, and inline known compile-time consts (non-input) as literals.
3019
+ def _sub(p: re.Match) -> str:
3020
+ name = p.group(0)
3021
+ if name in self._input_backed_vars:
3022
+ call_node = self._input_var_to_call.get(name)
3023
+ if call_node is None:
3024
+ return name
3025
+ func_name_i, namespace_i = self._resolve_callee(call_node.callee)
3026
+ title = self._get_input_title(call_node, var_name=name)
3027
+ return self._render_input_value(call_node, func_name_i, namespace_i, title)
3028
+ if name in self._known_vars and name not in self._input_backed_vars:
3029
+ val = self._known_vars[name]
3030
+ if isinstance(val, bool):
3031
+ return "true" if val else "false"
3032
+ if isinstance(val, (int, float)):
3033
+ return str(val)
3034
+ return f'std::string("{self._cpp_string_escape(val)}")'
3035
+ return name
1875
3036
 
1876
3037
  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):
3038
+
3039
+ # 5) Pine auto-converts floats to ints for TA lengths; C++ does not.
3040
+ # If any math.* function appears (returns double) OR a timeframe.*
3041
+ # boolean is part of a ternary whose branches are doubles, wrap the
3042
+ # whole expression in an explicit int cast so the TA ctor gets an
3043
+ # integer length.
3044
+ had_math = "std::" in rewritten or bool(re.search(r"\btimeframe\b", expanded))
3045
+ if had_math:
1881
3046
  return f"(int)({rewritten})"
1882
3047
  return rewritten
1883
3048
 
3049
+ def _lower_reset_expr_via_visitor(self, expanded: str) -> str | None:
3050
+ """Re-parse a gate-approved, fully-expanded TA-length expression and
3051
+ lower it through the SAME expression visitor the statement path uses,
3052
+ so operator grouping and Pine numeric typing are preserved identically
3053
+ (Pine ``/`` always yields float; ``math.*`` returns double; branches are
3054
+ parenthesized). Reuses ``_visit_binop`` etc.; nothing is re-typed here.
3055
+
3056
+ Input-backed variables render as override-aware ``get_input_*()`` reads
3057
+ (not member refs) via ``_reset_input_getter_mode`` — the reset can run
3058
+ in ``evaluate_security`` before the input members are initialised, so it
3059
+ must not depend on their init order. Inline ``input(...)`` calls and
3060
+ ``math.*`` / ``timeframe.*`` members lower through the visitor's own
3061
+ handlers (same C++ the statement path would emit).
3062
+
3063
+ Returns None if re-parse / lowering fails, so the caller falls back to
3064
+ the legacy token-substitution renderer (never worse than before)."""
3065
+ try:
3066
+ from ..lexer import Lexer
3067
+ from ..parser import Parser
3068
+ tokens = Lexer(expanded).tokenize()
3069
+ node = Parser(tokens, source=expanded)._parse_expression()
3070
+ except Exception:
3071
+ return None
3072
+ prev = self._reset_input_getter_mode
3073
+ self._reset_input_getter_mode = True
3074
+ try:
3075
+ rendered = self._visit_expr(node)
3076
+ except Exception:
3077
+ return None
3078
+ finally:
3079
+ self._reset_input_getter_mode = prev
3080
+ if not rendered or "/* " in rendered:
3081
+ # Unknown/unhandled node leaked a placeholder — defer to legacy.
3082
+ return None
3083
+ # A TA length must be int. Truncate when the lowered form is
3084
+ # float-typed (a ``std::*`` call, a ``(double)`` division coercion, or a
3085
+ # ``timeframe.*`` helper — all timeframe helpers reference script_tf_).
3086
+ # A bare int / identifier length carries none of these and is left
3087
+ # unwrapped, so simple sites stay byte-identical to the legacy output.
3088
+ if ("std::" in rendered or "(double)" in rendered
3089
+ or "script_tf_" in rendered):
3090
+ return f"(int)({rendered})"
3091
+ return rendered
3092
+
3093
+
1884
3094
  def _collect_ta_runtime_resets(self) -> list[str]:
1885
3095
  """Collect reassignment statements for every TA object whose ctor args
1886
3096
  depend on an input-backed variable. Returned strings are raw C++
@@ -1892,7 +3102,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1892
3102
  resets: list[str] = []
1893
3103
 
1894
3104
  # Main-context TA objects
1895
- for site in self.ctx.ta_call_sites:
3105
+ for _ta_idx, site in enumerate(self.ctx.ta_call_sites):
3106
+ if _ta_idx in self._dead_ta_indices:
3107
+ continue
1896
3108
  if not site.ctor_args:
1897
3109
  continue
1898
3110
  runtime_args: list[str] = []
@@ -1910,15 +3122,38 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1910
3122
  f"{site.member_name} = {site.class_name}({', '.join(runtime_args)});"
1911
3123
  )
1912
3124
 
1913
- # Security-context TA copies (same ctor args as their main-context site)
3125
+ # Security-context TA copies. Normally these share the ctor args of
3126
+ # their main-context site, but a request.security nested in a helper
3127
+ # called at several sites is cloned per call site (distinct sec_id +
3128
+ # callsite_idx) while the shared TA site's ``ctor_args`` were resolved
3129
+ # ONCE (against the first call site). Every clone would then size its
3130
+ # indicator from call site 0's argument (e.g. four EMAs all pinned to
3131
+ # crossFastLen instead of the per-site fast/slow lengths). Resolve each
3132
+ # sec's ctor args against ITS call site by reusing the per-call-site
3133
+ # function-clone TA remap (identity for cs0 / non-clones, so all other
3134
+ # output stays byte-identical).
3135
+ sec_call_by_id = {it["sec_id"]: it for it in self._security_calls}
3136
+ ta_site_by_member = {s.member_name: s for s in self.ctx.ta_call_sites}
1914
3137
  for info in self._security_eval_info:
3138
+ sec_item = sec_call_by_id.get(info["sec_id"])
3139
+ sec_containing = (sec_item or {}).get("containing_func") or ""
3140
+ sec_cs_idx = (sec_item or {}).get("callsite_idx")
1915
3141
  for idx, variants in (info.get("ta_variants") or {}).items():
1916
3142
  site = self.ctx.ta_call_sites[idx]
1917
- if not site.ctor_args:
3143
+ ctor_site = site
3144
+ if sec_containing and sec_cs_idx is not None:
3145
+ remap = self._func_cs_ta_remap.get((sec_containing, sec_cs_idx))
3146
+ if remap:
3147
+ cloned_name = remap.get(site.member_name)
3148
+ if cloned_name and cloned_name != site.member_name:
3149
+ cand = ta_site_by_member.get(cloned_name)
3150
+ if cand is not None:
3151
+ ctor_site = cand
3152
+ if not ctor_site.ctor_args:
1918
3153
  continue
1919
3154
  runtime_args = []
1920
3155
  any_runtime = False
1921
- for a in site.ctor_args:
3156
+ for a in ctor_site.ctor_args:
1922
3157
  rt = self._runtime_ctor_arg_for_reset(a)
1923
3158
  if rt is not None:
1924
3159
  runtime_args.append(rt)