@pineforge/codegen-pyodide 0.10.0 → 0.10.2

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.
@@ -58,11 +58,13 @@ tables and types come from ``codegen/tables.py``, ``..ast_nodes``,
58
58
 
59
59
  from __future__ import annotations
60
60
 
61
+ from dataclasses import replace
62
+
61
63
  from ..ast_nodes import (
62
64
  ASTNode, Assignment, BinOp, BoolLiteral, BreakStmt, ContinueStmt, ExprStmt,
63
65
  ForStmt, ForInStmt, FuncCall, FuncDef, Identifier, IfStmt, MemberAccess,
64
- NumberLiteral, StringLiteral, Subscript, SwitchStmt, Ternary, TupleAssign,
65
- TupleLiteral, UnaryOp, VarDecl, WhileStmt,
66
+ NaLiteral, NumberLiteral, StringLiteral, Subscript, SwitchStmt, Ternary,
67
+ TupleAssign, TupleLiteral, UnaryOp, VarDecl, WhileStmt,
66
68
  )
67
69
  from ..analyzer import (
68
70
  FuncInfo, TACallSite, TA_MULTI_CTOR, TA_NO_CTOR, TA_PERIOD_ARG,
@@ -76,6 +78,19 @@ from .tables import (
76
78
  )
77
79
 
78
80
 
81
+ class _SecurityHelperArgumentFrame(dict):
82
+ """Callee bindings whose argument ASTs belong to the caller scope.
83
+
84
+ A callee may reuse a caller parameter name. Keeping the caller binding
85
+ stack with the argument prevents that new callee frame from capturing
86
+ identifiers inside its own argument expression and falsely recursing.
87
+ """
88
+
89
+ def __init__(self, values: dict, caller_stack: tuple[dict, ...]) -> None:
90
+ super().__init__(values)
91
+ self.caller_stack = caller_stack
92
+
93
+
79
94
  class SecurityEmitter:
80
95
  """Mixin owning ``request.security()`` lowering: evaluators, dispatch,
81
96
  rebind/binding/TA-variant machinery, and the per-call helper plan.
@@ -96,14 +111,22 @@ class SecurityEmitter:
96
111
  """
97
112
  if isinstance(tf_node, StringLiteral):
98
113
  return tf_node.value, None
114
+ if isinstance(tf_node, SwitchStmt):
115
+ # Keep diagnostics from the registration-time switch renderer
116
+ # visible; the broad expression fallback below intentionally
117
+ # catches ordinary unresolved expressions.
118
+ return None, self._security_tf_runtime_expr(tf_node)
99
119
  if isinstance(tf_node, Identifier):
100
120
  name = tf_node.name
101
121
  if name in self._timeframe_period_vars:
102
122
  return None, "script_tf_"
103
123
  if (name in self._known_vars and name not in self._input_backed_vars
124
+ and not self._known_var_is_lexically_shadowed(name)
104
125
  and isinstance(self._known_vars[name], str)):
105
126
  return self._known_vars[name], None
106
- if name in self._input_backed_vars and name in self._input_var_to_call:
127
+ if (name in self._input_backed_vars
128
+ and name in self._input_var_to_call
129
+ and not self._known_var_is_lexically_shadowed(name)):
107
130
  return None, self._visit_expr(self._input_var_to_call[name])
108
131
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
109
132
  if name in global_expr_map:
@@ -148,9 +171,82 @@ class SecurityEmitter:
148
171
  """
149
172
  if node is None:
150
173
  return None
174
+ if isinstance(node, SwitchStmt):
175
+ return self._security_tf_switch_runtime_expr(node, resolving or set())
151
176
  substituted = self._substitute_tf_input_reads(node, resolving or set())
152
177
  return self._visit_expr(substituted)
153
178
 
179
+ def _security_tf_switch_runtime_expr(
180
+ self, node: SwitchStmt, resolving: set[str]
181
+ ) -> str:
182
+ """Render a pure switch expression for evaluator registration.
183
+
184
+ Pine parses ``tf = switch timeframe.period ...`` as a ``SwitchStmt``
185
+ even though it is the right-hand side of a declaration. The regular
186
+ expression visitor deliberately does not render statement nodes, so
187
+ passing that alias to ``request.security`` previously produced the
188
+ truthy placeholder ``/* unknown */`` in generated C++. Registration
189
+ only needs the value selection, which can be represented as nested
190
+ conditional expressions over setup-time-safe leaves such as
191
+ ``script_tf_`` and direct input getters.
192
+
193
+ Only pure, single-expression arms with an explicit default are accepted
194
+ here. Other shapes cannot be registered deterministically and produce
195
+ a clear codegen diagnostic.
196
+ """
197
+
198
+ def arm_value(body: list) -> str:
199
+ if len(body) != 1 or not isinstance(body[0], ExprStmt):
200
+ self._codegen_error(
201
+ node,
202
+ "request.security timeframe switch arms must contain one expression",
203
+ hint=(
204
+ "Compute multi-statement timeframe logic before the "
205
+ "request.security call or rewrite it as pure switch arms."
206
+ ),
207
+ )
208
+ value = self._security_tf_runtime_expr(body[0].expr, resolving)
209
+ if value is None:
210
+ self._codegen_error(
211
+ node,
212
+ "request.security timeframe switch arm could not be resolved at setup",
213
+ )
214
+ return value
215
+
216
+ if not node.default_body:
217
+ self._codegen_error(
218
+ node,
219
+ "request.security timeframe switch requires a default arm",
220
+ hint=(
221
+ "An unmatched Pine switch yields na; add an explicit default "
222
+ "timeframe so the evaluator can be registered deterministically."
223
+ ),
224
+ )
225
+ result = arm_value(node.default_body)
226
+
227
+ selector = None
228
+ if node.expr is not None:
229
+ selector = self._security_tf_runtime_expr(node.expr, resolving)
230
+ if selector is None:
231
+ self._codegen_error(
232
+ node,
233
+ "request.security timeframe switch selector could not be resolved at setup",
234
+ )
235
+
236
+ for case_expr, body in reversed(node.cases):
237
+ value = arm_value(body)
238
+ case = self._security_tf_runtime_expr(case_expr, resolving)
239
+ if case is None:
240
+ self._codegen_error(
241
+ node,
242
+ "request.security timeframe switch case could not be resolved at setup",
243
+ )
244
+ condition = (
245
+ f"(({selector}) == ({case}))" if selector is not None else case
246
+ )
247
+ result = f"(({condition}) ? ({value}) : ({result}))"
248
+ return result
249
+
154
250
  def _substitute_tf_input_reads(self, node, resolving: set[str]):
155
251
  """Return ``node`` with input-derived leaves rewritten to expressions
156
252
  that are valid at security-registration time (before ``on_bar()``
@@ -175,12 +271,24 @@ class SecurityEmitter:
175
271
  if isinstance(node, Identifier):
176
272
  name = node.name
177
273
  if name in self._timeframe_period_vars:
178
- return MemberAccess(object=Identifier(name="timeframe"), member="period")
179
- if name in self._input_backed_vars and name in self._input_var_to_call:
274
+ return MemberAccess(
275
+ object=Identifier(name="timeframe", loc=node.loc),
276
+ member="period",
277
+ loc=node.loc,
278
+ annotations=node.annotations,
279
+ )
280
+ if (name in self._input_backed_vars
281
+ and name in self._input_var_to_call
282
+ and not self._known_var_is_lexically_shadowed(name)):
180
283
  return self._input_var_to_call[name]
181
284
  if (name in self._known_vars and name not in self._input_backed_vars
285
+ and not self._known_var_is_lexically_shadowed(name)
182
286
  and isinstance(self._known_vars[name], str)):
183
- return StringLiteral(value=self._known_vars[name])
287
+ return StringLiteral(
288
+ value=self._known_vars[name],
289
+ loc=node.loc,
290
+ annotations=node.annotations,
291
+ )
184
292
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
185
293
  if name in global_expr_map and name not in resolving:
186
294
  return self._substitute_tf_input_reads(
@@ -192,18 +300,18 @@ class SecurityEmitter:
192
300
  fv = self._substitute_tf_input_reads(node.false_val, resolving)
193
301
  if cond is node.condition and tv is node.true_val and fv is node.false_val:
194
302
  return node
195
- return Ternary(condition=cond, true_val=tv, false_val=fv)
303
+ return replace(node, condition=cond, true_val=tv, false_val=fv)
196
304
  if isinstance(node, BinOp):
197
305
  left = self._substitute_tf_input_reads(node.left, resolving)
198
306
  right = self._substitute_tf_input_reads(node.right, resolving)
199
307
  if left is node.left and right is node.right:
200
308
  return node
201
- return BinOp(left=left, op=node.op, right=right)
309
+ return replace(node, left=left, right=right)
202
310
  if isinstance(node, UnaryOp):
203
311
  operand = self._substitute_tf_input_reads(node.operand, resolving)
204
312
  if operand is node.operand:
205
313
  return node
206
- return UnaryOp(op=node.op, operand=operand)
314
+ return replace(node, operand=operand)
207
315
  if isinstance(node, FuncCall):
208
316
  new_args = [self._substitute_tf_input_reads(a, resolving) for a in node.args]
209
317
  new_kwargs = {
@@ -217,13 +325,19 @@ class SecurityEmitter:
217
325
  )
218
326
  if unchanged:
219
327
  return node
220
- return FuncCall(callee=node.callee, args=new_args, kwargs=new_kwargs)
328
+ # Preserve the source span and annotations of the original call.
329
+ # Namespace/receiver resolution uses that provenance to decide
330
+ # whether a same-named global binding was visible at this call's
331
+ # source position. Rebuilding a bare FuncCall here used to turn
332
+ # the span into synthetic 1:1 and could let a later ``map`` global
333
+ # capture this earlier built-in ``map.get(...)``.
334
+ return replace(node, args=new_args, kwargs=new_kwargs)
221
335
  if isinstance(node, Subscript):
222
336
  obj = self._substitute_tf_input_reads(node.object, resolving)
223
337
  idx = self._substitute_tf_input_reads(node.index, resolving)
224
338
  if obj is node.object and idx is node.index:
225
339
  return node
226
- return Subscript(object=obj, index=idx)
340
+ return replace(node, object=obj, index=idx)
227
341
  return node
228
342
 
229
343
  def _resolve_param_tf_from_callsites(self, func_name: str, param_name: str):
@@ -275,6 +389,9 @@ class SecurityEmitter:
275
389
  "expr_node": item.expression,
276
390
  "returns_tuple": item.returns_tuple,
277
391
  "tuple_size": item.tuple_size,
392
+ "tuple_element_types": tuple(
393
+ getattr(item, "tuple_element_types", ()) or ()
394
+ ),
278
395
  "gaps_node": item.gaps,
279
396
  "lookahead_node": item.lookahead,
280
397
  "ta_range": item.ta_range,
@@ -291,6 +408,7 @@ class SecurityEmitter:
291
408
  "expr_node": item[2] if len(item) > 2 else None,
292
409
  "returns_tuple": item[3] if len(item) > 3 else False,
293
410
  "tuple_size": item[4] if len(item) > 4 else 0,
411
+ "tuple_element_types": (),
294
412
  "gaps_node": item[5] if len(item) > 5 else None,
295
413
  "lookahead_node": item[6] if len(item) > 6 else None,
296
414
  "ta_range": item[7] if len(item) > 7 else None,
@@ -371,6 +489,22 @@ class SecurityEmitter:
371
489
  name: str,
372
490
  helper_binding_stack: tuple[dict[str, ASTNode], ...] | None,
373
491
  ):
492
+ resolved = self._security_lookup_helper_binding_context(
493
+ name, helper_binding_stack
494
+ )
495
+ return resolved[0] if resolved is not None else None
496
+
497
+ def _security_lookup_helper_binding_context(
498
+ self,
499
+ name: str,
500
+ helper_binding_stack: tuple[dict[str, ASTNode], ...] | None,
501
+ ):
502
+ """Return ``(bound_value, lexical_stack_for_value)``.
503
+
504
+ Ordinary helper-local bindings are authored in the current stack.
505
+ Callee argument bindings are authored in the caller stack captured by
506
+ ``_SecurityHelperArgumentFrame`` and must be expanded there.
507
+ """
374
508
  if not helper_binding_stack:
375
509
  return None
376
510
  for frame in reversed(helper_binding_stack):
@@ -379,7 +513,12 @@ class SecurityEmitter:
379
513
  bound = frame[name]
380
514
  if isinstance(bound, Identifier) and bound.name == name:
381
515
  continue
382
- return bound
516
+ lexical_stack = (
517
+ frame.caller_stack
518
+ if isinstance(frame, _SecurityHelperArgumentFrame)
519
+ else helper_binding_stack
520
+ )
521
+ return bound, lexical_stack
383
522
  return None
384
523
 
385
524
  def _literal_int_for_security_index(self, node) -> int | None:
@@ -402,11 +541,11 @@ class SecurityEmitter:
402
541
  return None
403
542
  return None
404
543
 
405
- # In PineForge batch backtests these barstate flags are compile-time
406
- # constants (see support_checker / codegen.visit_expr barstate emission):
407
- # every bar is historical, none realtime/last. The other flags
408
- # (isfirst/isnew/isconfirmed) depend on runtime tick state, so they are
409
- # deliberately absent and leave a fold "unknown".
544
+ # Narrow approximations used only while resolving a request.security()
545
+ # history index to a literal. This is not chart-scope barstate lowering:
546
+ # visit_expr emits runtime state for islast/islastconfirmedhistory, whereas
547
+ # this security-index folder currently treats them as false. Other flags
548
+ # (isfirst/isnew/isconfirmed) remain unknown and are deliberately absent.
410
549
  _SECURITY_CONST_BARSTATE = {
411
550
  "isrealtime": False,
412
551
  "islast": False,
@@ -422,10 +561,10 @@ class SecurityEmitter:
422
561
  ) -> bool | None:
423
562
  """Fold a boolean expression to a compile-time constant, or None.
424
563
 
425
- Only reduces expressions whose non-literal leaves are the batch-constant
426
- barstate flags above; anything runtime-dependent returns None. Used to
427
- resolve a request.security history index expressed as
428
- ``barstate.isrealtime ? 1 : 0`` and friends."""
564
+ Only reduces expressions whose non-literal leaves have a narrow
565
+ request.security history-index approximation above; anything else
566
+ returns None. Used to resolve a request.security history index
567
+ expressed as ``barstate.isrealtime ? 1 : 0`` and friends."""
429
568
  if resolving is None:
430
569
  resolving = set()
431
570
  if isinstance(node, BoolLiteral):
@@ -452,16 +591,33 @@ class SecurityEmitter:
452
591
  return None
453
592
  return (left and right) if node.op == "and" else (left or right)
454
593
  if isinstance(node, Identifier):
455
- bound = self._security_lookup_helper_binding(node.name, helper_binding_stack)
456
- if bound is not None:
594
+ binding = None
595
+ if not self._security_identifier_is_global_binding(node):
596
+ binding = self._security_lookup_helper_binding_context(
597
+ node.name, helper_binding_stack
598
+ )
599
+ if binding is not None:
600
+ bound, bound_stack = binding
601
+ if isinstance(bound, str):
602
+ return None
457
603
  return self._fold_security_const_bool(
458
- bound, helper_binding_stack, resolving
604
+ bound, bound_stack, resolving
605
+ )
606
+ param_key = f"param:{id(node)}"
607
+ param_binding = self._security_index_param_callsite_binding(node)
608
+ if param_binding is not None and param_key not in resolving:
609
+ return self._fold_security_const_bool(
610
+ param_binding, (), resolving | {param_key}
459
611
  )
460
612
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
461
- if node.name in global_expr_map and node.name not in resolving:
613
+ if (
614
+ self._security_identifier_is_global_binding(node)
615
+ and node.name in global_expr_map
616
+ and node.name not in resolving
617
+ ):
462
618
  resolving.add(node.name)
463
619
  out = self._fold_security_const_bool(
464
- global_expr_map[node.name], helper_binding_stack, resolving
620
+ global_expr_map[node.name], (), resolving
465
621
  )
466
622
  resolving.remove(node.name)
467
623
  return out
@@ -477,27 +633,44 @@ class SecurityEmitter:
477
633
 
478
634
  Extends ``_literal_int_for_security_index`` by also resolving the index
479
635
  through helper-parameter bindings and global aliases and constant-folding
480
- a ternary whose condition is a batch-constant barstate flag (e.g.
481
- ``idxHigher = barstate.isrealtime ? 1 : 0`` -> 0). A literal index short-
482
- circuits on the first line, so behaviour is unchanged for every already-
483
- literal index. Returns None when the index cannot be reduced to a
484
- compile-time literal, so the caller keeps its existing rejection."""
636
+ a ternary whose condition has a security-index barstate approximation
637
+ (e.g. ``idxHigher = barstate.isrealtime ? 1 : 0`` -> 0). A literal index
638
+ short-circuits on the first line, so behaviour is unchanged for every
639
+ already-literal index. Returns None when the index cannot be reduced to
640
+ a compile-time literal, so the caller keeps its existing rejection."""
485
641
  direct = self._literal_int_for_security_index(node)
486
642
  if direct is not None:
487
643
  return direct
488
644
  if resolving is None:
489
645
  resolving = set()
490
646
  if isinstance(node, Identifier):
491
- bound = self._security_lookup_helper_binding(node.name, helper_binding_stack)
492
- if bound is not None:
647
+ binding = None
648
+ if not self._security_identifier_is_global_binding(node):
649
+ binding = self._security_lookup_helper_binding_context(
650
+ node.name, helper_binding_stack
651
+ )
652
+ if binding is not None:
653
+ bound, bound_stack = binding
654
+ if isinstance(bound, str):
655
+ return None
493
656
  return self._resolve_security_index_literal(
494
- bound, helper_binding_stack, resolving
657
+ bound, bound_stack, resolving
658
+ )
659
+ param_key = f"param:{id(node)}"
660
+ param_binding = self._security_index_param_callsite_binding(node)
661
+ if param_binding is not None and param_key not in resolving:
662
+ return self._resolve_security_index_literal(
663
+ param_binding, (), resolving | {param_key}
495
664
  )
496
665
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
497
- if node.name in global_expr_map and node.name not in resolving:
666
+ if (
667
+ self._security_identifier_is_global_binding(node)
668
+ and node.name in global_expr_map
669
+ and node.name not in resolving
670
+ ):
498
671
  resolving.add(node.name)
499
672
  out = self._resolve_security_index_literal(
500
- global_expr_map[node.name], helper_binding_stack, resolving
673
+ global_expr_map[node.name], (), resolving
501
674
  )
502
675
  resolving.remove(node.name)
503
676
  return out
@@ -512,35 +685,395 @@ class SecurityEmitter:
512
685
  )
513
686
  return None
514
687
 
515
- def _collect_security_ohlc_hist_fields(self, node) -> set[str]:
516
- """Which security bar fields need HTF history (subscript index >= 1)."""
688
+ def _security_identifier_is_global_binding(self, node: Identifier) -> bool:
689
+ """Whether this exact identifier AST resolved to a global binding.
690
+
691
+ ``global_expr_map`` is keyed only by spelling. Consulting it without
692
+ node provenance lets a UDF parameter, block local, or loop iterator
693
+ capture a same-named global input after analysis scopes have unwound.
694
+ """
695
+ scopes = getattr(self.ctx, "identifier_binding_scopes", {}) or {}
696
+ return scopes.get(id(node)) == "global"
697
+
698
+ def _security_index_param_callsite_binding(self, node: Identifier):
699
+ """Resolve one UDF parameter index from its call sites, conservatively.
700
+
701
+ A request.security evaluator is a class method, so a parameter of the
702
+ UDF that *contains* the request call is not otherwise in scope there.
703
+ Reuse the established timeframe-callsite model for the narrow offset
704
+ case: one call site is exact; several are accepted only for the same
705
+ literal or the same proven-global identifier. Mixed bindings fail
706
+ closed instead of sharing one evaluator across different offsets.
707
+ """
708
+ scopes = getattr(self.ctx, "identifier_binding_scopes", {}) or {}
709
+ scope = scopes.get(id(node))
710
+ if not isinstance(scope, str) or not scope.startswith("func_"):
711
+ return None
712
+ func_name = scope[5:]
713
+ func_info = self._func_info_map.get(func_name)
714
+ if (
715
+ func_info is None
716
+ or func_info.node is None
717
+ or node.name not in func_info.node.params
718
+ ):
719
+ return None
720
+ # A parameter is only equal to its call-site argument until the UDF
721
+ # reassigns it. Specializing a later ``ta.*[param]`` from the authored
722
+ # call argument would otherwise ignore legal ``param := ...`` writes
723
+ # and silently select the wrong requested-context history bar.
724
+ for child in self._walk_ast(func_info.node):
725
+ if (
726
+ isinstance(child, Assignment)
727
+ and isinstance(child.target, Identifier)
728
+ and child.target.name == node.name
729
+ ):
730
+ return None
731
+ if isinstance(child, VarDecl) and child.name == node.name:
732
+ return None
733
+ if isinstance(child, TupleAssign) and node.name in child.names:
734
+ return None
735
+ if isinstance(child, ForStmt) and child.var == node.name:
736
+ return None
737
+ if isinstance(child, ForInStmt) and (
738
+ child.var == node.name or node.name in (child.vars or [])
739
+ ):
740
+ return None
741
+ param_index = list(func_info.node.params).index(node.name)
742
+ bound_args = []
743
+ for call in self._walk_ast(self.ctx.ast):
744
+ if not (
745
+ isinstance(call, FuncCall)
746
+ and isinstance(call.callee, Identifier)
747
+ and call.callee.name == func_name
748
+ ):
749
+ continue
750
+ arg = call.args[param_index] if param_index < len(call.args) else None
751
+ if node.name in call.kwargs:
752
+ arg = call.kwargs[node.name]
753
+ if arg is None:
754
+ return None
755
+ bound_args.append(arg)
756
+ if not bound_args:
757
+ return None
758
+
759
+ first = bound_args[0]
760
+
761
+ def equivalent(left, right) -> bool:
762
+ if isinstance(left, NumberLiteral) and isinstance(right, NumberLiteral):
763
+ return left.value == right.value
764
+ if isinstance(left, Identifier) and isinstance(right, Identifier):
765
+ return (
766
+ left.name == right.name
767
+ and self._security_identifier_is_global_binding(left)
768
+ and self._security_identifier_is_global_binding(right)
769
+ )
770
+ return left is right
771
+
772
+ if not all(equivalent(first, other) for other in bound_args[1:]):
773
+ return None
774
+ return first
775
+
776
+ def _resolve_security_immutable_input_int(
777
+ self,
778
+ node,
779
+ helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None,
780
+ resolving: set[int] | None = None,
781
+ ) -> tuple[FuncCall, tuple[dict[str, ASTNode], ...]] | None:
782
+ """Resolve an immutable security-history offset to its ``input.int``.
783
+
784
+ Pine v6 permits a bar-invariant input integer as a history offset. A
785
+ request.security TA offset needs special admission because its backing
786
+ history advances in the requested context, not in chart context. Keep
787
+ this proof deliberately narrower than arbitrary ``series int`` support:
788
+
789
+ * the leaf must be exactly ``input.int``;
790
+ * helper-parameter and top-level immutable alias chains are followed;
791
+ * ``var``/``varip`` or any globally reassigned alias is rejected; and
792
+ * arithmetic/ternary expressions remain unsupported for now.
793
+
794
+ Return the proven input call plus the lexical stack in which that call
795
+ was authored. Rendering the original identifier under a helper's
796
+ stack would let a same-named helper parameter capture a global alias.
797
+ Crossing ``global_expr_map`` therefore resets the stack to global.
798
+
799
+ The generated ``Series`` safely returns ``na`` for insufficient depth;
800
+ negative runtime overrides are also lowered to ``na`` by the caller.
801
+ """
802
+ if resolving is None:
803
+ resolving = set()
804
+
805
+ if isinstance(node, FuncCall):
806
+ func_name, namespace = self._resolve_callee(node.callee)
807
+ if namespace == "input" and func_name == "int":
808
+ return node, tuple(helper_binding_stack or ())
809
+ return None
810
+
811
+ if not isinstance(node, Identifier) or id(node) in resolving:
812
+ return None
813
+
814
+ binding = None
815
+ if not self._security_identifier_is_global_binding(node):
816
+ binding = self._security_lookup_helper_binding_context(
817
+ node.name, helper_binding_stack
818
+ )
819
+ if binding is not None:
820
+ bound, bound_stack = binding
821
+ if isinstance(bound, str):
822
+ return None
823
+ return self._resolve_security_immutable_input_int(
824
+ bound,
825
+ bound_stack,
826
+ resolving | {id(node)},
827
+ )
828
+
829
+ param_binding = self._security_index_param_callsite_binding(node)
830
+ if param_binding is not None:
831
+ return self._resolve_security_immutable_input_int(
832
+ param_binding,
833
+ (),
834
+ resolving | {id(node)},
835
+ )
836
+
837
+ # Mutable globals have requested-context state of their own and cannot
838
+ # be replaced by their authored initializer without changing Pine
839
+ # semantics. Reject them before following global_expr_map.
840
+ if node.name in getattr(self, "_global_mutable_infos", {}):
841
+ return None
842
+
843
+ if not self._security_identifier_is_global_binding(node):
844
+ return None
845
+
846
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
847
+ if node.name not in global_expr_map:
848
+ return None
849
+ return self._resolve_security_immutable_input_int(
850
+ global_expr_map[node.name],
851
+ (),
852
+ resolving | {id(node)},
853
+ )
854
+
855
+ def _compose_security_helper_history_subscript(
856
+ self,
857
+ bound,
858
+ local_index,
859
+ source_node,
860
+ ) -> Subscript:
861
+ """Apply helper-local history to a supported bound bar series.
862
+
863
+ The security runtime currently has native requested-context history
864
+ storage only for direct OHLC/time bar fields. Keep that support fence
865
+ explicit instead of emitting a C++ subscript on a scalar expression
866
+ for bindings such as ``hl2`` or ``ta.ema(...)``.
867
+ """
868
+ if isinstance(bound, Subscript):
869
+ if not (
870
+ isinstance(bound.object, Identifier)
871
+ and bound.object.name in SECURITY_BAR_FIELDS
872
+ ):
873
+ self._codegen_error(
874
+ source_node,
875
+ "request.security helper-parameter history currently requires "
876
+ "a direct OHLC/time bar-series binding",
877
+ )
878
+ bound_idx = self._literal_int_for_security_index(bound.index)
879
+ local_idx = self._literal_int_for_security_index(local_index)
880
+ if bound_idx is not None and local_idx is not None:
881
+ combined_index = NumberLiteral(value=bound_idx + local_idx)
882
+ else:
883
+ combined_index = BinOp(
884
+ left=bound.index,
885
+ op="+",
886
+ right=local_index,
887
+ )
888
+ return Subscript(object=bound.object, index=combined_index)
889
+ if isinstance(bound, Identifier) and bound.name in SECURITY_BAR_FIELDS:
890
+ return Subscript(object=bound, index=local_index)
891
+ self._codegen_error(
892
+ source_node,
893
+ "request.security helper-parameter history currently requires "
894
+ "a direct OHLC/time bar-series binding",
895
+ )
896
+
897
+ def _collect_security_ohlc_hist_fields(
898
+ self,
899
+ node,
900
+ helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None,
901
+ resolving: set[str] | None = None,
902
+ ) -> set[str]:
903
+ """Which requested-context bar fields need completed-bar history.
904
+
905
+ This is the declaration prepass for ``_build_security_expr``. It must
906
+ follow the same helper-argument bindings and offset composition as the
907
+ emitter; otherwise ``f(close)`` plus ``src[1]`` can emit a history read
908
+ without declaring, pushing, or clearing its backing Series.
909
+ """
517
910
  out: set[str] = set()
911
+ if resolving is None:
912
+ resolving = set()
518
913
 
519
- def walk(n):
520
- if n is None:
914
+ def walk(n, bindings) -> None:
915
+ if n is None or isinstance(n, str):
521
916
  return
917
+
918
+ if isinstance(n, Identifier):
919
+ binding = None
920
+ if not self._security_identifier_is_global_binding(n):
921
+ binding = self._security_lookup_helper_binding_context(
922
+ n.name, bindings
923
+ )
924
+ if binding is not None:
925
+ bound, bound_stack = binding
926
+ if not isinstance(bound, str):
927
+ key = f"bind:{id(bound)}"
928
+ if key not in resolving:
929
+ resolving.add(key)
930
+ walk(bound, bound_stack)
931
+ resolving.remove(key)
932
+ return
933
+ mutable_info = self._global_mutable_infos.get(n.name)
934
+ if mutable_info is not None and n.name not in resolving:
935
+ resolving.add(n.name)
936
+ for stmt in getattr(mutable_info, "source_stmts", []) or []:
937
+ walk(stmt, bindings)
938
+ resolving.remove(n.name)
939
+ return
940
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
941
+ if (
942
+ self._security_identifier_is_global_binding(n)
943
+ and n.name in global_expr_map
944
+ and n.name not in resolving
945
+ ):
946
+ resolving.add(n.name)
947
+ walk(global_expr_map[n.name], ())
948
+ resolving.remove(n.name)
949
+ return
950
+
522
951
  if isinstance(n, Subscript) and isinstance(n.object, Identifier):
952
+ binding = None
953
+ if not self._security_identifier_is_global_binding(n.object):
954
+ binding = self._security_lookup_helper_binding_context(
955
+ n.object.name, bindings
956
+ )
957
+ if binding is not None:
958
+ bound, bound_stack = binding
959
+ if not isinstance(bound, str):
960
+ local_index = n.index
961
+ resolved_local_index = self._resolve_security_index_literal(
962
+ n.index, bindings
963
+ )
964
+ if resolved_local_index is not None:
965
+ local_index = NumberLiteral(
966
+ value=resolved_local_index
967
+ )
968
+ walk(
969
+ self._compose_security_helper_history_subscript(
970
+ bound,
971
+ local_index,
972
+ n,
973
+ ),
974
+ bound_stack,
975
+ )
976
+ return
523
977
  if n.object.name in SECURITY_BAR_FIELDS:
524
- idx = self._literal_int_for_security_index(n.index)
525
- # high[0]/time[0] uses current HTF `bar`; k>=1 reads prior
526
- # completed HTF bars from Series history (filled before push
527
- # in _eval_security_*). Dynamic indices may be >=1 at
528
- # runtime, so they need the same backing Series.
978
+ idx = self._resolve_security_index_literal(n.index, bindings)
979
+ # field[0] uses the current requested bar; k>=1 reads the
980
+ # completed-bar Series. Dynamic indices need that Series too.
529
981
  if idx is None or idx >= 1:
530
982
  out.add(n.object.name)
983
+ return
984
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
985
+ if (
986
+ self._security_identifier_is_global_binding(n.object)
987
+ and n.object.name in global_expr_map
988
+ and n.object.name not in resolving
989
+ ):
990
+ resolving.add(n.object.name)
991
+ walk(
992
+ Subscript(
993
+ object=global_expr_map[n.object.name],
994
+ index=n.index,
995
+ ),
996
+ bindings,
997
+ )
998
+ resolving.remove(n.object.name)
999
+ return
1000
+
1001
+ if isinstance(n, FuncCall) and isinstance(n.callee, Identifier):
1002
+ func_name = n.callee.name
1003
+ if func_name in self._func_names:
1004
+ call_key = f"func:{func_name}"
1005
+ if call_key in resolving:
1006
+ return
1007
+ resolving.add(call_key)
1008
+ plan = self._security_helper_call_plan(n, bindings)
1009
+ if plan["mode"] == "expr":
1010
+ walk(plan["expr"], plan["binding_stack"])
1011
+ else:
1012
+ local_series_names = set(plan.get("local_series_names", ()))
1013
+ active: dict[str, object] = {}
1014
+
1015
+ def walk_stmt(stmt, current: dict[str, object]) -> None:
1016
+ local_stack = plan["binding_stack"] + (current,)
1017
+ if isinstance(stmt, VarDecl):
1018
+ if stmt.value is not None:
1019
+ walk(stmt.value, local_stack)
1020
+ if stmt.name in local_series_names or stmt.is_var:
1021
+ current[stmt.name] = self._security_series_binding(
1022
+ f"{plan['func_info'].name}:{stmt.name}"
1023
+ )
1024
+ elif stmt.value is not None:
1025
+ current[stmt.name] = stmt.value
1026
+ return
1027
+ if isinstance(stmt, Assignment):
1028
+ if stmt.value is not None:
1029
+ walk(stmt.value, local_stack)
1030
+ target = self._get_target_name(stmt.target)
1031
+ existing = current.get(target) if target is not None else None
1032
+ if (
1033
+ target in local_series_names
1034
+ or (
1035
+ isinstance(existing, str)
1036
+ and self._security_series_binding_target(existing)
1037
+ is not None
1038
+ )
1039
+ ):
1040
+ current[target] = self._security_series_binding(
1041
+ f"{plan['func_info'].name}:{target}"
1042
+ )
1043
+ elif target is not None and stmt.value is not None:
1044
+ current[target] = stmt.value
1045
+ return
1046
+ if isinstance(stmt, IfStmt):
1047
+ walk(stmt.condition, local_stack)
1048
+ body_bindings = dict(current)
1049
+ for child in stmt.body:
1050
+ walk_stmt(child, body_bindings)
1051
+ else_bindings = dict(current)
1052
+ for child in stmt.else_body:
1053
+ walk_stmt(child, else_bindings)
1054
+
1055
+ for stmt in plan["body"]:
1056
+ walk_stmt(stmt, active)
1057
+ walk(
1058
+ plan["expr"],
1059
+ plan["binding_stack"] + (active,),
1060
+ )
1061
+ resolving.remove(call_key)
1062
+ return
1063
+
531
1064
  if isinstance(n, (list, tuple)):
532
- for x in n:
533
- walk(x)
1065
+ for child in n:
1066
+ walk(child, bindings)
534
1067
  return
535
- for _k, v in getattr(n, "__dict__", {}).items():
536
- if isinstance(v, ASTNode):
537
- walk(v)
538
- elif isinstance(v, (list, tuple)):
539
- for x in v:
540
- if isinstance(x, ASTNode):
541
- walk(x)
542
-
543
- walk(node)
1068
+ for value in getattr(n, "__dict__", {}).values():
1069
+ if isinstance(value, ASTNode):
1070
+ walk(value, bindings)
1071
+ elif isinstance(value, (list, tuple)):
1072
+ for child in value:
1073
+ if isinstance(child, ASTNode):
1074
+ walk(child, bindings)
1075
+
1076
+ walk(node, helper_binding_stack or ())
544
1077
  return out
545
1078
 
546
1079
  def _collect_security_ohlc_hist_fields_for_call(self, item: dict) -> set[str]:
@@ -565,10 +1098,43 @@ class SecurityEmitter:
565
1098
  return SECURITY_BAR_FIELD_EXPRS.get(field, f"bar.{field}")
566
1099
 
567
1100
  @staticmethod
568
- def _security_tuple_result_default(cpp_type: str, tuple_size: int) -> str:
569
- vals = ", ".join("na<double>()" for _ in range(max(0, tuple_size)))
1101
+ def _security_tuple_result_default(
1102
+ cpp_type: str,
1103
+ tuple_size: int,
1104
+ tuple_element_types: tuple[PineType, ...] = (),
1105
+ ) -> str:
1106
+ is_bool_tuple = (
1107
+ len(tuple_element_types) == tuple_size
1108
+ and tuple_size > 0
1109
+ and all(item == PineType.BOOL for item in tuple_element_types)
1110
+ )
1111
+ default_value = "false" if is_bool_tuple else "na<double>()"
1112
+ vals = ", ".join(
1113
+ default_value for _ in range(max(0, tuple_size))
1114
+ )
570
1115
  return f"{cpp_type}{{{vals}}}"
571
1116
 
1117
+ @staticmethod
1118
+ def _security_helper_tuple_cpp_type(
1119
+ tuple_size: int,
1120
+ tuple_element_types: tuple[PineType, ...] = (),
1121
+ ) -> str:
1122
+ """C++ storage type for a supported helper tuple of arbitrary arity.
1123
+
1124
+ Numeric int/float families retain the established double-coercing
1125
+ representation. Homogeneous bool families use real ``bool`` fields so
1126
+ true/false semantics survive the requested-context boundary.
1127
+ """
1128
+ is_bool_tuple = (
1129
+ len(tuple_element_types) == tuple_size
1130
+ and tuple_size > 0
1131
+ and all(item == PineType.BOOL for item in tuple_element_types)
1132
+ )
1133
+ element_cpp_type = "bool" if is_bool_tuple else "double"
1134
+ return "std::tuple<" + ", ".join(
1135
+ element_cpp_type for _ in range(max(0, tuple_size))
1136
+ ) + ">"
1137
+
572
1138
  def _collect_security_ta_hist_indices(self, node) -> set[int]:
573
1139
  """Which security TA call-site indices need HTF history (subscript index >= 1).
574
1140
 
@@ -578,50 +1144,172 @@ class SecurityEmitter:
578
1144
  per-site ``Series`` filled (gated on ``is_complete``) in
579
1145
  ``_eval_security_N``. Mirrors ``_collect_security_ohlc_hist_fields`` for
580
1146
  OHLC offsets. Offset 0 reuses the current committed value (``_secval_*``)
581
- and needs no Series, so only index >= 1 registers here."""
1147
+ and needs no Series, so only index >= 1 registers here. A dynamic
1148
+ index is registered conservatively; the expression emitter separately
1149
+ admits only an immutable ``input.int`` chain and rejects everything
1150
+ else. This declaration prepass must expand helper/global bindings just
1151
+ like ``_build_security_expr`` or a valid helper-wrapped offset could
1152
+ emit a history read without storage, pushes, or reset."""
582
1153
  out: set[int] = set()
583
1154
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
1155
+ resolving: set[str] = set()
584
1156
 
585
- def resolve_ta_site(obj, resolving: set[str] | None = None):
1157
+ def resolve_ta_site(obj, bindings, seen: set[str] | None = None):
586
1158
  """_get_ta_site only matches the literal ta.* FuncCall node by
587
- identity; fall back through global_expr_map for an indirect
588
- binding (``v = ta.ema(close, 55)`` then ``...v[1]...``), mirroring
589
- the same fallback in _build_security_expr's Subscript branch."""
1159
+ identity; fall back through helper/global bindings for an indirect
1160
+ binding (``v = ta.ema(close, 55)`` then ``...v[1]...``)."""
590
1161
  site = self._get_ta_site(obj)
591
1162
  if site is not None:
592
1163
  return site
593
1164
  if isinstance(obj, Identifier):
594
- if resolving is None:
595
- resolving = set()
596
- if obj.name in global_expr_map and obj.name not in resolving:
597
- resolving.add(obj.name)
598
- return resolve_ta_site(global_expr_map[obj.name], resolving)
1165
+ if seen is None:
1166
+ seen = set()
1167
+ if obj.name in seen:
1168
+ return None
1169
+ binding = None
1170
+ if not self._security_identifier_is_global_binding(obj):
1171
+ binding = self._security_lookup_helper_binding_context(
1172
+ obj.name, bindings
1173
+ )
1174
+ if binding is not None:
1175
+ bound, bound_stack = binding
1176
+ if not isinstance(bound, str):
1177
+ return resolve_ta_site(
1178
+ bound, bound_stack, seen | {obj.name}
1179
+ )
1180
+ if (
1181
+ self._security_identifier_is_global_binding(obj)
1182
+ and obj.name in global_expr_map
1183
+ ):
1184
+ return resolve_ta_site(
1185
+ global_expr_map[obj.name],
1186
+ (),
1187
+ seen | {obj.name},
1188
+ )
599
1189
  return None
600
1190
 
601
- def walk(n):
602
- if n is None:
1191
+ def walk(n, bindings) -> None:
1192
+ if n is None or isinstance(n, str):
603
1193
  return
1194
+
1195
+ if isinstance(n, Identifier):
1196
+ binding = None
1197
+ if not self._security_identifier_is_global_binding(n):
1198
+ binding = self._security_lookup_helper_binding_context(
1199
+ n.name, bindings
1200
+ )
1201
+ if binding is not None:
1202
+ bound, bound_stack = binding
1203
+ if not isinstance(bound, str):
1204
+ key = f"bind:{id(bound)}"
1205
+ if key not in resolving:
1206
+ resolving.add(key)
1207
+ walk(bound, bound_stack)
1208
+ resolving.remove(key)
1209
+ return
1210
+ mutable_info = self._global_mutable_infos.get(n.name)
1211
+ if mutable_info is not None and n.name not in resolving:
1212
+ resolving.add(n.name)
1213
+ for stmt in getattr(mutable_info, "source_stmts", []) or []:
1214
+ walk(stmt, bindings)
1215
+ resolving.remove(n.name)
1216
+ return
1217
+ if (
1218
+ self._security_identifier_is_global_binding(n)
1219
+ and n.name in global_expr_map
1220
+ and n.name not in resolving
1221
+ ):
1222
+ resolving.add(n.name)
1223
+ walk(global_expr_map[n.name], ())
1224
+ resolving.remove(n.name)
1225
+ return
1226
+
604
1227
  if isinstance(n, Subscript):
605
- site = resolve_ta_site(n.object)
1228
+ site = resolve_ta_site(n.object, bindings)
606
1229
  if site is not None:
607
- idx_lit = self._resolve_security_index_literal(n.index)
608
- if idx_lit is not None and idx_lit >= 1:
1230
+ idx_lit = self._resolve_security_index_literal(n.index, bindings)
1231
+ if idx_lit is None or idx_lit >= 1:
609
1232
  site_idx = self._ta_index_by_site_id.get(id(site))
610
1233
  if site_idx is not None:
611
1234
  out.add(site_idx)
1235
+
1236
+ if isinstance(n, FuncCall) and isinstance(n.callee, Identifier):
1237
+ func_name = n.callee.name
1238
+ if func_name in self._func_names:
1239
+ call_key = f"func:{func_name}"
1240
+ if call_key in resolving:
1241
+ return
1242
+ resolving.add(call_key)
1243
+ plan = self._security_helper_call_plan(n, bindings)
1244
+ if plan["mode"] == "expr":
1245
+ walk(plan["expr"], plan["binding_stack"])
1246
+ else:
1247
+ local_series_names = set(
1248
+ plan.get("local_series_names", ())
1249
+ )
1250
+ active: dict[str, object] = {}
1251
+
1252
+ def walk_stmt(stmt, current: dict[str, object]) -> None:
1253
+ local_stack = plan["binding_stack"] + (current,)
1254
+ if isinstance(stmt, VarDecl):
1255
+ if stmt.value is not None:
1256
+ walk(stmt.value, local_stack)
1257
+ if stmt.name in local_series_names or stmt.is_var:
1258
+ current[stmt.name] = self._security_series_binding(
1259
+ f"{plan['func_info'].name}:{stmt.name}"
1260
+ )
1261
+ elif stmt.value is not None:
1262
+ current[stmt.name] = stmt.value
1263
+ return
1264
+ if isinstance(stmt, Assignment):
1265
+ if stmt.value is not None:
1266
+ walk(stmt.value, local_stack)
1267
+ target = self._get_target_name(stmt.target)
1268
+ existing = current.get(target) if target else None
1269
+ if (
1270
+ target in local_series_names
1271
+ or (
1272
+ isinstance(existing, str)
1273
+ and self._security_series_binding_target(
1274
+ existing
1275
+ )
1276
+ is not None
1277
+ )
1278
+ ):
1279
+ current[target] = self._security_series_binding(
1280
+ f"{plan['func_info'].name}:{target}"
1281
+ )
1282
+ elif target is not None and stmt.value is not None:
1283
+ current[target] = stmt.value
1284
+ return
1285
+ if isinstance(stmt, IfStmt):
1286
+ walk(stmt.condition, local_stack)
1287
+ body_bindings = dict(current)
1288
+ for child in stmt.body:
1289
+ walk_stmt(child, body_bindings)
1290
+ else_bindings = dict(current)
1291
+ for child in stmt.else_body:
1292
+ walk_stmt(child, else_bindings)
1293
+
1294
+ for stmt in plan["body"]:
1295
+ walk_stmt(stmt, active)
1296
+ walk(plan["expr"], plan["binding_stack"] + (active,))
1297
+ resolving.remove(call_key)
1298
+ return
1299
+
612
1300
  if isinstance(n, (list, tuple)):
613
1301
  for x in n:
614
- walk(x)
1302
+ walk(x, bindings)
615
1303
  return
616
1304
  for _k, v in getattr(n, "__dict__", {}).items():
617
1305
  if isinstance(v, ASTNode):
618
- walk(v)
1306
+ walk(v, bindings)
619
1307
  elif isinstance(v, (list, tuple)):
620
1308
  for x in v:
621
1309
  if isinstance(x, ASTNode):
622
- walk(x)
1310
+ walk(x, bindings)
623
1311
 
624
- walk(node)
1312
+ walk(node, ())
625
1313
  return out
626
1314
 
627
1315
  def _security_ta_hist_series_cpp(self, member_name: str) -> str:
@@ -822,6 +1510,49 @@ class SecurityEmitter:
822
1510
  security_mutable_names: set[str],
823
1511
  lines: list[str],
824
1512
  resolving: set[str] | None = None,
1513
+ ) -> str:
1514
+ """Inline one linear helper against isolated lexical collection state."""
1515
+ saved = (
1516
+ self._current_func_collection_specs,
1517
+ self._current_func_collection_shadows,
1518
+ self._collection_types,
1519
+ self._array_vars,
1520
+ self._map_vars,
1521
+ self._matrix_specs,
1522
+ )
1523
+ self._current_func_collection_specs = {}
1524
+ self._current_func_collection_shadows = set()
1525
+ self._collection_types = dict(self._collection_types)
1526
+ self._array_vars = set(self._array_vars)
1527
+ self._map_vars = set(self._map_vars)
1528
+ self._matrix_specs = dict(self._matrix_specs)
1529
+ try:
1530
+ return self._emit_security_linear_helper_call_scoped(
1531
+ sec_id,
1532
+ plan,
1533
+ ta_results,
1534
+ security_mutable_names,
1535
+ lines,
1536
+ resolving,
1537
+ )
1538
+ finally:
1539
+ (
1540
+ self._current_func_collection_specs,
1541
+ self._current_func_collection_shadows,
1542
+ self._collection_types,
1543
+ self._array_vars,
1544
+ self._map_vars,
1545
+ self._matrix_specs,
1546
+ ) = saved
1547
+
1548
+ def _emit_security_linear_helper_call_scoped(
1549
+ self,
1550
+ sec_id: int,
1551
+ plan: dict,
1552
+ ta_results: dict,
1553
+ security_mutable_names: set[str],
1554
+ lines: list[str],
1555
+ resolving: set[str] | None = None,
825
1556
  ) -> str:
826
1557
  if plan["mode"] != "linear":
827
1558
  self._codegen_error(
@@ -839,8 +1570,18 @@ class SecurityEmitter:
839
1570
  pad = " " * indent
840
1571
  runtime_stack_local = plan["binding_stack"] + (active_bindings,)
841
1572
 
1573
+ def activate_decl() -> None:
1574
+ spec = self._callable_collection_bindings.get(id(stmt))
1575
+ if spec is None:
1576
+ inferred = self._type_spec_from_expr(stmt.value)
1577
+ if (inferred is not None
1578
+ and inferred.kind in {"array", "map", "matrix"}):
1579
+ spec = inferred
1580
+ self._activate_callable_collection_binding(stmt.name, spec)
1581
+
842
1582
  if isinstance(stmt, VarDecl):
843
- if stmt.name in local_series_names:
1583
+ is_persistent_var = stmt.is_var
1584
+ if stmt.name in local_series_names or is_persistent_var:
844
1585
  binding = active_bindings.get(stmt.name)
845
1586
  if binding is None:
846
1587
  binding = self._security_series_binding(
@@ -859,13 +1600,53 @@ class SecurityEmitter:
859
1600
  lines,
860
1601
  )
861
1602
  active_bindings[stmt.name] = binding
862
- lines.append(f'{pad}if (_security_helper_series_["{series_name}"].size() == 0) {{')
863
- lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});')
864
- lines.append(f'{pad}}} else if (security_series_slot_is_new({sec_id})) {{')
865
- lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});')
866
- lines.append(f'{pad}}} else {{')
867
- lines.append(f'{pad} _security_helper_series_["{series_name}"].update({expr_cpp});')
868
- lines.append(f'{pad}}}')
1603
+ if is_persistent_var:
1604
+ cpp_type = self._type_for_decl(stmt)
1605
+ if cpp_type not in {"double", "int", "bool"}:
1606
+ self._codegen_error(
1607
+ stmt,
1608
+ "request.security helper-local var state currently supports only int, float, and bool values",
1609
+ hint="Hoist collection, string, UDT, or drawing state outside request.security().",
1610
+ )
1611
+ # A Pine ``var`` initializer runs once per helper call
1612
+ # site in the requested context. On a new requested
1613
+ # bar the prior committed value is carried forward; on
1614
+ # a lookahead/realtime recomputation of the current
1615
+ # requested bar it rolls back before the helper body is
1616
+ # evaluated again. ``Series[1]`` is that rollback
1617
+ # value after the first bar. The companion seed entry
1618
+ # preserves the once-only initializer for first-bar
1619
+ # recomputations without adding another generated
1620
+ # member/state family.
1621
+ seed_name = f"{series_name}@var_seed"
1622
+ lines.append(f'{pad}if (_security_helper_series_["{series_name}"].size() == 0) {{')
1623
+ lines.append(f'{pad} _security_helper_series_["{seed_name}"].push({expr_cpp});')
1624
+ lines.append(
1625
+ f'{pad} _security_helper_series_["{series_name}"].push('
1626
+ f'_security_helper_series_["{seed_name}"][0]);'
1627
+ )
1628
+ lines.append(f'{pad}}} else if (security_series_slot_is_new({sec_id})) {{')
1629
+ lines.append(
1630
+ f'{pad} _security_helper_series_["{series_name}"].push('
1631
+ f'_security_helper_series_["{series_name}"][0]);'
1632
+ )
1633
+ lines.append(f'{pad}}} else {{')
1634
+ lines.append(
1635
+ f'{pad} _security_helper_series_["{series_name}"].update('
1636
+ f'_security_helper_series_["{series_name}"].size() > 1 '
1637
+ f'? _security_helper_series_["{series_name}"][1] '
1638
+ f': _security_helper_series_["{seed_name}"][0]);'
1639
+ )
1640
+ lines.append(f'{pad}}}')
1641
+ else:
1642
+ lines.append(f'{pad}if (_security_helper_series_["{series_name}"].size() == 0) {{')
1643
+ lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});')
1644
+ lines.append(f'{pad}}} else if (security_series_slot_is_new({sec_id})) {{')
1645
+ lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});')
1646
+ lines.append(f'{pad}}} else {{')
1647
+ lines.append(f'{pad} _security_helper_series_["{series_name}"].update({expr_cpp});')
1648
+ lines.append(f'{pad}}}')
1649
+ activate_decl()
869
1650
  return
870
1651
 
871
1652
  local_name = active_bindings.get(stmt.name)
@@ -900,6 +1681,7 @@ class SecurityEmitter:
900
1681
  lines,
901
1682
  )
902
1683
  lines.append(f"{pad}{local_name} = {expr_cpp};")
1684
+ activate_decl()
903
1685
  return
904
1686
 
905
1687
  if isinstance(stmt, Assignment):
@@ -956,13 +1738,69 @@ class SecurityEmitter:
956
1738
  )
957
1739
  lines.append(f"{pad}if ({cond_cpp}) {{")
958
1740
  body_bindings = dict(active_bindings)
959
- for child in stmt.body:
960
- emit_stmt(child, body_bindings, indent + 1)
1741
+ body_state = (
1742
+ self._current_func_collection_specs,
1743
+ self._current_func_collection_shadows,
1744
+ self._collection_types,
1745
+ self._array_vars,
1746
+ self._map_vars,
1747
+ self._matrix_specs,
1748
+ )
1749
+ self._current_func_collection_specs = dict(
1750
+ self._current_func_collection_specs
1751
+ )
1752
+ self._current_func_collection_shadows = set(
1753
+ self._current_func_collection_shadows
1754
+ )
1755
+ self._collection_types = dict(self._collection_types)
1756
+ self._array_vars = set(self._array_vars)
1757
+ self._map_vars = set(self._map_vars)
1758
+ self._matrix_specs = dict(self._matrix_specs)
1759
+ try:
1760
+ for child in stmt.body:
1761
+ emit_stmt(child, body_bindings, indent + 1)
1762
+ finally:
1763
+ (
1764
+ self._current_func_collection_specs,
1765
+ self._current_func_collection_shadows,
1766
+ self._collection_types,
1767
+ self._array_vars,
1768
+ self._map_vars,
1769
+ self._matrix_specs,
1770
+ ) = body_state
961
1771
  if stmt.else_body:
962
1772
  lines.append(f"{pad}}} else {{")
963
1773
  else_bindings = dict(active_bindings)
964
- for child in stmt.else_body:
965
- emit_stmt(child, else_bindings, indent + 1)
1774
+ else_state = (
1775
+ self._current_func_collection_specs,
1776
+ self._current_func_collection_shadows,
1777
+ self._collection_types,
1778
+ self._array_vars,
1779
+ self._map_vars,
1780
+ self._matrix_specs,
1781
+ )
1782
+ self._current_func_collection_specs = dict(
1783
+ self._current_func_collection_specs
1784
+ )
1785
+ self._current_func_collection_shadows = set(
1786
+ self._current_func_collection_shadows
1787
+ )
1788
+ self._collection_types = dict(self._collection_types)
1789
+ self._array_vars = set(self._array_vars)
1790
+ self._map_vars = set(self._map_vars)
1791
+ self._matrix_specs = dict(self._matrix_specs)
1792
+ try:
1793
+ for child in stmt.else_body:
1794
+ emit_stmt(child, else_bindings, indent + 1)
1795
+ finally:
1796
+ (
1797
+ self._current_func_collection_specs,
1798
+ self._current_func_collection_shadows,
1799
+ self._collection_types,
1800
+ self._array_vars,
1801
+ self._map_vars,
1802
+ self._matrix_specs,
1803
+ ) = else_state
966
1804
  lines.append(f"{pad}}}")
967
1805
  return
968
1806
 
@@ -998,7 +1836,7 @@ class SecurityEmitter:
998
1836
 
999
1837
  sig_frames = []
1000
1838
  for idx, frame in enumerate(helper_binding_stack):
1001
- if idx == 0:
1839
+ if idx == 0 or isinstance(frame, _SecurityHelperArgumentFrame):
1002
1840
  sig_frames.append(
1003
1841
  tuple((name, _sig_value(node)) for name, node in sorted(frame.items()))
1004
1842
  )
@@ -1051,8 +1889,14 @@ class SecurityEmitter:
1051
1889
  "request.security helper calls must bind every parameter explicitly",
1052
1890
  )
1053
1891
 
1054
- new_frame = {param_name: bound_args[idx] for idx, param_name in enumerate(params)}
1055
1892
  base_stack = helper_binding_stack or ()
1893
+ new_frame = _SecurityHelperArgumentFrame(
1894
+ {
1895
+ param_name: bound_args[idx]
1896
+ for idx, param_name in enumerate(params)
1897
+ },
1898
+ base_stack,
1899
+ )
1056
1900
  return fi, base_stack + (new_frame,)
1057
1901
 
1058
1902
  def _security_helper_call_plan(
@@ -1124,11 +1968,11 @@ class SecurityEmitter:
1124
1968
  "request.security multi-statement helpers may only use local declarations, assignments, and if-branches before the final expression",
1125
1969
  )
1126
1970
  if isinstance(stmt, VarDecl):
1127
- if stmt.is_var or stmt.is_varip:
1971
+ if stmt.is_varip:
1128
1972
  self._codegen_error(
1129
1973
  node,
1130
- "request.security does not support multi-statement helpers with helper-local var state",
1131
- hint="Rewrite helper-local state as plain temporaries or hoist it outside request.security().",
1974
+ "request.security does not support helper-local varip state",
1975
+ hint="Use var for requested-context bar state; varip tick state is unavailable in batch backtests.",
1132
1976
  )
1133
1977
  if isinstance(stmt, Assignment):
1134
1978
  target_name = self._get_target_name(stmt.target)
@@ -1148,6 +1992,92 @@ class SecurityEmitter:
1148
1992
  "local_series_names": local_series_names,
1149
1993
  }
1150
1994
 
1995
+ def _validate_security_persistent_var_control_flow(self, expr_node) -> None:
1996
+ """Reject helper ``var`` state whose rollback would be conditional.
1997
+
1998
+ Persistent helper state is restored at its declaration before the body
1999
+ mutates it. That is safe only when the declaration executes on every
2000
+ requested-bar evaluation. Until rollback is hoisted to evaluator entry,
2001
+ reject declarations reached through ``if``/``?:``/short-circuit paths,
2002
+ including state owned by a nested helper called from such a path.
2003
+ """
2004
+ call_stack: set[str] = set()
2005
+
2006
+ def visit_expr(node, conditional: bool) -> None:
2007
+ if node is None:
2008
+ return
2009
+ if isinstance(node, FuncCall) and isinstance(node.callee, Identifier):
2010
+ name = node.callee.name
2011
+ if name in self._func_names:
2012
+ if name in call_stack:
2013
+ return
2014
+ fi = self._func_info_map.get(name)
2015
+ if fi is not None and fi.node is not None:
2016
+ call_stack.add(name)
2017
+ for stmt in fi.node.body:
2018
+ visit_stmt(stmt, conditional)
2019
+ call_stack.remove(name)
2020
+ for arg in node.args:
2021
+ visit_expr(arg, conditional)
2022
+ for value in node.kwargs.values():
2023
+ if isinstance(value, ASTNode):
2024
+ visit_expr(value, conditional)
2025
+ return
2026
+ if isinstance(node, Ternary):
2027
+ visit_expr(node.condition, conditional)
2028
+ visit_expr(node.true_val, True)
2029
+ visit_expr(node.false_val, True)
2030
+ return
2031
+ if isinstance(node, BinOp) and node.op in ("and", "or"):
2032
+ visit_expr(node.left, conditional)
2033
+ visit_expr(node.right, True)
2034
+ return
2035
+ for value in getattr(node, "__dict__", {}).values():
2036
+ if isinstance(value, ASTNode):
2037
+ visit_expr(value, conditional)
2038
+ elif isinstance(value, (list, tuple)):
2039
+ for child in value:
2040
+ if isinstance(child, ASTNode):
2041
+ visit_expr(child, conditional)
2042
+
2043
+ def visit_stmt(stmt, conditional: bool) -> None:
2044
+ if isinstance(stmt, VarDecl):
2045
+ if stmt.is_var and conditional:
2046
+ self._codegen_error(
2047
+ stmt,
2048
+ "request.security helper-local var declarations inside "
2049
+ "conditional control flow are not supported",
2050
+ hint="Declare persistent requested-context state at the helper's top level.",
2051
+ )
2052
+ # A persistent initializer runs only when its requested-context
2053
+ # slot is first created. Any nested persistent helper reached
2054
+ # from that initializer would therefore need its rollback and
2055
+ # mutation to remain inside the same once-only guard. The
2056
+ # current linear emitter hoists nested helper statements ahead
2057
+ # of that guard, so treat the initializer as conditional and
2058
+ # fail closed until it can preserve that execution boundary.
2059
+ visit_expr(stmt.value, conditional or stmt.is_var)
2060
+ return
2061
+ if isinstance(stmt, IfStmt):
2062
+ visit_expr(stmt.condition, conditional)
2063
+ for child in stmt.body:
2064
+ visit_stmt(child, True)
2065
+ for child in stmt.else_body:
2066
+ visit_stmt(child, True)
2067
+ return
2068
+ if isinstance(stmt, ExprStmt):
2069
+ visit_expr(stmt.expr, conditional)
2070
+ return
2071
+ for value in getattr(stmt, "__dict__", {}).values():
2072
+ if isinstance(value, ASTNode):
2073
+ visit_expr(value, conditional)
2074
+ elif isinstance(value, (list, tuple)):
2075
+ for child in value:
2076
+ if isinstance(child, ASTNode):
2077
+ visit_expr(child, conditional)
2078
+
2079
+ visit_expr(expr_node, False)
2080
+
1151
2081
  def _security_next_inline_name(self, sec_id: int, func_name: str, base_name: str) -> str:
1152
2082
  self._security_inline_counter += 1
1153
2083
  return (
@@ -1168,13 +2098,18 @@ class SecurityEmitter:
1168
2098
  resolving = set()
1169
2099
 
1170
2100
  if isinstance(expr_node, Identifier):
1171
- bound = self._security_lookup_helper_binding(expr_node.name, helper_binding_stack)
1172
- if bound is not None:
2101
+ binding = None
2102
+ if not self._security_identifier_is_global_binding(expr_node):
2103
+ binding = self._security_lookup_helper_binding_context(
2104
+ expr_node.name, helper_binding_stack
2105
+ )
2106
+ if binding is not None:
2107
+ bound, bound_stack = binding
1173
2108
  return self._expr_depends_on_security_mutables(
1174
2109
  bound,
1175
2110
  security_mutable_names,
1176
2111
  resolving,
1177
- helper_binding_stack,
2112
+ bound_stack,
1178
2113
  )
1179
2114
  if expr_node.name in security_mutable_names:
1180
2115
  return True
@@ -1296,16 +2231,180 @@ class SecurityEmitter:
1296
2231
  if idx < len(all_args) and all_args[idx] is not None
1297
2232
  ]
1298
2233
 
2234
+ def _security_ta_ctor_args_for_variant(
2235
+ self,
2236
+ sec_id: int,
2237
+ site: TACallSite,
2238
+ helper_binding_stack: tuple[dict[str, ASTNode], ...] | None,
2239
+ fallback_args: list[str] | None = None,
2240
+ ) -> tuple[list[str], list[bool] | None]:
2241
+ """Resolve TA constructor args in one helper-call lexical context.
2242
+
2243
+ ``TACallSite.ctor_args`` is a source-string snapshot which ordinary
2244
+ callable specialization mutates from the first call site. A single
2245
+ TA AST can also be reached several times while lowering one
2246
+ ``request.security`` tuple (for example ``ema(src, 2)`` through
2247
+ ``ema(src, 5)``). Those requested-context variants share the AST but
2248
+ must not share that first snapshot. Re-lower the constructor AST
2249
+ through the exact helper binding stack used to key the variant.
2250
+
2251
+ Calls outside an inlined helper retain the established string path so
2252
+ input-backed runtime resets and direct top-level TA sites stay
2253
+ byte-compatible. The optional boolean list records, per argument,
2254
+ whether the source AST is a bar-invariant scalar after resolving its
2255
+ exact helper bindings. Runtime-reset collection uses that provenance
2256
+ to reuse trusted lowered C++ without allowing a mixed input + series
2257
+ expression merely because it contains a generated ``get_input_*`` call.
2258
+ """
2259
+ fallback = list(
2260
+ site.ctor_args if fallback_args is None else fallback_args
2261
+ )
2262
+ if not helper_binding_stack:
2263
+ return fallback, None
2264
+ arg_nodes = self._security_ta_ctor_arg_nodes(site)
2265
+ if len(arg_nodes) != len(fallback):
2266
+ return fallback, None
2267
+ return (
2268
+ [
2269
+ self._build_security_expr(
2270
+ sec_id,
2271
+ arg,
2272
+ None,
2273
+ {},
2274
+ resolving=set(),
2275
+ security_mutable_names=set(),
2276
+ helper_binding_stack=helper_binding_stack,
2277
+ emitted_lines=None,
2278
+ )
2279
+ for arg in arg_nodes
2280
+ ],
2281
+ [
2282
+ self._security_ta_ctor_arg_is_stable(
2283
+ arg,
2284
+ helper_binding_stack,
2285
+ )
2286
+ for arg in arg_nodes
2287
+ ],
2288
+ )
2289
+
2290
+ def _security_ta_ctor_arg_is_stable(
2291
+ self,
2292
+ node: ASTNode,
2293
+ helper_binding_stack: tuple[dict[str, ASTNode], ...] | None,
2294
+ ) -> bool:
2295
+ """Classify one helper-bound requested-context TA constructor arg.
2296
+
2297
+ ``_expr_is_stable`` already owns the compiler's conservative definition
2298
+ of a per-run scalar. Resolve helper parameters first, preserving the
2299
+ authored AST shape, and then delegate to that shared classifier. This
2300
+ prevents a later helper call such as ``ema(src, inputLen + int(close))``
2301
+ from bypassing the ordinary TA-length guard just because an earlier
2302
+ call through the same source TA site used a safe length.
2303
+ """
2304
+ import dataclasses
2305
+
2306
+ def resolve(
2307
+ current,
2308
+ stack: tuple[dict[str, ASTNode], ...] | None,
2309
+ seen: frozenset[tuple[str, int]] = frozenset(),
2310
+ depth: int = 0,
2311
+ ):
2312
+ if current is None or depth > 32:
2313
+ return None
2314
+ if isinstance(current, Identifier):
2315
+ binding = None
2316
+ if not self._security_identifier_is_global_binding(current):
2317
+ binding = self._security_lookup_helper_binding_context(
2318
+ current.name,
2319
+ stack,
2320
+ )
2321
+ if binding is None:
2322
+ return current
2323
+ bound, bound_stack = binding
2324
+ if isinstance(bound, str):
2325
+ return None
2326
+ key = (current.name, id(bound))
2327
+ if key in seen:
2328
+ return None
2329
+ return resolve(
2330
+ bound,
2331
+ bound_stack,
2332
+ seen | {key},
2333
+ depth + 1,
2334
+ )
2335
+ if isinstance(
2336
+ current,
2337
+ (NumberLiteral, StringLiteral, BoolLiteral, NaLiteral),
2338
+ ):
2339
+ return current
2340
+ if isinstance(current, MemberAccess):
2341
+ obj = resolve(current.object, stack, seen, depth + 1)
2342
+ if obj is None:
2343
+ return None
2344
+ return dataclasses.replace(current, object=obj)
2345
+ if isinstance(current, BinOp):
2346
+ left = resolve(current.left, stack, seen, depth + 1)
2347
+ right = resolve(current.right, stack, seen, depth + 1)
2348
+ if left is None or right is None:
2349
+ return None
2350
+ return dataclasses.replace(current, left=left, right=right)
2351
+ if isinstance(current, UnaryOp):
2352
+ operand = resolve(current.operand, stack, seen, depth + 1)
2353
+ if operand is None:
2354
+ return None
2355
+ return dataclasses.replace(current, operand=operand)
2356
+ if isinstance(current, Ternary):
2357
+ condition = resolve(current.condition, stack, seen, depth + 1)
2358
+ true_val = resolve(current.true_val, stack, seen, depth + 1)
2359
+ false_val = resolve(current.false_val, stack, seen, depth + 1)
2360
+ if condition is None or true_val is None or false_val is None:
2361
+ return None
2362
+ return dataclasses.replace(
2363
+ current,
2364
+ condition=condition,
2365
+ true_val=true_val,
2366
+ false_val=false_val,
2367
+ )
2368
+ if isinstance(current, FuncCall):
2369
+ args = [resolve(arg, stack, seen, depth + 1) for arg in current.args]
2370
+ kwargs = {
2371
+ name: resolve(arg, stack, seen, depth + 1)
2372
+ for name, arg in current.kwargs.items()
2373
+ }
2374
+ if any(arg is None for arg in args) or any(
2375
+ arg is None for arg in kwargs.values()
2376
+ ):
2377
+ return None
2378
+ return dataclasses.replace(current, args=args, kwargs=kwargs)
2379
+ # History, collections, and every other unhandled shape are not
2380
+ # stable TA lengths. Fail closed instead of inventing a buffer size.
2381
+ return None
2382
+
2383
+ resolved = resolve(node, helper_binding_stack)
2384
+ return resolved is not None and self._expr_is_stable(resolved)
2385
+
1299
2386
  def _security_ta_ctor_depends_on_mutables(
1300
2387
  self,
1301
2388
  site: TACallSite,
1302
2389
  security_mutable_names: set[str],
1303
2390
  helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None,
1304
2391
  ) -> bool:
2392
+ # A top-level ``var int L = 5`` is normally classified as mutable by
2393
+ # request.security because persistent state is rebound per requested
2394
+ # context. The TA constructor does not read that mutable member when
2395
+ # ``L`` passed the declaration-exact stable-literal admission proof:
2396
+ # all main/requested-context buffers are sized directly from ``5``.
2397
+ # Remove only those admitted names for this ctor-dependency query.
2398
+ # Compute arguments and every other security mutability path retain
2399
+ # the full set, and helper bindings still resolve transitively before
2400
+ # checking the reduced set.
2401
+ ctor_mutable_names = security_mutable_names.difference(
2402
+ getattr(self, "_stable_var_ctor_literals", {})
2403
+ )
1305
2404
  return any(
1306
2405
  self._expr_depends_on_security_mutables(
1307
2406
  arg,
1308
- security_mutable_names,
2407
+ ctor_mutable_names,
1309
2408
  helper_binding_stack=helper_binding_stack,
1310
2409
  )
1311
2410
  for arg in self._security_ta_ctor_arg_nodes(site)
@@ -1328,8 +2427,13 @@ class SecurityEmitter:
1328
2427
  resolving = set()
1329
2428
 
1330
2429
  if isinstance(expr_node, Identifier):
1331
- bound = self._security_lookup_helper_binding(expr_node.name, helper_binding_stack)
1332
- if bound is not None:
2430
+ binding = None
2431
+ if not self._security_identifier_is_global_binding(expr_node):
2432
+ binding = self._security_lookup_helper_binding_context(
2433
+ expr_node.name, helper_binding_stack
2434
+ )
2435
+ if binding is not None:
2436
+ bound, bound_stack = binding
1333
2437
  if isinstance(bound, str):
1334
2438
  return collected
1335
2439
  # Guard against a cyclic helper binding: an identifier bound to
@@ -1345,7 +2449,7 @@ class SecurityEmitter:
1345
2449
  self._collect_security_ta_binding_stacks(
1346
2450
  bound,
1347
2451
  resolving,
1348
- helper_binding_stack,
2452
+ bound_stack,
1349
2453
  collected,
1350
2454
  inline_ta_indices,
1351
2455
  inline_helper,
@@ -1369,12 +2473,16 @@ class SecurityEmitter:
1369
2473
  return collected
1370
2474
 
1371
2475
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
1372
- if expr_node.name in global_expr_map and expr_node.name not in resolving:
2476
+ if (
2477
+ self._security_identifier_is_global_binding(expr_node)
2478
+ and expr_node.name in global_expr_map
2479
+ and expr_node.name not in resolving
2480
+ ):
1373
2481
  resolving.add(expr_node.name)
1374
2482
  self._collect_security_ta_binding_stacks(
1375
2483
  global_expr_map[expr_node.name],
1376
2484
  resolving,
1377
- helper_binding_stack,
2485
+ (),
1378
2486
  collected,
1379
2487
  inline_ta_indices,
1380
2488
  inline_helper,
@@ -1736,6 +2844,156 @@ class SecurityEmitter:
1736
2844
  )
1737
2845
  return out
1738
2846
 
2847
+ def _security_lazy_ta_keys(
2848
+ self,
2849
+ sec_id: int,
2850
+ expr_node,
2851
+ info: dict,
2852
+ ) -> set[tuple[int, tuple]]:
2853
+ """``(ta_index, binding_signature)`` pairs Pine reaches only lazily.
2854
+
2855
+ Pine evaluates ``and``/``or`` with left-to-right short-circuit and a
2856
+ ternary evaluates its condition plus exactly one branch. A ``ta.*``
2857
+ call sitting in a skipped operand is **not evaluated on that bar**, so
2858
+ its series state does not advance. The evaluator used to hoist every
2859
+ collected site into an unconditional ``auto _secval_N = ...compute(...)``
2860
+ prologue, which advanced every site on every HTF bar and desynchronised
2861
+ the series against TradingView.
2862
+
2863
+ A site returned here is dropped from that prologue. ``_build_security_expr``
2864
+ then falls through to its inline
2865
+ ``(security_series_slot_is_new(N) ? m.compute(a) : m.recompute(a))``
2866
+ form, emitted in expression position — C++'s own ``&&`` / ``||`` /
2867
+ ``?:`` short-circuit then advances the state exactly when Pine would,
2868
+ and the relational lowering's ``_pna_l`` / ``_pna_r`` temporaries keep
2869
+ the evaluation order left-to-right.
2870
+
2871
+ Deliberately conservative — anything not provably single-reach and
2872
+ conditional keeps the existing eager hoist, so scripts without
2873
+ conditional security TA regenerate byte-identically:
2874
+
2875
+ - A site reached more than once must stay hoisted: sharing one
2876
+ ``_secval_*`` is what keeps it to a single advance per bar, whereas
2877
+ two inline copies would advance it twice (or zero times).
2878
+ - Laziness does **not** propagate through a global binding. In the
2879
+ requested context that global is its own unconditional top-level
2880
+ statement, so it evaluates on every HTF bar wherever it is read
2881
+ (this is what keeps chart-side ``t0``-shaped code exact).
2882
+ - Sites with a history offset (``ta.ema(close, 55)[1]``) keep the
2883
+ committed ``_secval_*`` their per-bar Series push reads.
2884
+ - Securities that rebind mutable globals, and multi-statement helper
2885
+ calls, bail entirely: both lower through statement emitters that
2886
+ consume ``ta_results`` outside this expression.
2887
+ """
2888
+ if info.get("mutable_globals"):
2889
+ return set()
2890
+
2891
+ occurrences: dict[tuple[int, tuple], list[bool]] = {}
2892
+ unsafe = False
2893
+
2894
+ def walk(node, lazy: bool, binding_stack, resolving: set[str]) -> None:
2895
+ nonlocal unsafe
2896
+ if node is None or unsafe:
2897
+ return
2898
+
2899
+ if isinstance(node, Identifier):
2900
+ binding = None
2901
+ if not self._security_identifier_is_global_binding(node):
2902
+ binding = self._security_lookup_helper_binding_context(
2903
+ node.name, binding_stack
2904
+ )
2905
+ if binding is not None:
2906
+ bound, bound_stack = binding
2907
+ if isinstance(bound, str):
2908
+ return
2909
+ bind_key = f"bind:{id(bound)}"
2910
+ if bind_key in resolving:
2911
+ return
2912
+ resolving.add(bind_key)
2913
+ walk(bound, lazy, bound_stack, resolving)
2914
+ resolving.discard(bind_key)
2915
+ return
2916
+
2917
+ if self._global_mutable_infos.get(node.name) is not None:
2918
+ unsafe = True
2919
+ return
2920
+
2921
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
2922
+ if (
2923
+ self._security_identifier_is_global_binding(node)
2924
+ and node.name in global_expr_map
2925
+ and node.name not in resolving
2926
+ ):
2927
+ resolving.add(node.name)
2928
+ walk(global_expr_map[node.name], False, (), resolving)
2929
+ resolving.remove(node.name)
2930
+ return
2931
+
2932
+ if isinstance(node, FuncCall) and isinstance(node.callee, Identifier):
2933
+ func_name = node.callee.name
2934
+ if func_name in self._func_names:
2935
+ call_key = f"func:{func_name}"
2936
+ if call_key in resolving:
2937
+ return
2938
+ resolving.add(call_key)
2939
+ plan = self._security_helper_call_plan(node, binding_stack)
2940
+ if plan["mode"] == "expr":
2941
+ walk(plan["expr"], lazy, plan["binding_stack"], resolving)
2942
+ else:
2943
+ unsafe = True
2944
+ resolving.discard(call_key)
2945
+ return
2946
+
2947
+ if isinstance(node, BinOp) and node.op in ("and", "or"):
2948
+ walk(node.left, lazy, binding_stack, resolving)
2949
+ walk(node.right, True, binding_stack, resolving)
2950
+ return
2951
+
2952
+ if isinstance(node, Ternary):
2953
+ walk(node.condition, lazy, binding_stack, resolving)
2954
+ walk(node.true_val, True, binding_stack, resolving)
2955
+ walk(node.false_val, True, binding_stack, resolving)
2956
+ return
2957
+
2958
+ site = self._get_ta_site(node)
2959
+ if site is not None:
2960
+ idx = self._ta_index_by_site_id.get(id(site))
2961
+ if idx is not None:
2962
+ key = (idx, self._security_binding_stack_signature(binding_stack))
2963
+ occurrences.setdefault(key, []).append(lazy)
2964
+
2965
+ for child in vars(node).values():
2966
+ walk_value(child, lazy, binding_stack, resolving)
2967
+
2968
+ def walk_value(value, lazy: bool, binding_stack, resolving: set[str]) -> None:
2969
+ if value is None or unsafe:
2970
+ return
2971
+ if hasattr(value, "__dict__"):
2972
+ walk(value, lazy, binding_stack, resolving)
2973
+ return
2974
+ if isinstance(value, (list, tuple)):
2975
+ for item in value:
2976
+ walk_value(item, lazy, binding_stack, resolving)
2977
+ return
2978
+ if isinstance(value, dict):
2979
+ for item in value.values():
2980
+ walk_value(item, lazy, binding_stack, resolving)
2981
+
2982
+ walk(expr_node, False, (), set())
2983
+ if unsafe:
2984
+ return set()
2985
+
2986
+ hist_indices = set(self._security_ta_hist_idx_by_sec.get(sec_id, ()))
2987
+ inline_helper_ta_indices = set(info.get("inline_helper_ta_indices", []))
2988
+ return {
2989
+ key
2990
+ for key, reaches in occurrences.items()
2991
+ if key[0] not in hist_indices
2992
+ and key[0] not in inline_helper_ta_indices
2993
+ and len(reaches) == 1
2994
+ and reaches[0]
2995
+ }
2996
+
1739
2997
  def _emit_security_ohlc_hist_pushes(self, sec_id: int, lines: list[str]) -> None:
1740
2998
  """Emit the OHLC history-offset Series pushes for ``sec_id``, gated on
1741
2999
  ``is_complete``.
@@ -1801,6 +3059,7 @@ class SecurityEmitter:
1801
3059
  ta_indices = info.get("ta_indices") or []
1802
3060
  security_mutable_names = set(info.get("mutable_globals", []))
1803
3061
  inline_helper_ta_indices = set(info.get("inline_helper_ta_indices", []))
3062
+ lazy_ta_keys = self._security_lazy_ta_keys(sec_id, expr_node, info)
1804
3063
 
1805
3064
  lines.append(f" void _eval_security_{sec_id}(const Bar& bar, bool is_complete) {{")
1806
3065
 
@@ -1841,6 +3100,15 @@ class SecurityEmitter:
1841
3100
  site = self.ctx.ta_call_sites[idx]
1842
3101
  variants = (info.get("ta_variants") or {}).get(idx, [])
1843
3102
  for variant in variants:
3103
+ if (idx, variant["signature"]) in lazy_ta_keys:
3104
+ # Pine only reaches this site through a
3105
+ # short-circuited operand or an untaken ternary
3106
+ # branch. Leave it out of the eager prologue:
3107
+ # _build_security_expr emits its
3108
+ # compute()/recompute() inline in expression
3109
+ # position, where C++'s &&/||/?: short-circuit
3110
+ # advances the series exactly on the bars Pine does.
3111
+ continue
1844
3112
  helper_binding_stack = variant.get("binding_stack", ())
1845
3113
  compute_args = self._security_ta_compute_args_for_site(
1846
3114
  sec_id,
@@ -1993,11 +3261,17 @@ class SecurityEmitter:
1993
3261
  "bb": "ta::BBResult",
1994
3262
  "kc": "ta::KCResult",
1995
3263
  "vwap_bands": "ta::VWAPBandsResult",
1996
- }.get(ta_name, "std::tuple<double, double>")
3264
+ }.get(
3265
+ ta_name,
3266
+ self._security_helper_tuple_cpp_type(
3267
+ tuple_size,
3268
+ item.get("tuple_element_types", ()),
3269
+ ),
3270
+ )
1997
3271
  lines.append(f" case {sec_id}:")
1998
3272
  lines.append(
1999
3273
  f" _req_sec_{sec_id} = "
2000
- f"{self._security_tuple_result_default(ctype, tuple_size)};"
3274
+ f"{self._security_tuple_result_default(ctype, tuple_size, item.get('tuple_element_types', ()))};"
2001
3275
  )
2002
3276
  for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
2003
3277
  lines.append(
@@ -2052,8 +3326,13 @@ class SecurityEmitter:
2052
3326
  helper_binding_stack = ()
2053
3327
 
2054
3328
  if isinstance(expr_node, Identifier):
2055
- bound = self._security_lookup_helper_binding(expr_node.name, helper_binding_stack)
2056
- if bound is not None:
3329
+ binding = None
3330
+ if not self._security_identifier_is_global_binding(expr_node):
3331
+ binding = self._security_lookup_helper_binding_context(
3332
+ expr_node.name, helper_binding_stack
3333
+ )
3334
+ if binding is not None:
3335
+ bound, bound_stack = binding
2057
3336
  if isinstance(bound, str):
2058
3337
  series_name = self._security_series_binding_target(bound)
2059
3338
  if series_name is not None:
@@ -2066,7 +3345,7 @@ class SecurityEmitter:
2066
3345
  ta_results,
2067
3346
  resolving,
2068
3347
  security_mutable_names,
2069
- helper_binding_stack,
3348
+ bound_stack,
2070
3349
  emitted_lines,
2071
3350
  )
2072
3351
  bar_fields = {
@@ -2086,7 +3365,11 @@ class SecurityEmitter:
2086
3365
  return state_name
2087
3366
 
2088
3367
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
2089
- if expr_node.name in global_expr_map and expr_node.name not in resolving:
3368
+ if (
3369
+ self._security_identifier_is_global_binding(expr_node)
3370
+ and expr_node.name in global_expr_map
3371
+ and expr_node.name not in resolving
3372
+ ):
2090
3373
  resolving.add(expr_node.name)
2091
3374
  resolved = self._build_security_expr(
2092
3375
  sec_id,
@@ -2095,7 +3378,7 @@ class SecurityEmitter:
2095
3378
  ta_results,
2096
3379
  resolving,
2097
3380
  security_mutable_names,
2098
- helper_binding_stack,
3381
+ (),
2099
3382
  emitted_lines,
2100
3383
  )
2101
3384
  resolving.remove(expr_node.name)
@@ -2111,38 +3394,60 @@ class SecurityEmitter:
2111
3394
  return resolved
2112
3395
 
2113
3396
  if isinstance(expr_node, Subscript):
2114
- index_cpp = self._build_security_expr(
2115
- sec_id,
2116
- expr_node.index,
2117
- ta_range,
2118
- ta_results,
2119
- resolving,
2120
- security_mutable_names,
2121
- helper_binding_stack,
2122
- emitted_lines,
2123
- )
2124
3397
  if isinstance(expr_node.object, Identifier):
2125
- bound = self._security_lookup_helper_binding(expr_node.object.name, helper_binding_stack)
2126
- if bound is not None:
3398
+ binding = None
3399
+ if not self._security_identifier_is_global_binding(expr_node.object):
3400
+ binding = self._security_lookup_helper_binding_context(
3401
+ expr_node.object.name, helper_binding_stack
3402
+ )
3403
+ if binding is not None:
3404
+ bound, bound_stack = binding
2127
3405
  if isinstance(bound, str):
2128
3406
  series_name = self._security_series_binding_target(bound)
2129
3407
  if series_name is not None:
3408
+ index_cpp = self._build_security_expr(
3409
+ sec_id,
3410
+ expr_node.index,
3411
+ ta_range,
3412
+ ta_results,
3413
+ resolving,
3414
+ security_mutable_names,
3415
+ helper_binding_stack,
3416
+ emitted_lines,
3417
+ )
2130
3418
  return f'_security_helper_series_["{series_name}"][{index_cpp}]'
2131
3419
  return bound
2132
- obj_cpp = self._build_security_expr(
2133
- sec_id,
3420
+ # Function parameters retain Pine's series identity. Apply
3421
+ # history to the supported bound bar series and compose
3422
+ # nested offsets before lowering. Unsupported scalar/
3423
+ # expression bindings fail closed in the shared helper.
3424
+ local_index = expr_node.index
3425
+ resolved_local_index = self._resolve_security_index_literal(
3426
+ expr_node.index, helper_binding_stack
3427
+ )
3428
+ if resolved_local_index is not None:
3429
+ local_index = NumberLiteral(value=resolved_local_index)
3430
+ composed = self._compose_security_helper_history_subscript(
2134
3431
  bound,
3432
+ local_index,
3433
+ expr_node,
3434
+ )
3435
+ return self._build_security_expr(
3436
+ sec_id,
3437
+ composed,
2135
3438
  ta_range,
2136
3439
  ta_results,
2137
3440
  resolving,
2138
3441
  security_mutable_names,
2139
- helper_binding_stack,
3442
+ bound_stack,
2140
3443
  emitted_lines,
2141
3444
  )
2142
- return f"{obj_cpp}[{index_cpp}]"
2143
3445
  if expr_node.object.name in SECURITY_BAR_FIELDS:
2144
3446
  field = expr_node.object.name
2145
- idx_lit = self._literal_int_for_security_index(expr_node.index)
3447
+ idx_lit = self._resolve_security_index_literal(
3448
+ expr_node.index,
3449
+ helper_binding_stack,
3450
+ )
2146
3451
  if idx_lit is not None:
2147
3452
  if idx_lit == 0:
2148
3453
  return self._security_bar_field_expr(field)
@@ -2162,6 +3467,16 @@ class SecurityEmitter:
2162
3467
  hist = self._security_ohlc_hist_series_cpp(sec_id, field)
2163
3468
  cpp_t = self._security_bar_hist_type(field)
2164
3469
  current = self._security_bar_field_expr(field)
3470
+ index_cpp = self._build_security_expr(
3471
+ sec_id,
3472
+ expr_node.index,
3473
+ ta_range,
3474
+ ta_results,
3475
+ resolving,
3476
+ security_mutable_names,
3477
+ helper_binding_stack,
3478
+ emitted_lines,
3479
+ )
2165
3480
  return (
2166
3481
  f"([&]() -> {cpp_t} {{ "
2167
3482
  f"int _hidx = (int)({index_cpp}); "
@@ -2181,8 +3496,11 @@ class SecurityEmitter:
2181
3496
  # helper binding, whichever the resolved expression turns out
2182
3497
  # to be) instead of duplicating that dispatch here.
2183
3498
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
2184
- if (expr_node.object.name in global_expr_map
2185
- and expr_node.object.name not in resolving):
3499
+ if (
3500
+ self._security_identifier_is_global_binding(expr_node.object)
3501
+ and expr_node.object.name in global_expr_map
3502
+ and expr_node.object.name not in resolving
3503
+ ):
2186
3504
  resolving.add(expr_node.object.name)
2187
3505
  resolved = self._build_security_expr(
2188
3506
  sec_id,
@@ -2203,6 +3521,16 @@ class SecurityEmitter:
2203
3521
  meta = self._security_expr_hist_by_node.get((sec_id, id(expr_node)))
2204
3522
  hist = meta["name"] if meta else f"_sec{sec_id}_expr_hist_missing"
2205
3523
  cpp_t = meta["type"] if meta else "double"
3524
+ index_cpp = self._build_security_expr(
3525
+ sec_id,
3526
+ expr_node.index,
3527
+ ta_range,
3528
+ ta_results,
3529
+ resolving,
3530
+ security_mutable_names,
3531
+ helper_binding_stack,
3532
+ emitted_lines,
3533
+ )
2206
3534
  inner = self._build_security_expr(
2207
3535
  sec_id,
2208
3536
  expr_node.object,
@@ -2234,14 +3562,72 @@ class SecurityEmitter:
2234
3562
  # tick), so without a magnifier it advanced every chart bar and
2235
3563
  # produced the chart-tf TA instead of the confirmed HTF value.
2236
3564
  idx = self._ta_index_by_site_id.get(id(ta_site))
2237
- sig = self._security_binding_stack_signature(helper_binding_stack)
3565
+ # A top-level TA site reached through a global alias owns the
3566
+ # global variant even when the subscript index was authored
3567
+ # in a helper. Keep the helper stack for resolving that
3568
+ # index, but do not use it to select the TA object's state.
3569
+ ta_binding_stack = (
3570
+ ()
3571
+ if getattr(ta_site, "owner_func", None) is None
3572
+ else helper_binding_stack
3573
+ )
3574
+ sig = self._security_binding_stack_signature(ta_binding_stack)
2238
3575
  idx_lit = self._resolve_security_index_literal(
2239
3576
  expr_node.index, helper_binding_stack
2240
3577
  )
2241
3578
  if idx_lit is None:
3579
+ resolved_input = self._resolve_security_immutable_input_int(
3580
+ expr_node.index, helper_binding_stack
3581
+ )
3582
+ if resolved_input is None:
3583
+ self._codegen_error(
3584
+ expr_node,
3585
+ "request.security() TA history index must be a literal integer (e.g. ta.ema(close, 55)[1])",
3586
+ )
3587
+ input_node, input_stack = resolved_input
3588
+ index_cpp = self._build_security_expr(
3589
+ sec_id,
3590
+ input_node,
3591
+ ta_range,
3592
+ ta_results,
3593
+ resolving,
3594
+ security_mutable_names,
3595
+ input_stack,
3596
+ emitted_lines,
3597
+ )
3598
+ result_key = (idx, sig)
3599
+ if result_key not in ta_results:
3600
+ self._codegen_error(
3601
+ expr_node,
3602
+ "request.security multi-statement helper TA history is not supported",
3603
+ hint="Use a single-expression helper for TA history offsets.",
3604
+ )
3605
+ current = self._build_security_expr(
3606
+ sec_id,
3607
+ expr_node.object,
3608
+ ta_range,
3609
+ ta_results,
3610
+ resolving,
3611
+ security_mutable_names,
3612
+ ta_binding_stack,
3613
+ emitted_lines,
3614
+ )
3615
+ member_name = self._security_ta_variant_names.get(
3616
+ (sec_id, idx, sig),
3617
+ f"_sec{sec_id}_{ta_site.member_name}",
3618
+ )
3619
+ hist = self._security_ta_hist_series_cpp(member_name)
3620
+ return (
3621
+ "([&]() -> double { "
3622
+ f"int _hidx = (int)({index_cpp}); "
3623
+ "if (is_na(_hidx) || _hidx < 0) return na<double>(); "
3624
+ f"return (_hidx == 0) ? {current} : {hist}[_hidx - 1]; "
3625
+ "}())"
3626
+ )
3627
+ if idx_lit < 0:
2242
3628
  self._codegen_error(
2243
3629
  expr_node,
2244
- "request.security() TA history index must be a literal integer (e.g. ta.ema(close, 55)[1])",
3630
+ "request.security() TA history index must be non-negative",
2245
3631
  )
2246
3632
  if idx_lit == 0:
2247
3633
  # Current completed-HTF-bar value: reuse the bare-TA emission.
@@ -2252,9 +3638,15 @@ class SecurityEmitter:
2252
3638
  ta_results,
2253
3639
  resolving,
2254
3640
  security_mutable_names,
2255
- helper_binding_stack,
3641
+ ta_binding_stack,
2256
3642
  emitted_lines,
2257
3643
  )
3644
+ if (idx, sig) not in ta_results:
3645
+ self._codegen_error(
3646
+ expr_node,
3647
+ "request.security multi-statement helper TA history is not supported",
3648
+ hint="Use a single-expression helper for TA history offsets.",
3649
+ )
2258
3650
  member_name = self._security_ta_variant_names.get(
2259
3651
  (sec_id, idx, sig),
2260
3652
  f"_sec{sec_id}_{ta_site.member_name}",
@@ -2300,6 +3692,28 @@ class SecurityEmitter:
2300
3692
  )
2301
3693
  return f"(({cond}) ? ({tv}) : ({fv}))"
2302
3694
 
3695
+ if isinstance(expr_node, TupleLiteral):
3696
+ # A tuple returned by a user helper must lower every element in the
3697
+ # requested context. Falling through to the ordinary expression
3698
+ # visitor loses AST-valued helper parameter bindings (for example
3699
+ # ``f(src) => [src, src + 1]``) and can emit an undeclared ``src``.
3700
+ # Recursive lowering also composes helper locals, HTF history, and
3701
+ # TA results for arbitrary supported tuple arity.
3702
+ elements = [
3703
+ self._build_security_expr(
3704
+ sec_id,
3705
+ element,
3706
+ ta_range,
3707
+ ta_results,
3708
+ resolving,
3709
+ security_mutable_names,
3710
+ helper_binding_stack,
3711
+ emitted_lines,
3712
+ )
3713
+ for element in expr_node.elements
3714
+ ]
3715
+ return f"std::make_tuple({', '.join(elements)})"
3716
+
2303
3717
  if isinstance(expr_node, FuncCall) and isinstance(expr_node.callee, Identifier):
2304
3718
  func_name = expr_node.callee.name
2305
3719
  if func_name in self._func_names: