@pineforge/codegen-pyodide 0.9.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:
@@ -140,42 +163,182 @@ class SecurityEmitter:
140
163
  : inputTf`` must be expanded to their source expression with direct
141
164
  ``get_input_*`` reads. Emitting the member name would register with its
142
165
  default-constructed value (usually an empty string).
166
+
167
+ Delegates to :meth:`_substitute_tf_input_reads`, which rewrites the
168
+ input-derived *leaves* of the expression tree — including leaves buried
169
+ inside a ternary CONDITION or any BinOp / UnaryOp / FuncCall — and then
170
+ renders the substituted tree through the normal expression visitor.
143
171
  """
144
172
  if node is None:
145
173
  return None
146
- resolving = resolving or set()
147
- if isinstance(node, StringLiteral):
148
- return self._visit_expr(node)
174
+ if isinstance(node, SwitchStmt):
175
+ return self._security_tf_switch_runtime_expr(node, resolving or set())
176
+ substituted = self._substitute_tf_input_reads(node, resolving or set())
177
+ return self._visit_expr(substituted)
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
+
250
+ def _substitute_tf_input_reads(self, node, resolving: set[str]):
251
+ """Return ``node`` with input-derived leaves rewritten to expressions
252
+ that are valid at security-registration time (before ``on_bar()``
253
+ assigns members from their inputs):
254
+
255
+ * an input-backed var -> its ``input.*()`` source call (``get_input_*``);
256
+ * a ``timeframe.period`` alias var -> ``timeframe.period`` (``script_tf_``);
257
+ * a known compile-time string var -> that string literal;
258
+ * a global alias -> its defining expression, expanded recursively.
259
+
260
+ The walk descends through Ternary / BinOp / UnaryOp / FuncCall /
261
+ Subscript, so an input-backed identifier nested inside e.g. a ternary
262
+ condition (``mode == "15" ? "240" : "60"``) resolves to its input read
263
+ instead of the uninitialised member. A subtree containing no
264
+ input-derived leaf is returned unchanged (same object) so unaffected
265
+ timeframe expressions render byte-identically to before the fix.
266
+ MemberAccess and literals are left verbatim: ``timeframe.period`` is
267
+ already lowered to ``script_tf_`` by the expression visitor.
268
+ """
269
+ if not isinstance(node, ASTNode):
270
+ return node
149
271
  if isinstance(node, Identifier):
150
272
  name = node.name
151
273
  if name in self._timeframe_period_vars:
152
- return "script_tf_"
153
- if name in self._input_backed_vars and name in self._input_var_to_call:
154
- return self._visit_expr(self._input_var_to_call[name])
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)):
283
+ return self._input_var_to_call[name]
155
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)
156
286
  and isinstance(self._known_vars[name], str)):
157
- return self._visit_expr(StringLiteral(value=self._known_vars[name]))
287
+ return StringLiteral(
288
+ value=self._known_vars[name],
289
+ loc=node.loc,
290
+ annotations=node.annotations,
291
+ )
158
292
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
159
293
  if name in global_expr_map and name not in resolving:
160
- resolving.add(name)
161
- out = self._security_tf_runtime_expr(global_expr_map[name], resolving)
162
- resolving.remove(name)
163
- return out
164
- return self._visit_expr(node)
165
- if (
166
- isinstance(node, MemberAccess)
167
- and isinstance(node.object, Identifier)
168
- and node.object.name == "timeframe"
169
- and node.member == "period"
170
- ):
171
- return "script_tf_"
294
+ return self._substitute_tf_input_reads(
295
+ global_expr_map[name], resolving | {name})
296
+ return node
172
297
  if isinstance(node, Ternary):
173
- cond = self._security_tf_runtime_expr(node.condition, resolving)
174
- tv = self._security_tf_runtime_expr(node.true_val, resolving)
175
- fv = self._security_tf_runtime_expr(node.false_val, resolving)
176
- if cond is not None and tv is not None and fv is not None:
177
- return f"(({cond}) ? ({tv}) : ({fv}))"
178
- return self._visit_expr(node)
298
+ cond = self._substitute_tf_input_reads(node.condition, resolving)
299
+ tv = self._substitute_tf_input_reads(node.true_val, resolving)
300
+ fv = self._substitute_tf_input_reads(node.false_val, resolving)
301
+ if cond is node.condition and tv is node.true_val and fv is node.false_val:
302
+ return node
303
+ return replace(node, condition=cond, true_val=tv, false_val=fv)
304
+ if isinstance(node, BinOp):
305
+ left = self._substitute_tf_input_reads(node.left, resolving)
306
+ right = self._substitute_tf_input_reads(node.right, resolving)
307
+ if left is node.left and right is node.right:
308
+ return node
309
+ return replace(node, left=left, right=right)
310
+ if isinstance(node, UnaryOp):
311
+ operand = self._substitute_tf_input_reads(node.operand, resolving)
312
+ if operand is node.operand:
313
+ return node
314
+ return replace(node, operand=operand)
315
+ if isinstance(node, FuncCall):
316
+ new_args = [self._substitute_tf_input_reads(a, resolving) for a in node.args]
317
+ new_kwargs = {
318
+ k: (self._substitute_tf_input_reads(v, resolving)
319
+ if isinstance(v, ASTNode) else v)
320
+ for k, v in node.kwargs.items()
321
+ }
322
+ unchanged = (
323
+ all(a is b for a, b in zip(new_args, node.args))
324
+ and all(new_kwargs[k] is node.kwargs[k] for k in node.kwargs)
325
+ )
326
+ if unchanged:
327
+ return node
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)
335
+ if isinstance(node, Subscript):
336
+ obj = self._substitute_tf_input_reads(node.object, resolving)
337
+ idx = self._substitute_tf_input_reads(node.index, resolving)
338
+ if obj is node.object and idx is node.index:
339
+ return node
340
+ return replace(node, object=obj, index=idx)
341
+ return node
179
342
 
180
343
  def _resolve_param_tf_from_callsites(self, func_name: str, param_name: str):
181
344
  """For a ``request.security`` whose tf is function parameter ``param_name``
