@pineforge/codegen-pyodide 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -1993,11 +3101,17 @@ class SecurityEmitter:
1993
3101
  "bb": "ta::BBResult",
1994
3102
  "kc": "ta::KCResult",
1995
3103
  "vwap_bands": "ta::VWAPBandsResult",
1996
- }.get(ta_name, "std::tuple<double, double>")
3104
+ }.get(
3105
+ ta_name,
3106
+ self._security_helper_tuple_cpp_type(
3107
+ tuple_size,
3108
+ item.get("tuple_element_types", ()),
3109
+ ),
3110
+ )
1997
3111
  lines.append(f" case {sec_id}:")
1998
3112
  lines.append(
1999
3113
  f" _req_sec_{sec_id} = "
2000
- f"{self._security_tuple_result_default(ctype, tuple_size)};"
3114
+ f"{self._security_tuple_result_default(ctype, tuple_size, item.get('tuple_element_types', ()))};"
2001
3115
  )
2002
3116
  for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
2003
3117
  lines.append(
@@ -2052,8 +3166,13 @@ class SecurityEmitter:
2052
3166
  helper_binding_stack = ()
2053
3167
 
2054
3168
  if isinstance(expr_node, Identifier):
2055
- bound = self._security_lookup_helper_binding(expr_node.name, helper_binding_stack)
2056
- if bound is not None:
3169
+ binding = None
3170
+ if not self._security_identifier_is_global_binding(expr_node):
3171
+ binding = self._security_lookup_helper_binding_context(
3172
+ expr_node.name, helper_binding_stack
3173
+ )
3174
+ if binding is not None:
3175
+ bound, bound_stack = binding
2057
3176
  if isinstance(bound, str):
2058
3177
  series_name = self._security_series_binding_target(bound)
2059
3178
  if series_name is not None:
@@ -2066,7 +3185,7 @@ class SecurityEmitter:
2066
3185
  ta_results,
2067
3186
  resolving,
2068
3187
  security_mutable_names,
2069
- helper_binding_stack,
3188
+ bound_stack,
2070
3189
  emitted_lines,
2071
3190
  )
2072
3191
  bar_fields = {
@@ -2086,7 +3205,11 @@ class SecurityEmitter:
2086
3205
  return state_name
2087
3206
 
2088
3207
  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:
3208
+ if (
3209
+ self._security_identifier_is_global_binding(expr_node)
3210
+ and expr_node.name in global_expr_map
3211
+ and expr_node.name not in resolving
3212
+ ):
2090
3213
  resolving.add(expr_node.name)
2091
3214
  resolved = self._build_security_expr(
2092
3215
  sec_id,
@@ -2095,7 +3218,7 @@ class SecurityEmitter:
2095
3218
  ta_results,
2096
3219
  resolving,
2097
3220
  security_mutable_names,
2098
- helper_binding_stack,
3221
+ (),
2099
3222
  emitted_lines,
2100
3223
  )
2101
3224
  resolving.remove(expr_node.name)
@@ -2111,38 +3234,60 @@ class SecurityEmitter:
2111
3234
  return resolved
2112
3235
 
2113
3236
  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
3237
  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:
3238
+ binding = None
3239
+ if not self._security_identifier_is_global_binding(expr_node.object):
3240
+ binding = self._security_lookup_helper_binding_context(
3241
+ expr_node.object.name, helper_binding_stack
3242
+ )
3243
+ if binding is not None:
3244
+ bound, bound_stack = binding
2127
3245
  if isinstance(bound, str):
2128
3246
  series_name = self._security_series_binding_target(bound)
2129
3247
  if series_name is not None:
3248
+ index_cpp = self._build_security_expr(
3249
+ sec_id,
3250
+ expr_node.index,
3251
+ ta_range,
3252
+ ta_results,
3253
+ resolving,
3254
+ security_mutable_names,
3255
+ helper_binding_stack,
3256
+ emitted_lines,
3257
+ )
2130
3258
  return f'_security_helper_series_["{series_name}"][{index_cpp}]'
2131
3259
  return bound
2132
- obj_cpp = self._build_security_expr(
2133
- sec_id,
3260
+ # Function parameters retain Pine's series identity. Apply
3261
+ # history to the supported bound bar series and compose
3262
+ # nested offsets before lowering. Unsupported scalar/
3263
+ # expression bindings fail closed in the shared helper.
3264
+ local_index = expr_node.index
3265
+ resolved_local_index = self._resolve_security_index_literal(
3266
+ expr_node.index, helper_binding_stack
3267
+ )
3268
+ if resolved_local_index is not None:
3269
+ local_index = NumberLiteral(value=resolved_local_index)
3270
+ composed = self._compose_security_helper_history_subscript(
2134
3271
  bound,
3272
+ local_index,
3273
+ expr_node,
3274
+ )
3275
+ return self._build_security_expr(
3276
+ sec_id,
3277
+ composed,
2135
3278
  ta_range,
2136
3279
  ta_results,
2137
3280
  resolving,
2138
3281
  security_mutable_names,
2139
- helper_binding_stack,
3282
+ bound_stack,
2140
3283
  emitted_lines,
2141
3284
  )
2142
- return f"{obj_cpp}[{index_cpp}]"
2143
3285
  if expr_node.object.name in SECURITY_BAR_FIELDS:
2144
3286
  field = expr_node.object.name
2145
- idx_lit = self._literal_int_for_security_index(expr_node.index)
3287
+ idx_lit = self._resolve_security_index_literal(
3288
+ expr_node.index,
3289
+ helper_binding_stack,
3290
+ )
2146
3291
  if idx_lit is not None:
2147
3292
  if idx_lit == 0:
2148
3293
  return self._security_bar_field_expr(field)
@@ -2162,6 +3307,16 @@ class SecurityEmitter:
2162
3307
  hist = self._security_ohlc_hist_series_cpp(sec_id, field)
2163
3308
  cpp_t = self._security_bar_hist_type(field)
2164
3309
  current = self._security_bar_field_expr(field)
3310
+ index_cpp = self._build_security_expr(
3311
+ sec_id,
3312
+ expr_node.index,
3313
+ ta_range,
3314
+ ta_results,
3315
+ resolving,
3316
+ security_mutable_names,
3317
+ helper_binding_stack,
3318
+ emitted_lines,
3319
+ )
2165
3320
  return (
2166
3321
  f"([&]() -> {cpp_t} {{ "
2167
3322
  f"int _hidx = (int)({index_cpp}); "
@@ -2181,8 +3336,11 @@ class SecurityEmitter:
2181
3336
  # helper binding, whichever the resolved expression turns out
2182
3337
  # to be) instead of duplicating that dispatch here.
2183
3338
  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):
3339
+ if (
3340
+ self._security_identifier_is_global_binding(expr_node.object)
3341
+ and expr_node.object.name in global_expr_map
3342
+ and expr_node.object.name not in resolving
3343
+ ):
2186
3344
  resolving.add(expr_node.object.name)
2187
3345
  resolved = self._build_security_expr(
2188
3346
  sec_id,
@@ -2203,6 +3361,16 @@ class SecurityEmitter:
2203
3361
  meta = self._security_expr_hist_by_node.get((sec_id, id(expr_node)))
2204
3362
  hist = meta["name"] if meta else f"_sec{sec_id}_expr_hist_missing"
2205
3363
  cpp_t = meta["type"] if meta else "double"
3364
+ index_cpp = self._build_security_expr(
3365
+ sec_id,
3366
+ expr_node.index,
3367
+ ta_range,
3368
+ ta_results,
3369
+ resolving,
3370
+ security_mutable_names,
3371
+ helper_binding_stack,
3372
+ emitted_lines,
3373
+ )
2206
3374
  inner = self._build_security_expr(
2207
3375
  sec_id,
2208
3376
  expr_node.object,
@@ -2234,14 +3402,72 @@ class SecurityEmitter:
2234
3402
  # tick), so without a magnifier it advanced every chart bar and
2235
3403
  # produced the chart-tf TA instead of the confirmed HTF value.
2236
3404
  idx = self._ta_index_by_site_id.get(id(ta_site))
2237
- sig = self._security_binding_stack_signature(helper_binding_stack)
3405
+ # A top-level TA site reached through a global alias owns the
3406
+ # global variant even when the subscript index was authored
3407
+ # in a helper. Keep the helper stack for resolving that
3408
+ # index, but do not use it to select the TA object's state.
3409
+ ta_binding_stack = (
3410
+ ()
3411
+ if getattr(ta_site, "owner_func", None) is None
3412
+ else helper_binding_stack
3413
+ )
3414
+ sig = self._security_binding_stack_signature(ta_binding_stack)
2238
3415
  idx_lit = self._resolve_security_index_literal(
2239
3416
  expr_node.index, helper_binding_stack
2240
3417
  )
2241
3418
  if idx_lit is None:
3419
+ resolved_input = self._resolve_security_immutable_input_int(
3420
+ expr_node.index, helper_binding_stack
3421
+ )
3422
+ if resolved_input is None:
3423
+ self._codegen_error(
3424
+ expr_node,
3425
+ "request.security() TA history index must be a literal integer (e.g. ta.ema(close, 55)[1])",
3426
+ )
3427
+ input_node, input_stack = resolved_input
3428
+ index_cpp = self._build_security_expr(
3429
+ sec_id,
3430
+ input_node,
3431
+ ta_range,
3432
+ ta_results,
3433
+ resolving,
3434
+ security_mutable_names,
3435
+ input_stack,
3436
+ emitted_lines,
3437
+ )
3438
+ result_key = (idx, sig)
3439
+ if result_key not in ta_results:
3440
+ self._codegen_error(
3441
+ expr_node,
3442
+ "request.security multi-statement helper TA history is not supported",
3443
+ hint="Use a single-expression helper for TA history offsets.",
3444
+ )
3445
+ current = self._build_security_expr(
3446
+ sec_id,
3447
+ expr_node.object,
3448
+ ta_range,
3449
+ ta_results,
3450
+ resolving,
3451
+ security_mutable_names,
3452
+ ta_binding_stack,
3453
+ emitted_lines,
3454
+ )
3455
+ member_name = self._security_ta_variant_names.get(
3456
+ (sec_id, idx, sig),
3457
+ f"_sec{sec_id}_{ta_site.member_name}",
3458
+ )
3459
+ hist = self._security_ta_hist_series_cpp(member_name)
3460
+ return (
3461
+ "([&]() -> double { "
3462
+ f"int _hidx = (int)({index_cpp}); "
3463
+ "if (is_na(_hidx) || _hidx < 0) return na<double>(); "
3464
+ f"return (_hidx == 0) ? {current} : {hist}[_hidx - 1]; "
3465
+ "}())"
3466
+ )
3467
+ if idx_lit < 0:
2242
3468
  self._codegen_error(
2243
3469
  expr_node,
2244
- "request.security() TA history index must be a literal integer (e.g. ta.ema(close, 55)[1])",
3470
+ "request.security() TA history index must be non-negative",
2245
3471
  )
2246
3472
  if idx_lit == 0:
2247
3473
  # Current completed-HTF-bar value: reuse the bare-TA emission.
@@ -2252,9 +3478,15 @@ class SecurityEmitter:
2252
3478
  ta_results,
2253
3479
  resolving,
2254
3480
  security_mutable_names,
2255
- helper_binding_stack,
3481
+ ta_binding_stack,
2256
3482
  emitted_lines,
2257
3483
  )
3484
+ if (idx, sig) not in ta_results:
3485
+ self._codegen_error(
3486
+ expr_node,
3487
+ "request.security multi-statement helper TA history is not supported",
3488
+ hint="Use a single-expression helper for TA history offsets.",
3489
+ )
2258
3490
  member_name = self._security_ta_variant_names.get(
2259
3491
  (sec_id, idx, sig),
2260
3492
  f"_sec{sec_id}_{ta_site.member_name}",
@@ -2300,6 +3532,28 @@ class SecurityEmitter:
2300
3532
  )
2301
3533
  return f"(({cond}) ? ({tv}) : ({fv}))"
2302
3534
 
3535
+ if isinstance(expr_node, TupleLiteral):
3536
+ # A tuple returned by a user helper must lower every element in the
3537
+ # requested context. Falling through to the ordinary expression
3538
+ # visitor loses AST-valued helper parameter bindings (for example
3539
+ # ``f(src) => [src, src + 1]``) and can emit an undeclared ``src``.
3540
+ # Recursive lowering also composes helper locals, HTF history, and
3541
+ # TA results for arbitrary supported tuple arity.
3542
+ elements = [
3543
+ self._build_security_expr(
3544
+ sec_id,
3545
+ element,
3546
+ ta_range,
3547
+ ta_results,
3548
+ resolving,
3549
+ security_mutable_names,
3550
+ helper_binding_stack,
3551
+ emitted_lines,
3552
+ )
3553
+ for element in expr_node.elements
3554
+ ]
3555
+ return f"std::make_tuple({', '.join(elements)})"
3556
+
2303
3557
  if isinstance(expr_node, FuncCall) and isinstance(expr_node.callee, Identifier):
2304
3558
  func_name = expr_node.callee.name
2305
3559
  if func_name in self._func_names: