@pineforge/codegen-pyodide 0.10.1 → 0.10.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineforge/codegen-pyodide",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Gate-validated Pyodide payload for the PineScript v6 -> C++ transpiler: archive (run in Pyodide), unpacked source, introspected tables, and release metadata.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -5,6 +5,7 @@ results from AnalyzerContext instead of walking the AST to collect info.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
+ import re
8
9
 
9
10
  from dataclasses import dataclass
10
11
 
@@ -309,6 +310,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
309
310
  # Set of var/series member names that belong to user functions (need cloning)
310
311
  self._func_var_members_set: set[str] = set()
311
312
  self._precalc_loop_active: bool = False
313
+ # Top-level lazy-edge TA sites hoisted to every-bar evaluation for the
314
+ # statement currently being lowered: FuncCall id -> local name, and
315
+ # Subscript id -> ``_hist_call_*`` member (see ``ta.py``).
316
+ self._hoisted_ta_values: dict[int, str] = {}
317
+ self._hoisted_hist_reads: dict[int, str] = {}
312
318
  # Names of ``var`` members that live in a callable scope (not global).
313
319
  # Their exact declaration statements own initialization; they must not
314
320
  # be initialized by the constructor or the global on_bar preamble.
@@ -514,6 +520,15 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
514
520
  # the declaration-derived checkpoint inventory.
515
521
  self._inline_history_members: list[dict] = []
516
522
  self._inline_history_member_by_key: dict[tuple, str] = {}
523
+ # request.security/request.security_lower_tf inline their Pine helper
524
+ # expressions on the requested clock instead of calling the ordinary
525
+ # emitted UDF methods. The chart-clocked plain-UDF history factor must
526
+ # therefore exclude those source nodes and any emitted helper context
527
+ # reached only through them.
528
+ self._requested_context_inline_node_ids: set[int] = set()
529
+ self._requested_context_only_inline_contexts: set[
530
+ tuple[str, str | None]
531
+ ] = set()
517
532
  # Unique lambda-local names used when an array lowering references its
518
533
  # receiver more than once. The binding keeps temporary-producing or
519
534
  # side-effectful receivers single-evaluation (see TypeInferer).
@@ -925,6 +940,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
925
940
  self._register_global_aggregate_member_types()
926
941
  self._uses_map = self._detect_map_usage()
927
942
  self._uses_matrix = self._detect_matrix_usage()
943
+ self._uses_pine_time = self._detect_pine_time_usage()
944
+ self._uses_vwap_anchor = self._detect_vwap_usage()
928
945
  # Drawing-objects-as-data: gate all new emission (drawing.hpp include +
929
946
  # the per-type arenas) on this flag so non-drawing strategies stay
930
947
  # byte-identical. Caps come from the strategy() header max_*_count.
@@ -2101,6 +2118,45 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2101
2118
  return True
2102
2119
  return False
2103
2120
 
2121
+ def _detect_pine_time_usage(self) -> bool:
2122
+ """True if emitted C++ calls the engine's pine_time()/pine_time_close().
2123
+
2124
+ Gates the PF_PINE_TIME_SESSION_DAY_ARGS prelude shim (emit_top.py) so
2125
+ scripts that never call ``time(tf[, session[, tz]])`` /
2126
+ ``time_close(...)`` stay byte-identical. A ``var`` initializer is
2127
+ also text-scanned, mirroring ``_detect_matrix_usage``.
2128
+ """
2129
+ pat = re.compile(r"(?<![\w.])time(?:_close)?\s*\(")
2130
+ for _, _, init_str in self.ctx.var_members:
2131
+ if init_str and pat.search(str(init_str)):
2132
+ return True
2133
+ for node in self._walk_ast(self.ctx.ast):
2134
+ if isinstance(node, FuncCall) and (node.args or node.kwargs):
2135
+ fn, ns = self._resolve_callee(node.callee)
2136
+ if ns is None and fn in ("time", "time_close"):
2137
+ return True
2138
+ return False
2139
+
2140
+ def _detect_vwap_usage(self) -> bool:
2141
+ """True if emitted C++ calls ta::VWAP / ta::VWAPBands compute().
2142
+
2143
+ Gates the PF_VWAP_SESSION_ANCHOR_ARGS prelude shim (emit_top.py):
2144
+ ``ta.vwap(...)`` calls, the bare ``ta.vwap`` property read, and
2145
+ ``var`` initializers are covered.
2146
+ """
2147
+ for _, _, init_str in self.ctx.var_members:
2148
+ if init_str and "ta.vwap" in str(init_str):
2149
+ return True
2150
+ for node in self._walk_ast(self.ctx.ast):
2151
+ if isinstance(node, FuncCall):
2152
+ fn, ns = self._resolve_callee(node.callee)
2153
+ if ns == "ta" and fn == "vwap":
2154
+ return True
2155
+ elif isinstance(node, MemberAccess) and node.member == "vwap":
2156
+ if getattr(node.object, "name", None) == "ta":
2157
+ return True
2158
+ return False
2159
+
2104
2160
  def _detect_generic_matrix_usage(self) -> bool:
2105
2161
  """True if emitted C++ mentions a non-float matrix specialization."""
2106
2162
  float_spec = TypeSpec.primitive("float")
@@ -3173,7 +3229,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3173
3229
  """
3174
3230
  self._inline_history_members = []
3175
3231
  self._inline_history_member_by_key = {}
3176
- counters = {"hist_call": 0, "series_arg": 0}
3232
+ counters = {
3233
+ "hist_call": 0,
3234
+ "series_arg": 0,
3235
+ "udf_series_arg": 0,
3236
+ }
3177
3237
 
3178
3238
  def walk_nodes(value):
3179
3239
  """Yield AST nodes in stable field order, including tuple elements.
@@ -3345,6 +3405,113 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3345
3405
  lexical.update(self._func_collection_types.get(owner, {}))
3346
3406
  return lexical
3347
3407
 
3408
+ def plain_udf_info(call: FuncCall):
3409
+ if not isinstance(call.callee, Identifier):
3410
+ return None
3411
+ candidate = self._func_info_map.get(call.callee.name)
3412
+ if (
3413
+ candidate is None
3414
+ or candidate.node is None
3415
+ or getattr(candidate, "is_udt_method", False)
3416
+ ):
3417
+ return None
3418
+ return candidate
3419
+
3420
+ # Requested-context UDF expressions are lowered independently by
3421
+ # security.py. Classify their otherwise-emitted helper variants so
3422
+ # this chart-clocked factor neither allocates unused buffers for them
3423
+ # nor changes the legacy bodies that remain in generated C++.
3424
+ requested_node_ids: set[int] = set()
3425
+ for security_call in self._security_calls:
3426
+ expression = security_call.get("expr_node")
3427
+ if expression is None:
3428
+ continue
3429
+ requested_node_ids.update(
3430
+ id(child) for child in walk_nodes(expression)
3431
+ )
3432
+ self._requested_context_inline_node_ids = requested_node_ids
3433
+
3434
+ def emitted_context_for_call(
3435
+ fi,
3436
+ call: FuncCall,
3437
+ owner: str | None,
3438
+ owner_context: str | None,
3439
+ ) -> str | None:
3440
+ dispatch = self._instance_dispatch.get(
3441
+ (owner_context, id(call))
3442
+ )
3443
+ if dispatch is not None:
3444
+ return dispatch
3445
+ target_cs = target_cs_for_context(
3446
+ fi, call, owner, owner_context
3447
+ )
3448
+ if target_cs is not None:
3449
+ return f"{self._func_cpp_base_name(fi.name)}_cs{target_cs}"
3450
+ return None
3451
+
3452
+ requested_roots: set[tuple[str, str | None]] = set()
3453
+ chart_roots: set[tuple[str, str | None]] = set()
3454
+ context_edges: dict[
3455
+ tuple[str, str | None], set[tuple[str, str | None]]
3456
+ ] = {}
3457
+ for call in (
3458
+ candidate
3459
+ for candidate in walk_nodes(self.ctx.ast)
3460
+ if isinstance(candidate, FuncCall)
3461
+ ):
3462
+ fi = plain_udf_info(call)
3463
+ if fi is None:
3464
+ continue
3465
+ owner = owner_by_node.get(id(call))
3466
+ if id(call) in requested_node_ids:
3467
+ owner_contexts = (
3468
+ [None]
3469
+ if owner is None
3470
+ else self._inline_history_contexts_for_owner(owner)
3471
+ )
3472
+ for owner_context in owner_contexts:
3473
+ requested_roots.add((
3474
+ fi.name,
3475
+ emitted_context_for_call(
3476
+ fi, call, owner, owner_context
3477
+ ),
3478
+ ))
3479
+ continue
3480
+ if owner is None:
3481
+ target = (
3482
+ fi.name,
3483
+ emitted_context_for_call(fi, call, None, None),
3484
+ )
3485
+ chart_roots.add(target)
3486
+ continue
3487
+ for owner_context in self._inline_history_contexts_for_owner(owner):
3488
+ source = (owner, owner_context)
3489
+ target = (
3490
+ fi.name,
3491
+ emitted_context_for_call(
3492
+ fi, call, owner, owner_context
3493
+ ),
3494
+ )
3495
+ context_edges.setdefault(source, set()).add(target)
3496
+
3497
+ def reachable(
3498
+ roots: set[tuple[str, str | None]],
3499
+ ) -> set[tuple[str, str | None]]:
3500
+ found = set(roots)
3501
+ pending = list(roots)
3502
+ while pending:
3503
+ source = pending.pop()
3504
+ for target in context_edges.get(source, ()):
3505
+ if target in found:
3506
+ continue
3507
+ found.add(target)
3508
+ pending.append(target)
3509
+ return found
3510
+
3511
+ self._requested_context_only_inline_contexts = (
3512
+ reachable(requested_roots) - reachable(chart_roots)
3513
+ )
3514
+
3348
3515
  for node in walk_nodes(self.ctx.ast):
3349
3516
  owner = owner_by_node.get(id(node))
3350
3517
  if isinstance(node, Subscript) and isinstance(node.object, FuncCall):
@@ -3403,6 +3570,26 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3403
3570
  expected_cpp_type = self._series_param_element_cpp_type(
3404
3571
  fi, idx, target_cs
3405
3572
  )
3573
+ # Pine gives each plain-UDF written call its own
3574
+ # chart-aligned parameter history. Even an exact Series
3575
+ # actual therefore needs a call-site-owned buffer: skipped
3576
+ # evaluations hold the last value in that bar's slot
3577
+ # instead of exposing the caller's independently changing
3578
+ # history. Typed methods retain their established direct
3579
+ # binding/bridge behavior below.
3580
+ chart_execution_context = (
3581
+ id(node) not in requested_node_ids
3582
+ and (owner, context)
3583
+ not in self._requested_context_only_inline_contexts
3584
+ )
3585
+ if not method_call and chart_execution_context:
3586
+ register_one(
3587
+ "udf_series_arg",
3588
+ (id(node), idx),
3589
+ expected_cpp_type,
3590
+ context,
3591
+ )
3592
+ continue
3406
3593
  needs_bridge = True
3407
3594
  if isinstance(arg, Identifier):
3408
3595
  if (
@@ -3620,7 +3807,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3620
3807
  # request.security helper-call results read at a history offset
3621
3808
  # (``myHelper()[k]``). Maps (sec_id, node-id) -> backing Series metadata.
3622
3809
  self._security_expr_hist_by_node: dict[tuple[int, int], dict] = {}
3623
- self._prepare_lazy_saturated_roc3_sites()
3810
+ self._prepare_lazy_source_clock_sites()
3624
3811
 
3625
3812
  lines: list[str] = []
3626
3813
 
@@ -3853,11 +4040,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
3853
4040
  )
3854
4041
  lines.append("")
3855
4042
 
3856
- # Source-shaped lazy ROC call clocks are generated support types, not
3857
- # script state themselves. Their per-callsite instances are declared
3858
- # below inside GeneratedStrategy and therefore join the automatic COOF
3859
- # checkpoint inventory.
3860
- self._emit_lazy_saturated_roc3_helper(lines)
4043
+ # Hold-last source clocks (change/mom/roc below a top-level lazy edge)
4044
+ # are generated support types, not script state themselves. Their
4045
+ # per-callsite instances are declared below inside GeneratedStrategy
4046
+ # and therefore join the automatic COOF checkpoint inventory.
4047
+ self._emit_lazy_source_clock_helper(lines)
3861
4048
 
3862
4049
  # 2. Open class
3863
4050
  lines.append("class GeneratedStrategy : public BacktestEngine {")
@@ -4013,20 +4200,21 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
4013
4200
  lines.append(f" std::vector<{vtype}> _precalc_{site.member_name};")
4014
4201
  lines.append(" bool _use_precalc = false;")
4015
4202
 
4016
- for clock_name in self._lazy_saturated_roc3_clock_by_node.values():
4017
- lines.append(
4018
- f" {self._lazy_saturated_roc3_type_name} {clock_name};"
4019
- )
4020
- if self._lazy_saturated_roc3_clock_by_node:
4021
- # Dedicated eager close[3] fallback. Its fixed four-slot capacity
4022
- # is independent of the user's max_bars_back directive, which may
4023
- # legitimately be smaller than the offset this generated route
4024
- # requires. It is ordinary copyable script state and therefore
4025
- # joins the automatic COOF checkpoint below.
4203
+ for info in self._lazy_source_clock_by_node.values():
4026
4204
  lines.append(
4027
- " Series<double> "
4028
- f"{self._lazy_saturated_roc3_history_name}{{4}};"
4205
+ f" {self._lazy_source_clock_type_name} {info['clock']};"
4029
4206
  )
4207
+ # Held-source history on the chart clock. A literal length sizes
4208
+ # it exactly (independent of the user's max_bars_back directive,
4209
+ # which may legitimately be smaller than this generated route
4210
+ # needs); a runtime length uses the Series default capacity. Both
4211
+ # are ordinary copyable script state and join the automatic COOF
4212
+ # checkpoint below.
4213
+ literal = info["length_literal"]
4214
+ capacity = f"{{{literal + 1}}}" if literal is not None and literal >= 1 else ""
4215
+ lines.append(f" Series<double> {info['hist']}{capacity};")
4216
+ if info["chart"] is not None:
4217
+ lines.append(f" Series<double> {info['chart']}{capacity};")
4030
4218
 
4031
4219
  # Security evaluator TA members (cloned from expression dependencies)
4032
4220
  # Skip for user function call expressions — their TA deps are internal to the function
@@ -136,6 +136,28 @@ class TopLevelEmitter:
136
136
  lines.append('#include <pineforge/log.hpp>')
137
137
  lines.append('#include <pineforge/str_utils.hpp>')
138
138
  lines.append('#include <pineforge/session_time.hpp>')
139
+ # Feature-guarded argument tails (lab experiment cells build the
140
+ # candidate codegen against the BASE engine too): the symbol clock
141
+ # (syminfo tz + session) reaches time()/time_close() and ta.vwap only
142
+ # when the engine header advertises the overloads; otherwise the
143
+ # macros expand to nothing and the calls collapse to the legacy
144
+ # 5-arg pine_time / 3-arg VWAP::compute shapes (session filter in
145
+ # UTC, UTC-day VWAP anchor — the honest "factor absent" behaviour).
146
+ # Gated on the pre-scans so scripts without such calls stay
147
+ # byte-identical. The expansion carries no extra parentheses so the
148
+ # preprocessed call is byte-identical to the plain 7-arg form.
149
+ if getattr(self, "_uses_pine_time", False):
150
+ lines.append("#ifdef PF_PINE_TIME_HAS_SESSION_DAY")
151
+ lines.append("#define PF_PINE_TIME_SESSION_DAY_ARGS(tz, sess) , tz, sess")
152
+ lines.append("#else")
153
+ lines.append("#define PF_PINE_TIME_SESSION_DAY_ARGS(tz, sess)")
154
+ lines.append("#endif")
155
+ if getattr(self, "_uses_vwap_anchor", False):
156
+ lines.append("#ifdef PF_VWAP_HAS_SESSION_ANCHOR")
157
+ lines.append("#define PF_VWAP_SESSION_ANCHOR_ARGS(tz, sess) , tz, sess")
158
+ lines.append("#else")
159
+ lines.append("#define PF_VWAP_SESSION_ANCHOR_ARGS(tz, sess)")
160
+ lines.append("#endif")
139
161
  if self._uses_matrix:
140
162
  # Unconditional include when matrix API is used — do not gate on
141
163
  # __has_include(<Eigen/Dense>) (can differ between runtime build and this TU).
@@ -912,28 +934,38 @@ class TopLevelEmitter:
912
934
 
913
935
  # A GeneratedStrategy handle may execute multiple batch runs or
914
936
  # streaming lifecycles. BacktestEngine resets broker/base state, but
915
- # generated members survive. Reset source-shaped lazy ROC clocks and
916
- # their forced eager-fallback close history at the first genuine slot
917
- # of each lifecycle. This lives in on_bar rather than a generated run
918
- # wrapper because stream_begin() enters the base run path directly.
919
- # COOF post-close recalculations have history_advances_new_bar()==false
920
- # and therefore preserve the current committed clock/base.
921
- if self._lazy_saturated_roc3_clock_by_node:
937
+ # generated members survive. Reset the hold-last source clocks
938
+ # (change/mom/roc below a top-level lazy edge) and their held-source
939
+ # Series at the first genuine slot of each lifecycle, then freeze this
940
+ # bar's base and record it once per chart bar. This lives in on_bar
941
+ # rather than a generated run wrapper because stream_begin() enters the
942
+ # base run path directly. COOF post-close recalculations have
943
+ # history_advances_new_bar()==false and therefore keep the same base.
944
+ if self._lazy_source_clock_by_node:
922
945
  lines.append(
923
946
  " if (history_advances_new_bar() && bar_index_ == 0) {"
924
947
  )
925
- for clock_name in self._lazy_saturated_roc3_clock_by_node.values():
926
- lines.append(f" {clock_name}.reset();")
927
- lines.append(
928
- f" {self._lazy_saturated_roc3_history_name}.clear();"
929
- )
948
+ for info in self._lazy_source_clock_by_node.values():
949
+ lines.append(f" {info['clock']}.reset();")
950
+ lines.append(f" {info['hist']}.clear();")
951
+ if info["chart"] is not None:
952
+ lines.append(f" {info['chart']}.clear();")
930
953
  lines.append(" }")
931
- self._emit_history_series_write(
932
- lines,
933
- " ",
934
- self._lazy_saturated_roc3_history_name,
935
- "current_bar_.close",
936
- )
954
+ for info in self._lazy_source_clock_by_node.values():
955
+ lines.append(f" {info['clock']}.begin_bar(bar_index_);")
956
+ self._emit_history_series_write(
957
+ lines,
958
+ " ",
959
+ info["hist"],
960
+ f"{info['clock']}.bar_base_source",
961
+ )
962
+ if info["chart"] is not None:
963
+ self._emit_history_series_write(
964
+ lines,
965
+ " ",
966
+ info["chart"],
967
+ self._visit_expr(info["chart_source"]),
968
+ )
937
969
 
938
970
  # reset_run_state() owns engine/broker state, while these generated
939
971
  # Series members belong to the strategy object. Clear all of them on
@@ -949,6 +981,21 @@ class TopLevelEmitter:
949
981
  f"{info['member_name']}.clear();"
950
982
  )
951
983
 
984
+ # A history-reading plain-UDF parameter advances on the chart clock,
985
+ # even when lazy control flow skips its written call on this bar. Seed
986
+ # the new slot with the prior current value (``na`` before first reach);
987
+ # an executed call later in the bar updates this same slot with its
988
+ # scalar actual. Typed-method ``series_arg`` bridges intentionally keep
989
+ # their existing execution-clock behavior.
990
+ for info in self._inline_history_members:
991
+ if info["kind"] != "udf_series_arg":
992
+ continue
993
+ member = info["member_name"]
994
+ lines.append(
995
+ f" if (history_advances_new_bar()) "
996
+ f"{member}.push({member}.current());"
997
+ )
998
+
952
999
  # a. Push bar field series (with bar magnifier support)
953
1000
  for field_name in sorted(self.ctx.series_bar_fields):
954
1001
  push_expr = BAR_SERIES_PUSH.get(field_name, f"current_bar_.{field_name}")
@@ -1181,9 +1228,15 @@ class TopLevelEmitter:
1181
1228
  # an input (the default-sized construction already matches Pine).
1182
1229
  self._emit_ta_runtime_reset(lines, indent=2)
1183
1230
 
1184
- # d. Visit each statement
1231
+ # d. Visit each statement. A stateful ``ta.*`` site below a lazy
1232
+ # ``and``/``or`` RHS or ternary arm of a top-level statement is
1233
+ # evaluated every bar BEFORE the statement (TV rule, see ``ta.py``).
1185
1234
  for stmt in self.ctx.ast.body:
1186
- self._visit_stmt(stmt, lines, indent=2)
1235
+ self._emit_lazy_edge_ta_hoists(stmt, lines, indent=2)
1236
+ try:
1237
+ self._visit_stmt(stmt, lines, indent=2)
1238
+ finally:
1239
+ self._clear_lazy_edge_ta_hoists()
1187
1240
 
1188
1241
  # e. ``// @pf-trace`` pragma block — emitted last so trace values
1189
1242
  # reflect every assignment / strategy call made earlier in the
@@ -2844,6 +2844,156 @@ class SecurityEmitter:
2844
2844
  )
2845
2845
  return out
2846
2846
 
2847
+ def _security_lazy_ta_keys(
2848
+ self,
2849
+ sec_id: int,
2850
+ expr_node,
2851
+ info: dict,
2852
+ ) -> set[tuple[int, tuple]]:
2853
+ """``(ta_index, binding_signature)`` pairs Pine reaches only lazily.
2854
+
2855
+ Pine evaluates ``and``/``or`` with left-to-right short-circuit and a
2856
+ ternary evaluates its condition plus exactly one branch. A ``ta.*``
2857
+ call sitting in a skipped operand is **not evaluated on that bar**, so
2858
+ its series state does not advance. The evaluator used to hoist every
2859
+ collected site into an unconditional ``auto _secval_N = ...compute(...)``
2860
+ prologue, which advanced every site on every HTF bar and desynchronised
2861
+ the series against TradingView.
2862
+
2863
+ A site returned here is dropped from that prologue. ``_build_security_expr``
2864
+ then falls through to its inline
2865
+ ``(security_series_slot_is_new(N) ? m.compute(a) : m.recompute(a))``
2866
+ form, emitted in expression position — C++'s own ``&&`` / ``||`` /
2867
+ ``?:`` short-circuit then advances the state exactly when Pine would,
2868
+ and the relational lowering's ``_pna_l`` / ``_pna_r`` temporaries keep
2869
+ the evaluation order left-to-right.
2870
+
2871
+ Deliberately conservative — anything not provably single-reach and
2872
+ conditional keeps the existing eager hoist, so scripts without
2873
+ conditional security TA regenerate byte-identically:
2874
+
2875
+ - A site reached more than once must stay hoisted: sharing one
2876
+ ``_secval_*`` is what keeps it to a single advance per bar, whereas
2877
+ two inline copies would advance it twice (or zero times).
2878
+ - Laziness does **not** propagate through a global binding. In the
2879
+ requested context that global is its own unconditional top-level
2880
+ statement, so it evaluates on every HTF bar wherever it is read
2881
+ (this is what keeps chart-side ``t0``-shaped code exact).
2882
+ - Sites with a history offset (``ta.ema(close, 55)[1]``) keep the
2883
+ committed ``_secval_*`` their per-bar Series push reads.
2884
+ - Securities that rebind mutable globals, and multi-statement helper
2885
+ calls, bail entirely: both lower through statement emitters that
2886
+ consume ``ta_results`` outside this expression.
2887
+ """
2888
+ if info.get("mutable_globals"):
2889
+ return set()
2890
+
2891
+ occurrences: dict[tuple[int, tuple], list[bool]] = {}
2892
+ unsafe = False
2893
+
2894
+ def walk(node, lazy: bool, binding_stack, resolving: set[str]) -> None:
2895
+ nonlocal unsafe
2896
+ if node is None or unsafe:
2897
+ return
2898
+
2899
+ if isinstance(node, Identifier):
2900
+ binding = None
2901
+ if not self._security_identifier_is_global_binding(node):
2902
+ binding = self._security_lookup_helper_binding_context(
2903
+ node.name, binding_stack
2904
+ )
2905
+ if binding is not None:
2906
+ bound, bound_stack = binding
2907
+ if isinstance(bound, str):
2908
+ return
2909
+ bind_key = f"bind:{id(bound)}"
2910
+ if bind_key in resolving:
2911
+ return
2912
+ resolving.add(bind_key)
2913
+ walk(bound, lazy, bound_stack, resolving)
2914
+ resolving.discard(bind_key)
2915
+ return
2916
+
2917
+ if self._global_mutable_infos.get(node.name) is not None:
2918
+ unsafe = True
2919
+ return
2920
+
2921
+ global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
2922
+ if (
2923
+ self._security_identifier_is_global_binding(node)
2924
+ and node.name in global_expr_map
2925
+ and node.name not in resolving
2926
+ ):
2927
+ resolving.add(node.name)
2928
+ walk(global_expr_map[node.name], False, (), resolving)
2929
+ resolving.remove(node.name)
2930
+ return
2931
+
2932
+ if isinstance(node, FuncCall) and isinstance(node.callee, Identifier):
2933
+ func_name = node.callee.name
2934
+ if func_name in self._func_names:
2935
+ call_key = f"func:{func_name}"
2936
+ if call_key in resolving:
2937
+ return
2938
+ resolving.add(call_key)
2939
+ plan = self._security_helper_call_plan(node, binding_stack)
2940
+ if plan["mode"] == "expr":
2941
+ walk(plan["expr"], lazy, plan["binding_stack"], resolving)
2942
+ else:
2943
+ unsafe = True
2944
+ resolving.discard(call_key)
2945
+ return
2946
+
2947
+ if isinstance(node, BinOp) and node.op in ("and", "or"):
2948
+ walk(node.left, lazy, binding_stack, resolving)
2949
+ walk(node.right, True, binding_stack, resolving)
2950
+ return
2951
+
2952
+ if isinstance(node, Ternary):
2953
+ walk(node.condition, lazy, binding_stack, resolving)
2954
+ walk(node.true_val, True, binding_stack, resolving)
2955
+ walk(node.false_val, True, binding_stack, resolving)
2956
+ return
2957
+
2958
+ site = self._get_ta_site(node)
2959
+ if site is not None:
2960
+ idx = self._ta_index_by_site_id.get(id(site))
2961
+ if idx is not None:
2962
+ key = (idx, self._security_binding_stack_signature(binding_stack))
2963
+ occurrences.setdefault(key, []).append(lazy)
2964
+
2965
+ for child in vars(node).values():
2966
+ walk_value(child, lazy, binding_stack, resolving)
2967
+
2968
+ def walk_value(value, lazy: bool, binding_stack, resolving: set[str]) -> None:
2969
+ if value is None or unsafe:
2970
+ return
2971
+ if hasattr(value, "__dict__"):
2972
+ walk(value, lazy, binding_stack, resolving)
2973
+ return
2974
+ if isinstance(value, (list, tuple)):
2975
+ for item in value:
2976
+ walk_value(item, lazy, binding_stack, resolving)
2977
+ return
2978
+ if isinstance(value, dict):
2979
+ for item in value.values():
2980
+ walk_value(item, lazy, binding_stack, resolving)
2981
+
2982
+ walk(expr_node, False, (), set())
2983
+ if unsafe:
2984
+ return set()
2985
+
2986
+ hist_indices = set(self._security_ta_hist_idx_by_sec.get(sec_id, ()))
2987
+ inline_helper_ta_indices = set(info.get("inline_helper_ta_indices", []))
2988
+ return {
2989
+ key
2990
+ for key, reaches in occurrences.items()
2991
+ if key[0] not in hist_indices
2992
+ and key[0] not in inline_helper_ta_indices
2993
+ and len(reaches) == 1
2994
+ and reaches[0]
2995
+ }
2996
+
2847
2997
  def _emit_security_ohlc_hist_pushes(self, sec_id: int, lines: list[str]) -> None:
2848
2998
  """Emit the OHLC history-offset Series pushes for ``sec_id``, gated on
2849
2999
  ``is_complete``.
@@ -2909,6 +3059,7 @@ class SecurityEmitter:
2909
3059
  ta_indices = info.get("ta_indices") or []
2910
3060
  security_mutable_names = set(info.get("mutable_globals", []))
2911
3061
  inline_helper_ta_indices = set(info.get("inline_helper_ta_indices", []))
3062
+ lazy_ta_keys = self._security_lazy_ta_keys(sec_id, expr_node, info)
2912
3063
 
2913
3064
  lines.append(f" void _eval_security_{sec_id}(const Bar& bar, bool is_complete) {{")
2914
3065
 
@@ -2949,6 +3100,15 @@ class SecurityEmitter:
2949
3100
  site = self.ctx.ta_call_sites[idx]
2950
3101
  variants = (info.get("ta_variants") or {}).get(idx, [])
2951
3102
  for variant in variants:
3103
+ if (idx, variant["signature"]) in lazy_ta_keys:
3104
+ # Pine only reaches this site through a
3105
+ # short-circuited operand or an untaken ternary
3106
+ # branch. Leave it out of the eager prologue:
3107
+ # _build_security_expr emits its
3108
+ # compute()/recompute() inline in expression
3109
+ # position, where C++'s &&/||/?: short-circuit
3110
+ # advances the series exactly on the bars Pine does.
3111
+ continue
2952
3112
  helper_binding_stack = variant.get("binding_stack", ())
2953
3113
  compute_args = self._security_ta_compute_args_for_site(
2954
3114
  sec_id,