@@ -226,6 +389,9 @@ class SecurityEmitter:
226
389
  "expr_node": item.expression,
227
390
  "returns_tuple": item.returns_tuple,
228
391
  "tuple_size": item.tuple_size,
392
+ "tuple_element_types": tuple(
393
+ getattr(item, "tuple_element_types", ()) or ()
394
+ ),
229
395
  "gaps_node": item.gaps,
230
396
  "lookahead_node": item.lookahead,
231
397
  "ta_range": item.ta_range,
@@ -242,6 +408,7 @@ class SecurityEmitter:
242
408
  "expr_node": item[2] if len(item) > 2 else None,
243
409
  "returns_tuple": item[3] if len(item) > 3 else False,
244
410
  "tuple_size": item[4] if len(item) > 4 else 0,
411
+ "tuple_element_types": (),
245
412
  "gaps_node": item[5] if len(item) > 5 else None,
246
413
  "lookahead_node": item[6] if len(item) > 6 else None,
247
414
  "ta_range": item[7] if len(item) > 7 else None,
@@ -322,6 +489,22 @@ class SecurityEmitter:
322
489
  name: str,
323
490
  helper_binding_stack: tuple[dict[str, ASTNode], ...] | None,
324
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
+ """
325
508
  if not helper_binding_stack:
326
509
  return None
327
510
  for frame in reversed(helper_binding_stack):
@@ -330,7 +513,12 @@ class SecurityEmitter:
330
513
  bound = frame[name]
331
514
  if isinstance(bound, Identifier) and bound.name == name:
332
515
  continue
333
- 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
334
522
  return None
335
523
 
336
524
  def _literal_int_for_security_index(self, node) -> int | None:
@@ -353,11 +541,11 @@ class SecurityEmitter:
353
541
  return None
354
542
  return None
355
543
 
356
- # In PineForge batch backtests these barstate flags are compile-time
357
- # constants (see support_checker / codegen.visit_expr barstate emission):
358
- # every bar is historical, none realtime/last. The other flags
359
- # (isfirst/isnew/isconfirmed) depend on runtime tick state, so they are
360
- # 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.
361
549
  _SECURITY_CONST_BARSTATE = {
362
550
  "isrealtime": False,
363
551
  "islast": False,
@@ -373,10 +561,10 @@ class SecurityEmitter:
373
561
  ) -> bool | None:
374
562
  """Fold a boolean expression to a compile-time constant, or None.
375
563
 
376
- Only reduces expressions whose non-literal leaves are the batch-constant
377
- barstate flags above; anything runtime-dependent returns None. Used to
378
- resolve a request.security history index expressed as
379
- ``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."""
380
568
  if resolving is None:
381
569
  resolving = set()
382
570
  if isinstance(node, BoolLiteral):
@@ -403,16 +591,33 @@ class SecurityEmitter:
403
591
  return None
404
592
  return (left and right) if node.op == "and" else (left or right)
405
593
  if isinstance(node, Identifier):
406
- bound = self._security_lookup_helper_binding(node.name, helper_binding_stack)
407
- 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
603
+ return self._fold_security_const_bool(
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:
408
609
  return self._fold_security_const_bool(
409
- bound, helper_binding_stack, resolving
610
+ param_binding, (), resolving | {param_key}
410
611
  )
411
612
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
412
- 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
+ ):
413
618
  resolving.add(node.name)
414
619
  out = self._fold_security_const_bool(
415
- global_expr_map[node.name], helper_binding_stack, resolving
620
+ global_expr_map[node.name], (), resolving
416
621
  )
417
622
  resolving.remove(node.name)
418
623
  return out
@@ -428,27 +633,44 @@ class SecurityEmitter:
428
633
 
429
634
  Extends ``_literal_int_for_security_index`` by also resolving the index
430
635
  through helper-parameter bindings and global aliases and constant-folding
431
- a ternary whose condition is a batch-constant barstate flag (e.g.
432
- ``idxHigher = barstate.isrealtime ? 1 : 0`` -> 0). A literal index short-
433
- circuits on the first line, so behaviour is unchanged for every already-
434
- literal index. Returns None when the index cannot be reduced to a
435
- 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."""
436
641
  direct = self._literal_int_for_security_index(node)
437
642
  if direct is not None:
438
643
  return direct
439
644
  if resolving is None:
440
645
  resolving = set()
441
646
  if isinstance(node, Identifier):
442
- bound = self._security_lookup_helper_binding(node.name, helper_binding_stack)
443
- 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
656
+ return self._resolve_security_index_literal(
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:
444
662
  return self._resolve_security_index_literal(
445
- bound, helper_binding_stack, resolving
663
+ param_binding, (), resolving | {param_key}
446
664
  )
447
665
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
448
- 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
+ ):
449
671
  resolving.add(node.name)
450
672
  out = self._resolve_security_index_literal(
451
- global_expr_map[node.name], helper_binding_stack, resolving
673
+ global_expr_map[node.name], (), resolving
452
674
  )
453
675
  resolving.remove(node.name)
454
676
  return out
@@ -463,35 +685,395 @@ class SecurityEmitter:
463
685
  )
464
686
  return None
465
687
 
466
- def _collect_security_ohlc_hist_fields(self, node) -> set[str]:
467
- """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
+ """
468
910
  out: set[str] = set()
911
+ if resolving is None:
912
+ resolving = set()
469
913
 
470
- def walk(n):
471
- if n is None:
914
+ def walk(n, bindings) -> None:
915
+ if n is None or isinstance(n, str):
472
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
+
473
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
474
977
  if n.object.name in SECURITY_BAR_FIELDS:
475
- idx = self._literal_int_for_security_index(n.index)
476
- # high[0]/time[0] uses current HTF `bar`; k>=1 reads prior
477
- # completed HTF bars from Series history (filled before push
478
- # in _eval_security_*). Dynamic indices may be >=1 at
479
- # 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.
480
981
  if idx is None or idx >= 1:
481
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
+
482
1064
  if isinstance(n, (list, tuple)):
483
- for x in n:
484
- walk(x)
1065
+ for child in n:
1066
+ walk(child, bindings)
485
1067
  return
486
- for _k, v in getattr(n, "__dict__", {}).items():
487
- if isinstance(v, ASTNode):
488
- walk(v)
489
- elif isinstance(v, (list, tuple)):
490
- for x in v:
491
- if isinstance(x, ASTNode):
492
- walk(x)
493
-
494
- 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 ())
495
1077
  return out
496
1078
 
497
1079
  def _collect_security_ohlc_hist_fields_for_call(self, item: dict) -> set[str]:
@@ -516,10 +1098,43 @@ class SecurityEmitter:
516
1098
  return SECURITY_BAR_FIELD_EXPRS.get(field, f"bar.{field}")
517
1099
 
518
1100
  @staticmethod
519
- def _security_tuple_result_default(cpp_type: str, tuple_size: int) -> str:
520
- 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
+ )
521
1115
  return f"{cpp_type}{{{vals}}}"
522
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
+
523
1138
  def _collect_security_ta_hist_indices(self, node) -> set[int]:
524
1139
  """Which security TA call-site indices need HTF history (subscript index >= 1).
525
1140
 
@@ -529,50 +1144,172 @@ class SecurityEmitter:
529
1144
  per-site ``Series`` filled (gated on ``is_complete``) in
530
1145
  ``_eval_security_N``. Mirrors ``_collect_security_ohlc_hist_fields`` for
531
1146
  OHLC offsets. Offset 0 reuses the current committed value (``_secval_*``)
532
- 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."""
533
1153
  out: set[int] = set()
534
1154
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
1155
+ resolving: set[str] = set()
535
1156
 
536
- def resolve_ta_site(obj, resolving: set[str] | None = None):
1157
+ def resolve_ta_site(obj, bindings, seen: set[str] | None = None):
537
1158
  """_get_ta_site only matches the literal ta.* FuncCall node by
538
- identity; fall back through global_expr_map for an indirect
539
- binding (``v = ta.ema(close, 55)`` then ``...v[1]...``), mirroring
540
- 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]...``)."""
541
1161
  site = self._get_ta_site(obj)
542
1162
  if site is not None:
543
1163
  return site
544
1164
  if isinstance(obj, Identifier):
545
- if resolving is None:
546
- resolving = set()
547
- if obj.name in global_expr_map and obj.name not in resolving:
548
- resolving.add(obj.name)
549
- 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
+ )
550
1189
  return None
551
1190
 
552
- def walk(n):
553
- if n is None:
1191
+ def walk(n, bindings) -> None:
1192
+ if n is None or isinstance(n, str):
554
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
+
555
1227
  if isinstance(n, Subscript):
556
- site = resolve_ta_site(n.object)
1228
+ site = resolve_ta_site(n.object, bindings)
557
1229
  if site is not None:
558
- idx_lit = self._resolve_security_index_literal(n.index)
559
- 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:
560
1232
  site_idx = self._ta_index_by_site_id.get(id(site))
561
1233
  if site_idx is not None:
562
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
+
563
1300
  if isinstance(n, (list, tuple)):
564
1301
  for x in n:
565
- walk(x)
1302
+ walk(x, bindings)
566
1303
  return
567
1304
  for _k, v in getattr(n, "__dict__", {}).items():
568
1305
  if isinstance(v, ASTNode):
569
- walk(v)
1306
+ walk(v, bindings)
570
1307
  elif isinstance(v, (list, tuple)):
571
1308
  for x in v:
572
1309
  if isinstance(x, ASTNode):
573
- walk(x)
1310
+ walk(x, bindings)
574
1311
 
575
- walk(node)
1312
+ walk(node, ())
576
1313
  return out
577
1314
 
578
1315
  def _security_ta_hist_series_cpp(self, member_name: str) -> str:
@@ -773,6 +1510,49 @@ class SecurityEmitter:
773
1510
  security_mutable_names: set[str],
774
1511
  lines: list[str],
775
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,
776
1556
  ) -> str:
777
1557
  if plan["mode"] != "linear":
778
1558
  self._codegen_error(
@@ -790,8 +1570,18 @@ class SecurityEmitter:
790
1570
  pad = " " * indent
791
1571
  runtime_stack_local = plan["binding_stack"] + (active_bindings,)
792
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
+
793
1582
  if isinstance(stmt, VarDecl):
794
- 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:
795
1585
  binding = active_bindings.get(stmt.name)
796
1586
  if binding is None:
797
1587
  binding = self._security_series_binding(
@@ -810,13 +1600,53 @@ class SecurityEmitter:
810
1600
  lines,
811
1601
  )
812
1602
  active_bindings[stmt.name] = binding
813
- lines.append(f'{pad}if (_security_helper_series_["{series_name}"].size() == 0) {{')
814
- lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});')
815
- lines.append(f'{pad}}} else if (security_series_slot_is_new({sec_id})) {{')
816
- lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});')
817
- lines.append(f'{pad}}} else {{')
818
- lines.append(f'{pad} _security_helper_series_["{series_name}"].update({expr_cpp});')
819
- 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()
820
1650
  return
821
1651
 
822
1652
  local_name = active_bindings.get(stmt.name)
@@ -851,6 +1681,7 @@ class SecurityEmitter:
851
1681
  lines,
852
1682
  )
853
1683
  lines.append(f"{pad}{local_name} = {expr_cpp};")
1684
+ activate_decl()
854
1685
  return
855
1686
 
856
1687
  if isinstance(stmt, Assignment):
@@ -907,13 +1738,69 @@ class SecurityEmitter:
907
1738
  )
908
1739
  lines.append(f"{pad}if ({cond_cpp}) {{")
909
1740
  body_bindings = dict(active_bindings)
910
- for child in stmt.body:
911
- 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
912
1771
  if stmt.else_body:
913
1772
  lines.append(f"{pad}}} else {{")
914
1773
  else_bindings = dict(active_bindings)
915
- for child in stmt.else_body:
916
- 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
917
1804
  lines.append(f"{pad}}}")
918
1805
  return
919
1806
 
@@ -949,7 +1836,7 @@ class SecurityEmitter:
949
1836
 
950
1837
  sig_frames = []
951
1838
  for idx, frame in enumerate(helper_binding_stack):
952
- if idx == 0:
1839
+ if idx == 0 or isinstance(frame, _SecurityHelperArgumentFrame):
953
1840
  sig_frames.append(
954
1841
  tuple((name, _sig_value(node)) for name, node in sorted(frame.items()))
955
1842
  )
@@ -1002,8 +1889,14 @@ class SecurityEmitter:
1002
1889
  "request.security helper calls must bind every parameter explicitly",
1003
1890
  )
1004
1891
 
1005
- new_frame = {param_name: bound_args[idx] for idx, param_name in enumerate(params)}
1006
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
+ )
1007
1900
  return fi, base_stack + (new_frame,)
1008
1901
 
1009
1902
  def _security_helper_call_plan(
@@ -1075,11 +1968,11 @@ class SecurityEmitter:
1075
1968
  "request.security multi-statement helpers may only use local declarations, assignments, and if-branches before the final expression",
1076
1969
  )
1077
1970
  if isinstance(stmt, VarDecl):
1078
- if stmt.is_var or stmt.is_varip:
1971
+ if stmt.is_varip:
1079
1972
  self._codegen_error(
1080
1973
  node,
1081
- "request.security does not support multi-statement helpers with helper-local var state",
1082
- 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.",
1083
1976
  )
1084
1977
  if isinstance(stmt, Assignment):
1085
1978
  target_name = self._get_target_name(stmt.target)
@@ -1099,6 +1992,92 @@ class SecurityEmitter:
1099
1992
  "local_series_names": local_series_names,
1100
1993
  }
1101
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
+
1102
2081
  def _security_next_inline_name(self, sec_id: int, func_name: str, base_name: str) -> str:
1103
2082
  self._security_inline_counter += 1
1104
2083
  return (
@@ -1119,13 +2098,18 @@ class SecurityEmitter:
1119
2098
  resolving = set()
1120
2099
 
1121
2100
  if isinstance(expr_node, Identifier):
1122
- bound = self._security_lookup_helper_binding(expr_node.name, helper_binding_stack)
1123
- 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
1124
2108
  return self._expr_depends_on_security_mutables(
1125
2109
  bound,
1126
2110
  security_mutable_names,
1127
2111
  resolving,
1128
- helper_binding_stack,
2112
+ bound_stack,
1129
2113
  )
1130
2114
  if expr_node.name in security_mutable_names:
1131
2115
  return True
@@ -1247,16 +2231,180 @@ class SecurityEmitter:
1247
2231
  if idx < len(all_args) and all_args[idx] is not None
1248
2232
  ]
1249
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
+
1250
2386
  def _security_ta_ctor_depends_on_mutables(
1251
2387
  self,
1252
2388
  site: TACallSite,
1253
2389
  security_mutable_names: set[str],
1254
2390
  helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None,
1255
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
+ )
1256
2404
  return any(
1257
2405
  self._expr_depends_on_security_mutables(
1258
2406
  arg,
1259
- security_mutable_names,
2407
+ ctor_mutable_names,
1260
2408
  helper_binding_stack=helper_binding_stack,
1261
2409
  )
1262
2410
  for arg in self._security_ta_ctor_arg_nodes(site)
@@ -1279,8 +2427,13 @@ class SecurityEmitter:
1279
2427
  resolving = set()
1280
2428
 
1281
2429
  if isinstance(expr_node, Identifier):
1282
- bound = self._security_lookup_helper_binding(expr_node.name, helper_binding_stack)
1283
- 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
1284
2437
  if isinstance(bound, str):
1285
2438
  return collected
1286
2439
  # Guard against a cyclic helper binding: an identifier bound to
@@ -1296,7 +2449,7 @@ class SecurityEmitter:
1296
2449
  self._collect_security_ta_binding_stacks(
1297
2450
  bound,
1298
2451
  resolving,
1299
- helper_binding_stack,
2452
+ bound_stack,
1300
2453
  collected,
1301
2454
  inline_ta_indices,
1302
2455
  inline_helper,
@@ -1320,12 +2473,16 @@ class SecurityEmitter:
1320
2473
  return collected
1321
2474
 
1322
2475
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
1323
- 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
+ ):
1324
2481
  resolving.add(expr_node.name)
1325
2482
  self._collect_security_ta_binding_stacks(
1326
2483
  global_expr_map[expr_node.name],
1327
2484
  resolving,
1328
- helper_binding_stack,
2485
+ (),
1329
2486
  collected,
1330
2487
  inline_ta_indices,
1331
2488
  inline_helper,
@@ -1944,11 +3101,17 @@ class SecurityEmitter:
1944
3101
  "bb": "ta::BBResult",
1945
3102
  "kc": "ta::KCResult",
1946
3103
  "vwap_bands": "ta::VWAPBandsResult",
1947
- }.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
+ )
1948
3111
  lines.append(f" case {sec_id}:")
1949
3112
  lines.append(
1950
3113
  f" _req_sec_{sec_id} = "
1951
- f"{self._security_tuple_result_default(ctype, tuple_size)};"
3114
+ f"{self._security_tuple_result_default(ctype, tuple_size, item.get('tuple_element_types', ()))};"
1952
3115
  )
1953
3116
  for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
1954
3117
  lines.append(
@@ -2003,8 +3166,13 @@ class SecurityEmitter:
2003
3166
  helper_binding_stack = ()
2004
3167
 
2005
3168
  if isinstance(expr_node, Identifier):
2006
- bound = self._security_lookup_helper_binding(expr_node.name, helper_binding_stack)
2007
- 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
2008
3176
  if isinstance(bound, str):
2009
3177
  series_name = self._security_series_binding_target(bound)
2010
3178
  if series_name is not None:
@@ -2017,7 +3185,7 @@ class SecurityEmitter:
2017
3185
  ta_results,
2018
3186
  resolving,
2019
3187
  security_mutable_names,
2020
- helper_binding_stack,
3188
+ bound_stack,
2021
3189
  emitted_lines,
2022
3190
  )
2023
3191
  bar_fields = {
@@ -2037,7 +3205,11 @@ class SecurityEmitter:
2037
3205
  return state_name
2038
3206
 
2039
3207
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
2040
- 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
+ ):
2041
3213
  resolving.add(expr_node.name)
2042
3214
  resolved = self._build_security_expr(
2043
3215
  sec_id,
@@ -2046,7 +3218,7 @@ class SecurityEmitter:
2046
3218
  ta_results,
2047
3219
  resolving,
2048
3220
  security_mutable_names,
2049
- helper_binding_stack,
3221
+ (),
2050
3222
  emitted_lines,
2051
3223
  )
2052
3224
  resolving.remove(expr_node.name)
@@ -2062,38 +3234,60 @@ class SecurityEmitter:
2062
3234
  return resolved
2063
3235
 
2064
3236
  if isinstance(expr_node, Subscript):
2065
- index_cpp = self._build_security_expr(
2066
- sec_id,
2067
- expr_node.index,
2068
- ta_range,
2069
- ta_results,
2070
- resolving,
2071
- security_mutable_names,
2072
- helper_binding_stack,
2073
- emitted_lines,
2074
- )
2075
3237
  if isinstance(expr_node.object, Identifier):
2076
- bound = self._security_lookup_helper_binding(expr_node.object.name, helper_binding_stack)
2077
- 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
2078
3245
  if isinstance(bound, str):
2079
3246
  series_name = self._security_series_binding_target(bound)
2080
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
+ )
2081
3258
  return f'_security_helper_series_["{series_name}"][{index_cpp}]'
2082
3259
  return bound
2083
- obj_cpp = self._build_security_expr(
2084
- 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(
2085
3271
  bound,
3272
+ local_index,
3273
+ expr_node,
3274
+ )
3275
+ return self._build_security_expr(
3276
+ sec_id,
3277
+ composed,
2086
3278
  ta_range,
2087
3279
  ta_results,
2088
3280
  resolving,
2089
3281
  security_mutable_names,
2090
- helper_binding_stack,
3282
+ bound_stack,
2091
3283
  emitted_lines,
2092
3284
  )
2093
- return f"{obj_cpp}[{index_cpp}]"
2094
3285
  if expr_node.object.name in SECURITY_BAR_FIELDS:
2095
3286
  field = expr_node.object.name
2096
- 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
+ )
2097
3291
  if idx_lit is not None:
2098
3292
  if idx_lit == 0:
2099
3293
  return self._security_bar_field_expr(field)
@@ -2113,6 +3307,16 @@ class SecurityEmitter:
2113
3307
  hist = self._security_ohlc_hist_series_cpp(sec_id, field)
2114
3308
  cpp_t = self._security_bar_hist_type(field)
2115
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
+ )
2116
3320
  return (
2117
3321
  f"([&]() -> {cpp_t} {{ "
2118
3322
  f"int _hidx = (int)({index_cpp}); "
@@ -2132,8 +3336,11 @@ class SecurityEmitter:
2132
3336
  # helper binding, whichever the resolved expression turns out
2133
3337
  # to be) instead of duplicating that dispatch here.
2134
3338
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
2135
- if (expr_node.object.name in global_expr_map
2136
- 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
+ ):
2137
3344
  resolving.add(expr_node.object.name)
2138
3345
  resolved = self._build_security_expr(
2139
3346
  sec_id,
@@ -2154,6 +3361,16 @@ class SecurityEmitter:
2154
3361
  meta = self._security_expr_hist_by_node.get((sec_id, id(expr_node)))
2155
3362
  hist = meta["name"] if meta else f"_sec{sec_id}_expr_hist_missing"
2156
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
+ )
2157
3374
  inner = self._build_security_expr(
2158
3375
  sec_id,
2159
3376
  expr_node.object,
@@ -2185,14 +3402,72 @@ class SecurityEmitter:
2185
3402
  # tick), so without a magnifier it advanced every chart bar and
2186
3403
  # produced the chart-tf TA instead of the confirmed HTF value.
2187
3404
  idx = self._ta_index_by_site_id.get(id(ta_site))
2188
- 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)
2189
3415
  idx_lit = self._resolve_security_index_literal(
2190
3416
  expr_node.index, helper_binding_stack
2191
3417
  )
2192
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:
2193
3468
  self._codegen_error(
2194
3469
  expr_node,
2195
- "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",
2196
3471
  )
2197
3472
  if idx_lit == 0:
2198
3473
  # Current completed-HTF-bar value: reuse the bare-TA emission.
@@ -2203,9 +3478,15 @@ class SecurityEmitter:
2203
3478
  ta_results,
2204
3479
  resolving,
2205
3480
  security_mutable_names,
2206
- helper_binding_stack,
3481
+ ta_binding_stack,
2207
3482
  emitted_lines,
2208
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
+ )
2209
3490
  member_name = self._security_ta_variant_names.get(
2210
3491
  (sec_id, idx, sig),
2211
3492
  f"_sec{sec_id}_{ta_site.member_name}",
@@ -2226,7 +3507,10 @@ class SecurityEmitter:
2226
3507
  op = cpp_ops.get(expr_node.op, expr_node.op)
2227
3508
  if expr_node.op == "%":
2228
3509
  return f"std::fmod((double)({left}), (double)({right}))"
2229
- return f"({left} {op} {right})"
3510
+ # KI-71: honour Pine's falsy-on-na relational rule inside
3511
+ # request.security expressions too (this builder is a second
3512
+ # relational emission site independent of _visit_binop).
3513
+ return self._lower_relational(op, expr_node.left, expr_node.right, left, right)
2230
3514
 
2231
3515
  if isinstance(expr_node, UnaryOp):
2232
3516
  operand = self._build_security_expr(
@@ -2248,6 +3532,28 @@ class SecurityEmitter:
2248
3532
  )
2249
3533
  return f"(({cond}) ? ({tv}) : ({fv}))"
2250
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
+
2251
3557
  if isinstance(expr_node, FuncCall) and isinstance(expr_node.callee, Identifier):
2252
3558
  func_name = expr_node.callee.name
2253
3559
  if func_name in self._func_names: