@pineforge/codegen-pyodide 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,16 +59,21 @@ tables and types come from ``codegen/tables.py``, ``..ast_nodes``,
59
59
  from __future__ import annotations
60
60
 
61
61
  from ..ast_nodes import (
62
- ASTNode, Assignment, BinOp, BreakStmt, ContinueStmt, ExprStmt, ForStmt,
63
- ForInStmt, FuncCall, FuncDef, Identifier, IfStmt, NumberLiteral, StringLiteral,
64
- Subscript, SwitchStmt, Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl,
65
- WhileStmt,
62
+ ASTNode, Assignment, BinOp, BoolLiteral, BreakStmt, ContinueStmt, ExprStmt,
63
+ ForStmt, ForInStmt, FuncCall, FuncDef, Identifier, IfStmt, MemberAccess,
64
+ NumberLiteral, StringLiteral, Subscript, SwitchStmt, Ternary, TupleAssign,
65
+ TupleLiteral, UnaryOp, VarDecl, WhileStmt,
66
66
  )
67
67
  from ..analyzer import (
68
68
  FuncInfo, TACallSite, TA_MULTI_CTOR, TA_NO_CTOR, TA_PERIOD_ARG,
69
69
  )
70
+ from .. import signatures as sigs
70
71
  from ..symbols import PineType
71
- from .tables import PINE_TYPE_TO_CPP, SECURITY_OHLC_BAR_FIELDS
72
+ from .tables import (
73
+ MATH_FUNC_MAP, PINE_TYPE_TO_CPP, SECURITY_BAR_FIELDS,
74
+ SECURITY_BAR_FIELD_EXPRS, SECURITY_BAR_FIELD_TYPES, _math_minmax_na_expr,
75
+ _merge_kwargs,
76
+ )
72
77
 
73
78
 
74
79
  class SecurityEmitter:
@@ -100,6 +105,13 @@ class SecurityEmitter:
100
105
  return self._known_vars[name], None
101
106
  if name in self._input_backed_vars and name in self._input_var_to_call:
102
107
  return None, self._visit_expr(self._input_var_to_call[name])
108
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
109
+ if name in global_expr_map:
110
+ expanded = self._security_tf_runtime_expr(
111
+ global_expr_map[name], resolving={name}
112
+ )
113
+ if expanded is not None:
114
+ return None, expanded
103
115
  # class-scope resolvable (global / input member)?
104
116
  if self._ident_is_resolvable(name):
105
117
  try:
@@ -115,10 +127,105 @@ class SecurityEmitter:
115
127
  return None, "input_tf_"
116
128
  # any other expression — visit if it resolves at class scope
117
129
  try:
118
- return None, self._visit_expr(tf_node)
130
+ expanded = self._security_tf_runtime_expr(tf_node)
131
+ return None, expanded if expanded is not None else self._visit_expr(tf_node)
119
132
  except Exception:
120
133
  return None, "input_tf_"
121
134
 
135
+ def _security_tf_runtime_expr(self, node, resolving: set[str] | None = None) -> str | None:
136
+ """Render a request.security timeframe expression for registration time.
137
+
138
+ Security evaluators are registered before ``on_bar()`` initializes global
139
+ variables, so input-backed aliases such as ``tf = useChart ? timeframe.period
140
+ : inputTf`` must be expanded to their source expression with direct
141
+ ``get_input_*`` reads. Emitting the member name would register with its
142
+ default-constructed value (usually an empty string).
143
+
144
+ Delegates to :meth:`_substitute_tf_input_reads`, which rewrites the
145
+ input-derived *leaves* of the expression tree — including leaves buried
146
+ inside a ternary CONDITION or any BinOp / UnaryOp / FuncCall — and then
147
+ renders the substituted tree through the normal expression visitor.
148
+ """
149
+ if node is None:
150
+ return None
151
+ substituted = self._substitute_tf_input_reads(node, resolving or set())
152
+ return self._visit_expr(substituted)
153
+
154
+ def _substitute_tf_input_reads(self, node, resolving: set[str]):
155
+ """Return ``node`` with input-derived leaves rewritten to expressions
156
+ that are valid at security-registration time (before ``on_bar()``
157
+ assigns members from their inputs):
158
+
159
+ * an input-backed var -> its ``input.*()`` source call (``get_input_*``);
160
+ * a ``timeframe.period`` alias var -> ``timeframe.period`` (``script_tf_``);
161
+ * a known compile-time string var -> that string literal;
162
+ * a global alias -> its defining expression, expanded recursively.
163
+
164
+ The walk descends through Ternary / BinOp / UnaryOp / FuncCall /
165
+ Subscript, so an input-backed identifier nested inside e.g. a ternary
166
+ condition (``mode == "15" ? "240" : "60"``) resolves to its input read
167
+ instead of the uninitialised member. A subtree containing no
168
+ input-derived leaf is returned unchanged (same object) so unaffected
169
+ timeframe expressions render byte-identically to before the fix.
170
+ MemberAccess and literals are left verbatim: ``timeframe.period`` is
171
+ already lowered to ``script_tf_`` by the expression visitor.
172
+ """
173
+ if not isinstance(node, ASTNode):
174
+ return node
175
+ if isinstance(node, Identifier):
176
+ name = node.name
177
+ 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:
180
+ return self._input_var_to_call[name]
181
+ if (name in self._known_vars and name not in self._input_backed_vars
182
+ and isinstance(self._known_vars[name], str)):
183
+ return StringLiteral(value=self._known_vars[name])
184
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
185
+ if name in global_expr_map and name not in resolving:
186
+ return self._substitute_tf_input_reads(
187
+ global_expr_map[name], resolving | {name})
188
+ return node
189
+ if isinstance(node, Ternary):
190
+ cond = self._substitute_tf_input_reads(node.condition, resolving)
191
+ tv = self._substitute_tf_input_reads(node.true_val, resolving)
192
+ fv = self._substitute_tf_input_reads(node.false_val, resolving)
193
+ if cond is node.condition and tv is node.true_val and fv is node.false_val:
194
+ return node
195
+ return Ternary(condition=cond, true_val=tv, false_val=fv)
196
+ if isinstance(node, BinOp):
197
+ left = self._substitute_tf_input_reads(node.left, resolving)
198
+ right = self._substitute_tf_input_reads(node.right, resolving)
199
+ if left is node.left and right is node.right:
200
+ return node
201
+ return BinOp(left=left, op=node.op, right=right)
202
+ if isinstance(node, UnaryOp):
203
+ operand = self._substitute_tf_input_reads(node.operand, resolving)
204
+ if operand is node.operand:
205
+ return node
206
+ return UnaryOp(op=node.op, operand=operand)
207
+ if isinstance(node, FuncCall):
208
+ new_args = [self._substitute_tf_input_reads(a, resolving) for a in node.args]
209
+ new_kwargs = {
210
+ k: (self._substitute_tf_input_reads(v, resolving)
211
+ if isinstance(v, ASTNode) else v)
212
+ for k, v in node.kwargs.items()
213
+ }
214
+ unchanged = (
215
+ all(a is b for a, b in zip(new_args, node.args))
216
+ and all(new_kwargs[k] is node.kwargs[k] for k in node.kwargs)
217
+ )
218
+ if unchanged:
219
+ return node
220
+ return FuncCall(callee=node.callee, args=new_args, kwargs=new_kwargs)
221
+ if isinstance(node, Subscript):
222
+ obj = self._substitute_tf_input_reads(node.object, resolving)
223
+ idx = self._substitute_tf_input_reads(node.index, resolving)
224
+ if obj is node.object and idx is node.index:
225
+ return node
226
+ return Subscript(object=obj, index=idx)
227
+ return node
228
+
122
229
  def _resolve_param_tf_from_callsites(self, func_name: str, param_name: str):
123
230
  """For a ``request.security`` whose tf is function parameter ``param_name``
124
231
  of user function ``func_name``, return ``(tf_str, tf_expr)`` resolved from
@@ -276,7 +383,7 @@ class SecurityEmitter:
276
383
  return None
277
384
 
278
385
  def _literal_int_for_security_index(self, node) -> int | None:
279
- """Integer index for OHLC[ n ] inside request.security (must be literal)."""
386
+ """Integer index for bar-field[n] inside request.security (must be literal)."""
280
387
  if isinstance(node, NumberLiteral):
281
388
  v = node.value
282
389
  if isinstance(v, bool):
@@ -295,19 +402,131 @@ class SecurityEmitter:
295
402
  return None
296
403
  return None
297
404
 
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".
410
+ _SECURITY_CONST_BARSTATE = {
411
+ "isrealtime": False,
412
+ "islast": False,
413
+ "ishistory": True,
414
+ "islastconfirmedhistory": False,
415
+ }
416
+
417
+ def _fold_security_const_bool(
418
+ self,
419
+ node,
420
+ helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None,
421
+ resolving: set[str] | None = None,
422
+ ) -> bool | None:
423
+ """Fold a boolean expression to a compile-time constant, or None.
424
+
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."""
429
+ if resolving is None:
430
+ resolving = set()
431
+ if isinstance(node, BoolLiteral):
432
+ return bool(node.value)
433
+ if (
434
+ isinstance(node, MemberAccess)
435
+ and isinstance(node.object, Identifier)
436
+ and node.object.name == "barstate"
437
+ ):
438
+ return self._SECURITY_CONST_BARSTATE.get(node.member)
439
+ if isinstance(node, UnaryOp) and node.op == "not":
440
+ inner = self._fold_security_const_bool(
441
+ node.operand, helper_binding_stack, resolving
442
+ )
443
+ return None if inner is None else (not inner)
444
+ if isinstance(node, BinOp) and node.op in ("and", "or"):
445
+ left = self._fold_security_const_bool(
446
+ node.left, helper_binding_stack, resolving
447
+ )
448
+ right = self._fold_security_const_bool(
449
+ node.right, helper_binding_stack, resolving
450
+ )
451
+ if left is None or right is None:
452
+ return None
453
+ return (left and right) if node.op == "and" else (left or right)
454
+ if isinstance(node, Identifier):
455
+ bound = self._security_lookup_helper_binding(node.name, helper_binding_stack)
456
+ if bound is not None:
457
+ return self._fold_security_const_bool(
458
+ bound, helper_binding_stack, resolving
459
+ )
460
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
461
+ if node.name in global_expr_map and node.name not in resolving:
462
+ resolving.add(node.name)
463
+ out = self._fold_security_const_bool(
464
+ global_expr_map[node.name], helper_binding_stack, resolving
465
+ )
466
+ resolving.remove(node.name)
467
+ return out
468
+ return None
469
+
470
+ def _resolve_security_index_literal(
471
+ self,
472
+ node,
473
+ helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None,
474
+ resolving: set[str] | None = None,
475
+ ) -> int | None:
476
+ """Resolve a request.security TA/bar-field history index to a literal int.
477
+
478
+ Extends ``_literal_int_for_security_index`` by also resolving the index
479
+ 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."""
485
+ direct = self._literal_int_for_security_index(node)
486
+ if direct is not None:
487
+ return direct
488
+ if resolving is None:
489
+ resolving = set()
490
+ if isinstance(node, Identifier):
491
+ bound = self._security_lookup_helper_binding(node.name, helper_binding_stack)
492
+ if bound is not None:
493
+ return self._resolve_security_index_literal(
494
+ bound, helper_binding_stack, resolving
495
+ )
496
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
497
+ if node.name in global_expr_map and node.name not in resolving:
498
+ resolving.add(node.name)
499
+ out = self._resolve_security_index_literal(
500
+ global_expr_map[node.name], helper_binding_stack, resolving
501
+ )
502
+ resolving.remove(node.name)
503
+ return out
504
+ return None
505
+ if isinstance(node, Ternary):
506
+ cond = self._fold_security_const_bool(node.condition, helper_binding_stack)
507
+ if cond is None:
508
+ return None
509
+ chosen = node.true_val if cond else node.false_val
510
+ return self._resolve_security_index_literal(
511
+ chosen, helper_binding_stack, resolving
512
+ )
513
+ return None
514
+
298
515
  def _collect_security_ohlc_hist_fields(self, node) -> set[str]:
299
- """Which OHLC fields need HTF history (subscript index >= 1) for this expression."""
516
+ """Which security bar fields need HTF history (subscript index >= 1)."""
300
517
  out: set[str] = set()
301
518
 
302
519
  def walk(n):
303
520
  if n is None:
304
521
  return
305
522
  if isinstance(n, Subscript) and isinstance(n.object, Identifier):
306
- if n.object.name in SECURITY_OHLC_BAR_FIELDS:
523
+ if n.object.name in SECURITY_BAR_FIELDS:
307
524
  idx = self._literal_int_for_security_index(n.index)
308
- # high[0] uses current HTF `bar`; high[k>=1] reads prior completed HTF
309
- # bars from Series history (filled before push in _eval_security_*).
310
- if idx is not None and idx >= 1:
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.
529
+ if idx is None or idx >= 1:
311
530
  out.add(n.object.name)
312
531
  if isinstance(n, (list, tuple)):
313
532
  for x in n:
@@ -324,9 +543,32 @@ class SecurityEmitter:
324
543
  walk(node)
325
544
  return out
326
545
 
546
+ def _collect_security_ohlc_hist_fields_for_call(self, item: dict) -> set[str]:
547
+ """Collect HTF OHLC history needed by a security expression and any
548
+ mutable-global rebinds replayed inside that security evaluator."""
549
+ fields = self._collect_security_ohlc_hist_fields(item.get("expr_node"))
550
+ for name in item.get("mutable_globals", []) or []:
551
+ info = self._global_mutable_infos.get(name)
552
+ if info is None:
553
+ continue
554
+ for stmt in getattr(info, "source_stmts", []) or []:
555
+ fields |= self._collect_security_ohlc_hist_fields(stmt)
556
+ return fields
557
+
327
558
  def _security_ohlc_hist_series_cpp(self, sec_id: int, field: str) -> str:
328
559
  return f"_sec{sec_id}_hist_{field}"
329
560
 
561
+ def _security_bar_hist_type(self, field: str) -> str:
562
+ return SECURITY_BAR_FIELD_TYPES.get(field, "double")
563
+
564
+ def _security_bar_field_expr(self, field: str) -> str:
565
+ return SECURITY_BAR_FIELD_EXPRS.get(field, f"bar.{field}")
566
+
567
+ @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)))
570
+ return f"{cpp_type}{{{vals}}}"
571
+
330
572
  def _collect_security_ta_hist_indices(self, node) -> set[int]:
331
573
  """Which security TA call-site indices need HTF history (subscript index >= 1).
332
574
 
@@ -362,7 +604,7 @@ class SecurityEmitter:
362
604
  if isinstance(n, Subscript):
363
605
  site = resolve_ta_site(n.object)
364
606
  if site is not None:
365
- idx_lit = self._literal_int_for_security_index(n.index)
607
+ idx_lit = self._resolve_security_index_literal(n.index)
366
608
  if idx_lit is not None and idx_lit >= 1:
367
609
  site_idx = self._ta_index_by_site_id.get(id(site))
368
610
  if site_idx is not None:
@@ -396,6 +638,172 @@ class SecurityEmitter:
396
638
  names.append(self._security_ta_hist_series_cpp(variant["member_name"]))
397
639
  return names
398
640
 
641
+ def _collect_security_expr_hist_subscripts(
642
+ self, node, resolving: set[str] | None = None
643
+ ) -> list[Subscript]:
644
+ """Subscripted helper-call results needing security-context history."""
645
+ if node is None:
646
+ return []
647
+ if resolving is None:
648
+ resolving = set()
649
+
650
+ out: list[Subscript] = []
651
+ seen: set[int] = set()
652
+
653
+ def add(n: Subscript) -> None:
654
+ key = id(n)
655
+ if key not in seen:
656
+ seen.add(key)
657
+ out.append(n)
658
+
659
+ def walk(n) -> None:
660
+ if n is None:
661
+ return
662
+ if isinstance(n, Identifier):
663
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
664
+ if n.name in global_expr_map and n.name not in resolving:
665
+ resolving.add(n.name)
666
+ walk(global_expr_map[n.name])
667
+ resolving.remove(n.name)
668
+ return
669
+ if (
670
+ isinstance(n, Subscript)
671
+ and isinstance(n.object, FuncCall)
672
+ and self._get_ta_site(n.object) is None
673
+ ):
674
+ add(n)
675
+ if isinstance(n, (list, tuple)):
676
+ for x in n:
677
+ walk(x)
678
+ return
679
+ for _k, v in getattr(n, "__dict__", {}).items():
680
+ if isinstance(v, ASTNode):
681
+ walk(v)
682
+ elif isinstance(v, (list, tuple)):
683
+ for x in v:
684
+ if isinstance(x, ASTNode):
685
+ walk(x)
686
+
687
+ walk(node)
688
+ return out
689
+
690
+ def _security_expr_hist_series_names(self, sec_id: int) -> list[str]:
691
+ names = []
692
+ for (sid, _node_id), meta in sorted(self._security_expr_hist_by_node.items()):
693
+ if sid == sec_id:
694
+ names.append(meta["name"])
695
+ return names
696
+
697
+ def _emit_security_expr_hist_members(
698
+ self, sec_id: int, expr_node, lines: list[str], mbb_suffix: str
699
+ ) -> None:
700
+ for idx, node in enumerate(self._collect_security_expr_hist_subscripts(expr_node)):
701
+ cpp_t = self._infer_type(node.object)
702
+ if cpp_t not in ("double", "int", "bool"):
703
+ cpp_t = "double"
704
+ name = f"_sec{sec_id}_expr_hist_{idx}"
705
+ self._security_expr_hist_by_node[(sec_id, id(node))] = {
706
+ "name": name,
707
+ "type": cpp_t,
708
+ }
709
+ lines.append(f" Series<{cpp_t}> {name}{mbb_suffix};")
710
+
711
+ def _build_security_math_call(
712
+ self,
713
+ sec_id: int,
714
+ func_name: str,
715
+ node: FuncCall,
716
+ ta_range,
717
+ ta_results: dict,
718
+ resolving: set[str],
719
+ security_mutable_names: set[str],
720
+ helper_binding_stack: tuple[dict[str, ASTNode], ...],
721
+ emitted_lines: list[str] | None,
722
+ ) -> str:
723
+ visit = lambda arg: self._build_security_expr(
724
+ sec_id,
725
+ arg,
726
+ ta_range,
727
+ ta_results,
728
+ resolving,
729
+ security_mutable_names,
730
+ helper_binding_stack,
731
+ emitted_lines,
732
+ )
733
+ args = _merge_kwargs(
734
+ node.args,
735
+ node.kwargs,
736
+ sigs.get_param_names("math", func_name),
737
+ visit,
738
+ )
739
+ if func_name == "round" and len(args) == 2:
740
+ return f"(std::round({args[0]} * std::pow(10.0, {args[1]})) / std::pow(10.0, {args[1]}))"
741
+ if func_name == "round_to_mintick":
742
+ x = args[0] if args else "0.0"
743
+ return f"round_to_mintick({x})"
744
+ if func_name == "todegrees":
745
+ x = args[0] if args else "0.0"
746
+ return f"({x} * 180.0 / M_PI)"
747
+ if func_name == "toradians":
748
+ x = args[0] if args else "0.0"
749
+ return f"({x} * M_PI / 180.0)"
750
+ if func_name == "random":
751
+ lo = args[0] if len(args) > 0 else "0.0"
752
+ hi = args[1] if len(args) > 1 else "1.0"
753
+ seed = args[2] if len(args) > 2 else "0"
754
+ call_site = self._random_call_counter
755
+ self._random_call_counter += 1
756
+ return f"pine_random({lo}, {call_site}u, {hi}, (uint32_t)({seed}), bar_index_)"
757
+ if func_name == "avg" and len(args) > 2:
758
+ sum_expr = " + ".join(f"(double)({a})" for a in args)
759
+ return f"(({sum_expr}) / {len(args)}.0)"
760
+ if func_name in ("min", "max"):
761
+ return _math_minmax_na_expr(func_name, args)
762
+ if func_name in MATH_FUNC_MAP:
763
+ mapped = MATH_FUNC_MAP[func_name]
764
+ if "{0}" in mapped:
765
+ return mapped.format(*args)
766
+ return f"{mapped}({', '.join(args)})"
767
+ return f"0.0 /* unsupported: math.{func_name} */"
768
+
769
+ def _security_timeframe_expr(self, sec_id: int) -> str:
770
+ """C++ expression for the timeframe of a request.security evaluator."""
771
+ info = self._security_eval_info[sec_id]
772
+ if info.get("tf"):
773
+ return f'"{info["tf"]}"'
774
+ if info.get("tf_expr"):
775
+ return info["tf_expr"]
776
+ return "input_tf_"
777
+
778
+ def _build_security_timeframe_member(self, sec_id: int, member: str) -> str | None:
779
+ """Lower timeframe.* reads inside request.security to the requested TF."""
780
+ tf = self._security_timeframe_expr(sec_id)
781
+ if member == "period":
782
+ return tf
783
+ if member == "main_period":
784
+ return "main_period()"
785
+ if member == "multiplier":
786
+ return f"tf_multiplier({tf})"
787
+ if member == "isintraday":
788
+ return f"tf_is_intraday({tf})"
789
+ if member == "isminutes":
790
+ return f"(tf_is_intraday({tf}) && !tf_is_seconds({tf}))"
791
+ if member == "isdaily":
792
+ return f"tf_is_daily({tf})"
793
+ if member == "isweekly":
794
+ return f"tf_is_weekly({tf})"
795
+ if member == "ismonthly":
796
+ return f"tf_is_monthly({tf})"
797
+ if member == "isdwm":
798
+ return f"(tf_is_daily({tf}) || tf_is_weekly({tf}) || tf_is_monthly({tf}))"
799
+ if member == "isseconds":
800
+ return f"tf_is_seconds({tf})"
801
+ if member == "in_seconds":
802
+ return f"tf_to_seconds({tf})"
803
+ if member == "isticks":
804
+ return "false"
805
+ return None
806
+
399
807
  @staticmethod
400
808
  def _security_series_binding(series_name: str) -> str:
401
809
  return f"@series:{series_name}"
@@ -924,6 +1332,16 @@ class SecurityEmitter:
924
1332
  if bound is not None:
925
1333
  if isinstance(bound, str):
926
1334
  return collected
1335
+ # Guard against a cyclic helper binding: an identifier bound to
1336
+ # an expression that transitively references the same binding
1337
+ # (e.g. mutually-referential helper params) would recurse here
1338
+ # forever — the mutable/global/func paths below already carry a
1339
+ # `resolving` guard, this path did not. Keyed by the bound node
1340
+ # so acyclic bindings are unaffected (byte-identical emission).
1341
+ bind_key = f"bind:{id(bound)}"
1342
+ if bind_key in resolving:
1343
+ return collected
1344
+ resolving.add(bind_key)
927
1345
  self._collect_security_ta_binding_stacks(
928
1346
  bound,
929
1347
  resolving,
@@ -932,6 +1350,7 @@ class SecurityEmitter:
932
1350
  inline_ta_indices,
933
1351
  inline_helper,
934
1352
  )
1353
+ resolving.discard(bind_key)
935
1354
  return collected
936
1355
 
937
1356
  mutable_info = self._global_mutable_infos.get(expr_node.name)
@@ -1335,7 +1754,7 @@ class SecurityEmitter:
1335
1754
  lines.append(" if (is_complete) {")
1336
1755
  for field in fields:
1337
1756
  lines.append(
1338
- f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});"
1757
+ f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push({self._security_bar_field_expr(field)});"
1339
1758
  )
1340
1759
  lines.append(" }")
1341
1760
 
@@ -1535,6 +1954,8 @@ class SecurityEmitter:
1535
1954
  )
1536
1955
  for name in self._security_ta_hist_series_names(sec_id):
1537
1956
  lines.append(f" {name}.clear();")
1957
+ for name in self._security_expr_hist_series_names(sec_id):
1958
+ lines.append(f" {name}.clear();")
1538
1959
  lines.append(" break;")
1539
1960
  continue
1540
1961
  if returns_tuple and tuple_size and tuple_size > 0 and isinstance(expr_node, TupleLiteral):
@@ -1559,11 +1980,39 @@ class SecurityEmitter:
1559
1980
  )
1560
1981
  for name in self._security_ta_hist_series_names(sec_id):
1561
1982
  lines.append(f" {name}.clear();")
1983
+ for name in self._security_expr_hist_series_names(sec_id):
1984
+ lines.append(f" {name}.clear();")
1985
+ lines.append(" break;")
1986
+ elif returns_tuple and tuple_size and tuple_size > 0:
1987
+ site = self._get_ta_site(expr_node)
1988
+ ta_name = self._ta_name_from_site(site) if site is not None else ""
1989
+ ctype = {
1990
+ "macd": "ta::MACDResult",
1991
+ "supertrend": "ta::SupertrendResult",
1992
+ "dmi": "ta::DMIResult",
1993
+ "bb": "ta::BBResult",
1994
+ "kc": "ta::KCResult",
1995
+ "vwap_bands": "ta::VWAPBandsResult",
1996
+ }.get(ta_name, "std::tuple<double, double>")
1997
+ lines.append(f" case {sec_id}:")
1998
+ lines.append(
1999
+ f" _req_sec_{sec_id} = "
2000
+ f"{self._security_tuple_result_default(ctype, tuple_size)};"
2001
+ )
2002
+ for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
2003
+ lines.append(
2004
+ f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.clear();"
2005
+ )
2006
+ for name in self._security_ta_hist_series_names(sec_id):
2007
+ lines.append(f" {name}.clear();")
2008
+ for name in self._security_expr_hist_series_names(sec_id):
2009
+ lines.append(f" {name}.clear();")
1562
2010
  lines.append(" break;")
1563
2011
  else:
1564
2012
  hist = self._security_ohlc_hist_fields_by_sec.get(sec_id, ())
1565
2013
  ta_hist_names = self._security_ta_hist_series_names(sec_id)
1566
- if hist or ta_hist_names:
2014
+ expr_hist_names = self._security_expr_hist_series_names(sec_id)
2015
+ if hist or ta_hist_names or expr_hist_names:
1567
2016
  lines.append(f" case {sec_id}:")
1568
2017
  lines.append(f" _req_sec_{sec_id} = na<double>();")
1569
2018
  for field in sorted(hist):
@@ -1572,6 +2021,8 @@ class SecurityEmitter:
1572
2021
  )
1573
2022
  for name in ta_hist_names:
1574
2023
  lines.append(f" {name}.clear();")
2024
+ for name in expr_hist_names:
2025
+ lines.append(f" {name}.clear();")
1575
2026
  lines.append(" break;")
1576
2027
  else:
1577
2028
  lines.append(f" case {sec_id}: _req_sec_{sec_id} = na<double>(); break;")
@@ -1619,9 +2070,7 @@ class SecurityEmitter:
1619
2070
  emitted_lines,
1620
2071
  )
1621
2072
  bar_fields = {
1622
- "close": "bar.close", "high": "bar.high",
1623
- "low": "bar.low", "open": "bar.open",
1624
- "volume": "bar.volume",
2073
+ **SECURITY_BAR_FIELD_EXPRS,
1625
2074
  "hl2": "((bar.high + bar.low) / 2.0)",
1626
2075
  "hlc3": "((bar.high + bar.low + bar.close) / 3.0)",
1627
2076
  "ohlc4": "((bar.open + bar.high + bar.low + bar.close) / 4.0)",
@@ -1652,6 +2101,15 @@ class SecurityEmitter:
1652
2101
  resolving.remove(expr_node.name)
1653
2102
  return resolved
1654
2103
 
2104
+ if (
2105
+ isinstance(expr_node, MemberAccess)
2106
+ and isinstance(expr_node.object, Identifier)
2107
+ and expr_node.object.name == "timeframe"
2108
+ ):
2109
+ resolved = self._build_security_timeframe_member(sec_id, expr_node.member)
2110
+ if resolved is not None:
2111
+ return resolved
2112
+
1655
2113
  if isinstance(expr_node, Subscript):
1656
2114
  index_cpp = self._build_security_expr(
1657
2115
  sec_id,
@@ -1682,29 +2140,33 @@ class SecurityEmitter:
1682
2140
  emitted_lines,
1683
2141
  )
1684
2142
  return f"{obj_cpp}[{index_cpp}]"
1685
- if expr_node.object.name in SECURITY_OHLC_BAR_FIELDS:
2143
+ if expr_node.object.name in SECURITY_BAR_FIELDS:
2144
+ field = expr_node.object.name
1686
2145
  idx_lit = self._literal_int_for_security_index(expr_node.index)
1687
2146
  if idx_lit is not None:
1688
- bar_map = {
1689
- "open": "bar.open",
1690
- "high": "bar.high",
1691
- "low": "bar.low",
1692
- "close": "bar.close",
1693
- "volume": "bar.volume",
1694
- }
1695
2147
  if idx_lit == 0:
1696
- return bar_map[expr_node.object.name]
2148
+ return self._security_bar_field_expr(field)
1697
2149
  if idx_lit >= 1:
1698
2150
  # lookahead_off: we evaluate when an HTF bar completes; `bar` is that
1699
- # bar. On the HTF series, high[0]/close is the current (just-finished)
1700
- # bar; high[1] is one HTF bar back = hist[field][0] *before* we push
1701
- # `bar` (Series [0] = most recent prior push). high[k] -> hist[k-1].
1702
- field = expr_node.object.name
2151
+ # bar. On the HTF series, high[0]/time[0] is the current
2152
+ # (just-finished) bar; high[1]/time[1] is one HTF bar back
2153
+ # = hist[field][0] *before* we push `bar` (Series [0] =
2154
+ # most recent prior push). field[k] -> hist[k-1].
1703
2155
  hist = self._security_ohlc_hist_series_cpp(sec_id, field)
1704
2156
  return f"{hist}[{idx_lit - 1}]"
1705
- self._codegen_error(
1706
- expr_node,
1707
- "request.security() OHLC history index must be a literal integer (e.g. high[1])",
2157
+ if idx_lit is not None:
2158
+ self._codegen_error(
2159
+ expr_node,
2160
+ "request.security() bar-field history index must be non-negative",
2161
+ )
2162
+ hist = self._security_ohlc_hist_series_cpp(sec_id, field)
2163
+ cpp_t = self._security_bar_hist_type(field)
2164
+ current = self._security_bar_field_expr(field)
2165
+ return (
2166
+ f"([&]() -> {cpp_t} {{ "
2167
+ f"int _hidx = (int)({index_cpp}); "
2168
+ f"return (_hidx <= 0) ? {current} : {hist}[_hidx - 1]; "
2169
+ f"}}())"
1708
2170
  )
1709
2171
 
1710
2172
  # Indirect TA binding: ``v = ta.ema(close, 55)`` then
@@ -1734,6 +2196,31 @@ class SecurityEmitter:
1734
2196
  )
1735
2197
  resolving.remove(expr_node.object.name)
1736
2198
  return resolved
2199
+ if (
2200
+ isinstance(expr_node.object, FuncCall)
2201
+ and self._get_ta_site(expr_node.object) is None
2202
+ ):
2203
+ meta = self._security_expr_hist_by_node.get((sec_id, id(expr_node)))
2204
+ hist = meta["name"] if meta else f"_sec{sec_id}_expr_hist_missing"
2205
+ cpp_t = meta["type"] if meta else "double"
2206
+ inner = self._build_security_expr(
2207
+ sec_id,
2208
+ expr_node.object,
2209
+ ta_range,
2210
+ ta_results,
2211
+ resolving,
2212
+ security_mutable_names,
2213
+ helper_binding_stack,
2214
+ emitted_lines,
2215
+ )
2216
+ return (
2217
+ f"([&]() -> {cpp_t} {{ "
2218
+ f"{cpp_t} _hv = ({inner}); "
2219
+ f"int _hidx = (int)({index_cpp}); "
2220
+ f"{cpp_t} _out = (_hidx <= 0) ? _hv : {hist}[_hidx - 1]; "
2221
+ f"if (is_complete) {hist}.push(_hv); "
2222
+ f"return _out; }}())"
2223
+ )
1737
2224
  ta_site = self._get_ta_site(expr_node.object)
1738
2225
  if ta_site is not None:
1739
2226
  # ``ta.<fn>(...)[k]`` inside request.security(): the inner TA call
@@ -1748,7 +2235,9 @@ class SecurityEmitter:
1748
2235
  # produced the chart-tf TA instead of the confirmed HTF value.
1749
2236
  idx = self._ta_index_by_site_id.get(id(ta_site))
1750
2237
  sig = self._security_binding_stack_signature(helper_binding_stack)
1751
- idx_lit = self._literal_int_for_security_index(expr_node.index)
2238
+ idx_lit = self._resolve_security_index_literal(
2239
+ expr_node.index, helper_binding_stack
2240
+ )
1752
2241
  if idx_lit is None:
1753
2242
  self._codegen_error(
1754
2243
  expr_node,
@@ -1786,7 +2275,10 @@ class SecurityEmitter:
1786
2275
  op = cpp_ops.get(expr_node.op, expr_node.op)
1787
2276
  if expr_node.op == "%":
1788
2277
  return f"std::fmod((double)({left}), (double)({right}))"
1789
- return f"({left} {op} {right})"
2278
+ # KI-71: honour Pine's falsy-on-na relational rule inside
2279
+ # request.security expressions too (this builder is a second
2280
+ # relational emission site independent of _visit_binop).
2281
+ return self._lower_relational(op, expr_node.left, expr_node.right, left, right)
1790
2282
 
1791
2283
  if isinstance(expr_node, UnaryOp):
1792
2284
  operand = self._build_security_expr(
@@ -1850,6 +2342,37 @@ class SecurityEmitter:
1850
2342
  resolving.remove(call_key)
1851
2343
  return resolved
1852
2344
 
2345
+ if (
2346
+ isinstance(expr_node, FuncCall)
2347
+ and isinstance(expr_node.callee, MemberAccess)
2348
+ and isinstance(expr_node.callee.object, Identifier)
2349
+ and expr_node.callee.object.name == "math"
2350
+ ):
2351
+ # A rolling/stateful math reducer (e.g. math.sum -> math::Sum) is
2352
+ # precomputed into a committed _secval_* by the security TA
2353
+ # machinery, exactly like a ta.* call. Return that committed value
2354
+ # instead of falling through to _build_security_math_call, whose
2355
+ # inline lowering only covers scalar math and emits a broken
2356
+ # "unsupported: math.<f>" 0.0 for a reducer. Scalar math
2357
+ # (abs/round/min/max/...) is not a TA site, so this is a no-op.
2358
+ math_site = self._get_ta_site(expr_node)
2359
+ if math_site is not None:
2360
+ math_idx = self._ta_index_by_site_id.get(id(math_site))
2361
+ math_sig = self._security_binding_stack_signature(helper_binding_stack)
2362
+ if math_idx is not None and (math_idx, math_sig) in ta_results:
2363
+ return ta_results[(math_idx, math_sig)]
2364
+ return self._build_security_math_call(
2365
+ sec_id,
2366
+ expr_node.callee.member,
2367
+ expr_node,
2368
+ ta_range,
2369
+ ta_results,
2370
+ resolving,
2371
+ security_mutable_names,
2372
+ helper_binding_stack,
2373
+ emitted_lines,
2374
+ )
2375
+
1853
2376
  site = self._get_ta_site(expr_node)
1854
2377
  if site:
1855
2378
  idx = self._ta_index_by_site_id.get(id(site))