@pineforge/codegen-pyodide 0.10.2 → 0.10.4

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.
@@ -25,12 +25,18 @@ from __future__ import annotations
25
25
  from typing import TYPE_CHECKING
26
26
 
27
27
  from ..ast_nodes import (
28
- Assignment, BinOp, BoolLiteral, ColorLiteral, ExprStmt, FuncCall,
29
- ForInStmt, ForStmt, FuncDef, Identifier, MemberAccess, NaLiteral,
30
- NumberLiteral, StringLiteral, Subscript, Ternary, TupleAssign,
31
- TupleLiteral, TypeDecl, UnaryOp, VarDecl,
28
+ ASTNode, Assignment, BinOp, BoolLiteral, ColorLiteral, EnumDecl, ExprStmt,
29
+ FuncCall, ForInStmt, ForStmt, FuncDef, Identifier, IfStmt, MemberAccess,
30
+ MethodDef, NaLiteral, NumberLiteral, StringLiteral, Subscript, SwitchStmt,
31
+ Ternary, TupleAssign, TupleLiteral, TypeDecl, TypeField, UnaryOp, VarDecl,
32
+ WhileStmt,
33
+ )
34
+ from .tables import (
35
+ TA_CHART_PREV_CLOSE,
36
+ TA_CHART_PREV_CLOSE_ARG,
37
+ TA_IMPLICIT_APPEND,
38
+ TA_IMPLICIT_COMPUTE_FULL,
32
39
  )
33
- from .tables import TA_IMPLICIT_APPEND, TA_IMPLICIT_COMPUTE_FULL
34
40
 
35
41
  if TYPE_CHECKING:
36
42
  from ..analyzer import TACallSite
@@ -112,6 +118,10 @@ class TaSiteHelper:
112
118
 
113
119
  if ta_name in TA_IMPLICIT_COMPUTE_FULL:
114
120
  implicit = TA_IMPLICIT_COMPUTE_FULL[ta_name]
121
+ # issue #178: chart-context atr / tr take the previous CHART
122
+ # bar's close as a 4th argument (see TA_CHART_PREV_CLOSE).
123
+ if ta_name in TA_CHART_PREV_CLOSE:
124
+ implicit = f"{implicit}, {TA_CHART_PREV_CLOSE_ARG}"
115
125
  if site.compute_args:
116
126
  explicit = ", ".join(self._visit_expr(a) for a in site.compute_args)
117
127
  if ta_name in self._TA_IMPLICIT_REPLACE:
@@ -200,18 +210,26 @@ class TaSiteHelper:
200
210
  return False
201
211
  return False
202
212
 
203
- def _ta_call_nodes_by_lazy_scope(self) -> tuple[set[int], set[int], set[int]]:
204
- """Classify chart ``ta.*`` sites below Pine-v6 lazy-expression edges.
205
-
206
- The first set contains sites below an ``and`` RHS, preserving the
207
- already accepted lazy-SMA scope. The second contains sites below any
208
- Pine-v6 lazy edge: an ``and``/``or`` RHS or either ``?:`` branch. The
209
- broader set is consumed only by the recursive-EMA route;
210
- other TA families retain their current precalculation behavior. The
211
- third is the narrow ``and``-only subset: the site is below an ``and``
212
- RHS and is not nested anywhere inside an ``or`` or ternary expression.
213
- It gives source-shaped lazy call clocks a stable AST identity without
214
- inspecting generated member names.
213
+ # ------------------------------------------------------------------
214
+ # Lazy-edge classification in every scope -- consumed by the precalc gate
215
+ # ------------------------------------------------------------------
216
+
217
+ _LAZY_SCOPE_STMT_TYPES = (
218
+ VarDecl, Assignment, TupleAssign, ExprStmt, IfStmt, ForStmt, ForInStmt,
219
+ WhileStmt, SwitchStmt, FuncDef, MethodDef, TypeDecl, EnumDecl,
220
+ )
221
+
222
+ def _ta_call_nodes_by_lazy_scope(self) -> tuple[set[int], set[int]]:
223
+ """Classify chart ``ta.*`` call nodes below Pine-v6 lazy edges, everywhere.
224
+
225
+ Returns ``(and_rhs, lazy_rhs)``: sites below an ``and`` RHS, and sites
226
+ below any lazy edge (an ``and``/``or`` RHS or a ``?:`` arm), walking the
227
+ top level, control-flow bodies, user-function bodies and UDT field
228
+ defaults. Entering a statement resets both flags, expression nodes
229
+ propagate them, and a ``request.security*`` payload is skipped because
230
+ its own evaluator lowers it. Consumed only by the block-scope opt-outs
231
+ in ``_ta_site_uses_precalc``; top-level statement operands are governed
232
+ by ``_lazy_edge_ta_hoist_plan`` instead.
215
233
  """
216
234
  cached = getattr(self, "_lazy_scope_ta_call_nodes", None)
217
235
  if cached is not None:
@@ -219,198 +237,78 @@ class TaSiteHelper:
219
237
 
220
238
  and_rhs: set[int] = set()
221
239
  lazy_rhs: set[int] = set()
222
- plain_and_rhs: set[int] = set()
223
240
 
224
- def note_ta_calls(
225
- expr,
226
- under_and_rhs: bool,
227
- under_lazy_rhs: bool,
228
- under_disallowed_shape: bool,
229
- ) -> None:
230
- if expr is None:
241
+ def note(value, under_and: bool, under_lazy: bool) -> None:
242
+ if value is None:
231
243
  return
232
- if isinstance(expr, FuncCall):
233
- callee = expr.callee
244
+ if isinstance(value, (list, tuple)):
245
+ for item in value:
246
+ note(item, under_and, under_lazy)
247
+ return
248
+ if isinstance(value, dict):
249
+ for item in value.values():
250
+ note(item, under_and, under_lazy)
251
+ return
252
+ if isinstance(value, TypeField):
253
+ note(value.default, False, False)
254
+ return
255
+ if not isinstance(value, ASTNode):
256
+ return
257
+ if isinstance(value, self._LAZY_SCOPE_STMT_TYPES):
258
+ for child in vars(value).values():
259
+ note(child, False, False)
260
+ return
261
+ if isinstance(value, FuncCall):
262
+ callee = value.callee
234
263
  is_security = (
235
264
  isinstance(callee, MemberAccess)
236
265
  and isinstance(callee.object, Identifier)
237
266
  and callee.object.name == "request"
238
267
  and callee.member in ("security", "security_lower_tf")
239
268
  )
240
- if isinstance(expr.callee, MemberAccess):
241
- obj = expr.callee.object
242
- if isinstance(obj, Identifier) and obj.name == "ta":
243
- if under_and_rhs:
244
- and_rhs.add(id(expr))
245
- if under_lazy_rhs:
246
- lazy_rhs.add(id(expr))
247
- if under_and_rhs and not under_disallowed_shape:
248
- plain_and_rhs.add(id(expr))
249
- # A call target may itself be an evaluated expression, as in
250
- # ``array.new_float(...).get(0)``. Walk the callee subtree so
251
- # TA nested in a chained receiver inherits the surrounding
252
- # lazy context. Plain identifiers and namespace receivers are
253
- # leaves, so ordinary ``ta.sma`` / ``request.security`` calls
254
- # remain unaffected here.
255
- note_ta_calls(
256
- callee,
257
- under_and_rhs,
258
- under_lazy_rhs,
259
- under_disallowed_shape,
260
- )
261
- for idx, arg in enumerate(getattr(expr, "args", ()) or ()):
262
- # The third request.security* argument is evaluated by its
263
- # own security evaluator. Symbol, timeframe, and remaining
264
- # options are chart-context expressions and must still be
265
- # inspected for lazy chart TA.
269
+ # A chained receiver (``label.new(...).get_y()``) is an
270
+ # evaluated expression too; namespace/identifier callees are
271
+ # leaves.
272
+ note(callee, under_and, under_lazy)
273
+ for idx, arg in enumerate(getattr(value, "args", ()) or ()):
266
274
  if is_security and idx == 2:
267
275
  continue
268
- note_ta_calls(
269
- arg,
270
- under_and_rhs,
271
- under_lazy_rhs,
272
- under_disallowed_shape,
273
- )
274
- for key, value in (getattr(expr, "kwargs", None) or {}).items():
276
+ note(arg, under_and, under_lazy)
277
+ for key, kw_value in (getattr(value, "kwargs", None) or {}).items():
275
278
  if is_security and key == "expression":
276
279
  continue
277
- note_ta_calls(
278
- value,
279
- under_and_rhs,
280
- under_lazy_rhs,
281
- under_disallowed_shape,
282
- )
283
- return
284
- if isinstance(expr, BinOp):
285
- if expr.op == "and":
286
- # LHS always runs first; RHS is short-circuit conditional.
287
- note_ta_calls(
288
- expr.left,
289
- under_and_rhs,
290
- under_lazy_rhs,
291
- under_disallowed_shape,
292
- )
293
- note_ta_calls(
294
- expr.right,
295
- True,
296
- True,
297
- under_disallowed_shape,
298
- )
299
- elif expr.op == "or":
300
- # ``or`` preserves an enclosing ``and`` scope, but its own
301
- # RHS is a Pine-v6 lazy edge for the EMA classifier.
302
- note_ta_calls(expr.left, under_and_rhs, under_lazy_rhs, True)
303
- note_ta_calls(expr.right, under_and_rhs, True, True)
304
- else:
305
- note_ta_calls(
306
- expr.left,
307
- under_and_rhs,
308
- under_lazy_rhs,
309
- under_disallowed_shape,
310
- )
311
- note_ta_calls(
312
- expr.right,
313
- under_and_rhs,
314
- under_lazy_rhs,
315
- under_disallowed_shape,
316
- )
317
- return
318
- if isinstance(expr, Ternary):
319
- note_ta_calls(expr.condition, under_and_rhs, under_lazy_rhs, True)
320
- note_ta_calls(expr.true_val, under_and_rhs, True, True)
321
- note_ta_calls(expr.false_val, under_and_rhs, True, True)
322
- return
323
- if isinstance(expr, UnaryOp):
324
- note_ta_calls(
325
- expr.operand,
326
- under_and_rhs,
327
- under_lazy_rhs,
328
- under_disallowed_shape,
329
- )
330
- return
331
- if isinstance(expr, (MemberAccess, Subscript)):
332
- note_ta_calls(
333
- getattr(expr, "object", None),
334
- under_and_rhs,
335
- under_lazy_rhs,
336
- under_disallowed_shape,
337
- )
338
- note_ta_calls(
339
- getattr(expr, "index", None),
340
- under_and_rhs,
341
- under_lazy_rhs,
342
- under_disallowed_shape,
343
- )
344
- return
345
- if isinstance(expr, TupleLiteral):
346
- for elem in expr.elements:
347
- note_ta_calls(
348
- elem,
349
- under_and_rhs,
350
- under_lazy_rhs,
351
- under_disallowed_shape,
352
- )
353
- return
354
-
355
- def walk_stmt(stmt) -> None:
356
- if stmt is None:
357
- return
358
- if isinstance(stmt, VarDecl):
359
- note_ta_calls(stmt.value, False, False, False)
360
- return
361
- if isinstance(stmt, ExprStmt):
362
- note_ta_calls(
363
- getattr(stmt, "value", None) or getattr(stmt, "expr", None),
364
- False,
365
- False,
366
- False,
367
- )
280
+ note(kw_value, under_and, under_lazy)
281
+ if (
282
+ isinstance(callee, MemberAccess)
283
+ and isinstance(callee.object, Identifier)
284
+ and callee.object.name == "ta"
285
+ ):
286
+ if under_and:
287
+ and_rhs.add(id(value))
288
+ if under_lazy:
289
+ lazy_rhs.add(id(value))
368
290
  return
369
- if isinstance(stmt, Assignment):
370
- note_ta_calls(getattr(stmt, "target", None), False, False, False)
371
- note_ta_calls(getattr(stmt, "value", None), False, False, False)
291
+ if isinstance(value, BinOp) and value.op == "and":
292
+ note(value.left, under_and, under_lazy)
293
+ note(value.right, True, True)
372
294
  return
373
- if isinstance(stmt, TupleAssign):
374
- note_ta_calls(getattr(stmt, "value", None), False, False, False)
295
+ if isinstance(value, BinOp) and value.op == "or":
296
+ note(value.left, under_and, under_lazy)
297
+ note(value.right, under_and, True)
375
298
  return
376
- if isinstance(stmt, TypeDecl):
377
- for field in getattr(stmt, "fields", ()) or ():
378
- note_ta_calls(
379
- getattr(field, "default", None), False, False, False
380
- )
299
+ if isinstance(value, Ternary):
300
+ note(value.condition, under_and, under_lazy)
301
+ note(value.true_val, under_and, True)
302
+ note(value.false_val, under_and, True)
381
303
  return
382
- # If / for / while / assign-like — best-effort field walk
383
- for attr in ("condition", "body", "else_body", "else_ifs", "value", "target", "iterable"):
384
- child = getattr(stmt, attr, None)
385
- if child is None:
386
- continue
387
- if isinstance(child, list):
388
- for item in child:
389
- if isinstance(item, (list, tuple)):
390
- for sub in item:
391
- walk_stmt(sub)
392
- elif hasattr(item, "body") or hasattr(item, "name") or hasattr(item, "value"):
393
- walk_stmt(item)
394
- else:
395
- note_ta_calls(item, False, False, False)
396
- elif hasattr(child, "op") or hasattr(child, "args") or hasattr(child, "left"):
397
- note_ta_calls(child, False, False, False)
398
- elif hasattr(child, "body") or hasattr(child, "value") or hasattr(child, "condition"):
399
- walk_stmt(child)
304
+ for child in vars(value).values():
305
+ note(child, under_and, under_lazy)
400
306
 
401
- ast = getattr(self.ctx, "ast", None)
402
- for stmt in getattr(ast, "body", ()) or ():
403
- walk_stmt(stmt)
404
- # User function bodies (original sites live here; clones share node ids
405
- # only when they reuse the same FuncCall object — still best-effort).
307
+ note(getattr(self.ctx, "ast", None), False, False)
406
308
  for finfo in getattr(self.ctx, "func_infos", None) or []:
407
- node = getattr(finfo, "node", None)
408
- body = getattr(node, "body", None) if node is not None else None
409
- if body:
410
- for stmt in body:
411
- walk_stmt(stmt)
309
+ note(getattr(finfo, "node", None), False, False)
412
310
 
413
- result = (and_rhs, lazy_rhs, plain_and_rhs)
311
+ result = (and_rhs, lazy_rhs)
414
312
  self._lazy_scope_ta_call_nodes = result
415
313
  return result
416
314
 
@@ -420,23 +318,83 @@ class TaSiteHelper:
420
318
  def _ta_call_nodes_under_lazy_rhs(self) -> set[int]:
421
319
  return self._ta_call_nodes_by_lazy_scope()[1]
422
320
 
423
- def _ta_call_nodes_under_plain_and_rhs(self) -> set[int]:
424
- return self._ta_call_nodes_by_lazy_scope()[2]
425
-
426
- def _ta_call_nodes_in_top_level_var_values(self) -> set[int]:
427
- cached = getattr(self, "_top_level_var_value_ta_nodes", None)
428
- if cached is not None:
429
- return cached
430
- result: set[int] = set()
431
- ast = getattr(self.ctx, "ast", None)
432
- for stmt in getattr(ast, "body", ()) or ():
433
- if not isinstance(stmt, VarDecl):
434
- continue
435
- for child in self._walk_ast(stmt.value):
436
- if isinstance(child, FuncCall):
437
- result.add(id(child))
438
- self._top_level_var_value_ta_nodes = result
439
- return result
321
+ # ------------------------------------------------------------------
322
+ # TradingView's per-family clocks below a top-level lazy edge
323
+ # ------------------------------------------------------------------
324
+ #
325
+ # Pinned 2026-09-03 with ``lab tv`` on NYSE:F 1D (range 2025-04-01 ..
326
+ # 2026-05-01; cadence-7 ternary probes ``v = bar_index % 7 == 3 ? <call> :
327
+ # na`` exposing the value through the entry size, plus lazy-``and``
328
+ # probes), each scored per call against three models:
329
+ #
330
+ # every-bar natives highest 9/9, sma 25/25, ema 23/23 -- the runtime
331
+ # advances the built-in on every chart bar; only the
332
+ # value is gated (``_lazy_edge_ta_hoist_plan``).
333
+ # hold-last source roc 38/38 (+39/39 entries), change 39/39, mom
334
+ # 39/39 -- TradingView computes these from the CALL'S
335
+ # OWN ``source[length]`` history: the source is
336
+ # written only on bars where the call executes, the
337
+ # last executed value is held on skipped bars, and
338
+ # the history is na before the first execution
339
+ # (call 1 of every value probe has no TV entry).
340
+ # every-bar 0/38..0/39, ring-of-executions 0..1/39.
341
+ # per-execution cum, barssince, valuewhen, cross, crossover 39/39,
342
+ # rising 39/39 (strictly monotonic over its executed
343
+ # samples) and math.sum 39/39 (na until three
344
+ # executed samples) -- the native only ever sees the
345
+ # samples of bars where the call executes, which is
346
+ # exactly the reached-only inline ``compute`` lowering
347
+ # (every-bar 0..31/39).
348
+ #
349
+ # The hoist is an ALLOW-LIST and requires a direct ``[k]`` read. A broad
350
+ # every-bar hoist of every stateful family measured on Cloud Run against
351
+ # the full population (2026-09-04, like-for-like vs the same lab bundle)
352
+ # fixed the pinned shapes but cost 170 tiers / 30 hard-lane regressions,
353
+ # and hoisting allow-listed families WITHOUT a history read still broke
354
+ # four ETH hard-lane probes that main matched at 100%: ``adxVal > 30 and
355
+ # adxVal > ta.sma(adxVal, 7)`` (louislapis9), ``volume < ta.sma(volume,
356
+ # 20)`` under ``and`` (oliver1002 -- the original pf-probe-oliver-dual-
357
+ # vol-sma pin), ``ta.change(ta.sma(close, 50)) > 0`` under ``and``
358
+ # (ycelestine77), five ``ta.ema(close, 200)`` under ``or`` (quantbyboji),
359
+ # ``ta.highest(high, n) / entry > 1.05`` under ``and`` (miemomo3). Every
360
+ # every-bar tape (highest/sma/ema ``[1]`` under ``and``, the ternary
361
+ # ``ta.highest(high, 5)[1]``, robmagnaye) reads ``[k]`` on the call, and
362
+ # the bare twins of those tapes (2026-09-04, same cadence-7 probes) are
363
+ # per-execution: ``close > ta.sma(close, 5)`` ring 39/39 (every-bar 23),
364
+ # ``close > ta.ema(close, 5)`` ring 39/39 (26), ``? ta.sma(close, 5) : na``
365
+ # ring 39/39 (2, na until five executed samples), ``? ta.highest(high, 5)
366
+ # : na`` ring 38/39 (11; TV is na with one executed sample, then the
367
+ # partial maximum). So: TradingView keeps a lazily executed call on the
368
+ # reached-only clock unless the call's own history is referenced (``[k]``),
369
+ # in which case it materialises a continuous series and the call is
370
+ # evaluated on every bar. Only ``LAZY_EVERY_BAR_TA`` families
371
+ # (``lowest`` is ``highest``'s mirror) with such a read hoist.
372
+ # ``LAZY_SOURCE_CLOCK_TA`` routes to the hold-last clock;
373
+ # ``LAZY_PER_EXECUTION_TA`` (the seven taped families -- ``math.sum``
374
+ # 39/39 per-execution, na until three executed samples -- plus the exact
375
+ # mirrors ``crossunder``/``falling``) keeps the inline compute and never
376
+ # precalcs. Every other family keeps its existing lowering unchanged
377
+ # (inline compute when reached; precalc when static) until a tape pins
378
+ # it -- ``_lazy_edge_hoist_plan()["skipped"]`` lists them per script.
379
+ LAZY_EVERY_BAR_TA = frozenset({"highest", "lowest", "sma", "ema"})
380
+ LAZY_SOURCE_CLOCK_TA = frozenset({"change", "mom", "roc"})
381
+ LAZY_PER_EXECUTION_TA = frozenset({
382
+ "cum", "barssince", "valuewhen", "cross", "crossover", "crossunder",
383
+ "rising", "falling", "sum",
384
+ })
385
+ UNPINNED_LAZY_EDGE_REASON = (
386
+ "family not pinned every-bar by tape (LAZY_EVERY_BAR_TA allow-list): "
387
+ "existing lowering"
388
+ )
389
+ NO_HISTORY_READ_REASON = (
390
+ "allow-listed family without a direct [k] history read: TradingView "
391
+ "keeps the reached-only clock (louislapis9/oliver1002/miemomo3/"
392
+ "ycelestine77/quantbyboji exact on main, 2026-09-04)"
393
+ )
394
+
395
+ _LAZY_SOURCE_CLOCK_BAR_SOURCES = frozenset({
396
+ "open", "high", "low", "close", "volume", "hl2", "hlc3", "ohlc4", "hlcc4",
397
+ })
440
398
 
441
399
  def _script_has_user_binding(self, name: str) -> bool:
442
400
  cache = getattr(self, "_user_binding_names", None)
@@ -460,44 +418,53 @@ class TaSiteHelper:
460
418
  self._user_binding_names = cache
461
419
  return name in cache
462
420
 
463
- def _is_lazy_saturated_roc3_site(self, site: "TACallSite") -> bool:
464
- """Return whether ``site`` has the oracle-backed lazy ROC source shape.
465
-
466
- Selection is deliberately syntactic and callsite-local: a direct
467
- top-level variable value containing ``ta.roc(close, 3)`` below a plain
468
- ``and`` RHS, where ``close`` is not shadowed by any user binding. User
469
- functions, control-flow bodies, request.security evaluators,
470
- aliases/other sources, named or dynamic lengths, shadowed built-ins,
471
- and any surrounding ``or``/ternary shape remain on their existing eager
472
- route. Comparator polarity is intentionally absent; two otherwise
473
- identical callsites must not acquire different history semantics merely
474
- because one compares above zero and one below it.
421
+ def _lazy_source_clock_chart_source(self, site: "TACallSite"):
422
+ """The unshadowed bar-builtin source node, or None.
423
+
424
+ Between two executions closer than ``length`` bars the pin does not
425
+ distinguish the held read from #64's eager chart ``source[length]``;
426
+ the eager read is kept where the source is a plain chart builtin.
475
427
  """
476
- node = getattr(site, "node", None)
428
+ source = site.compute_args[0] if site.compute_args else None
477
429
  if (
478
- node is None
479
- or getattr(site, "owner_func", None) is not None
480
- or self._ta_name_from_site(site) != "roc"
481
- or id(node) not in self._ta_call_nodes_under_plain_and_rhs()
482
- or id(node) not in self._ta_call_nodes_in_top_level_var_values()
483
- or self._script_has_user_binding("close")
484
- or not isinstance(node, FuncCall)
485
- or getattr(node, "kwargs", None)
486
- or len(getattr(node, "args", ())) != 2
430
+ isinstance(source, Identifier)
431
+ and source.name in self._LAZY_SOURCE_CLOCK_BAR_SOURCES
432
+ and not self._script_has_user_binding(source.name)
487
433
  ):
434
+ return source
435
+ return None
436
+
437
+ @staticmethod
438
+ def _lazy_source_clock_length_node(node):
439
+ kwargs = getattr(node, "kwargs", None) or {}
440
+ if "length" in kwargs:
441
+ return kwargs["length"]
442
+ args = getattr(node, "args", ()) or ()
443
+ return args[1] if len(args) > 1 else None
444
+
445
+ @staticmethod
446
+ def _lazy_source_clock_length_literal(length_node) -> int | None:
447
+ if length_node is None:
448
+ return 1
449
+ if (
450
+ isinstance(length_node, NumberLiteral)
451
+ and not isinstance(length_node.value, bool)
452
+ and float(length_node.value) == int(length_node.value)
453
+ ):
454
+ return int(length_node.value)
455
+ return None
456
+
457
+ def _lazy_source_clock_eligible(self, site: "TACallSite") -> bool:
458
+ """change/mom/roc with a numeric source; anything else keeps its lowering."""
459
+ if getattr(site, "owner_func", None) is not None or getattr(site, "returns_tuple", False):
488
460
  return False
489
- source, length = node.args
490
- return (
491
- isinstance(source, Identifier)
492
- and source.name == "close"
493
- and isinstance(length, NumberLiteral)
494
- and not isinstance(length.value, bool)
495
- and length.value == 3
496
- )
461
+ if not site.compute_args:
462
+ return False
463
+ return self._infer_type(site.compute_args[0]) in ("double", "int", "int64_t")
497
464
 
498
- def _prepare_lazy_saturated_roc3_sites(self) -> None:
499
- """Assign one copyable clock member to each selected AST callsite."""
500
- if hasattr(self, "_lazy_saturated_roc3_clock_by_node"):
465
+ def _prepare_lazy_source_clock_sites(self) -> None:
466
+ """Allocate one clock + one held-source Series per routed site (idempotent)."""
467
+ if hasattr(self, "_lazy_source_clock_by_node"):
501
468
  return
502
469
 
503
470
  def allocate(base: str, reserved: set[str]) -> str:
@@ -531,45 +498,70 @@ class TaSiteHelper:
531
498
  reserved_members.add(f"{emitted}_cs{callsite}")
532
499
  for instance in getattr(self, "_fresh_instances", ()):
533
500
  reserved_members.add(instance["name"])
534
- self._lazy_saturated_roc3_type_name = allocate(
535
- "_PFLazySaturatedROC3Clock", reserved_types
536
- )
537
- self._lazy_saturated_roc3_history_name = allocate(
538
- "_pf_lazy_saturated_roc3_close_history", reserved_members
539
- )
540
-
541
- clocks: dict[int, str] = {}
542
- for index, site in enumerate(self.ctx.ta_call_sites):
543
- if index in getattr(self, "_dead_ta_indices", set()):
544
- continue
545
- if self._is_lazy_saturated_roc3_site(site):
546
- clocks[id(site.node)] = allocate(
547
- f"_pf_lazy_saturated_roc3_clock_{len(clocks) + 1}",
548
- reserved_members,
549
- )
550
- self._lazy_saturated_roc3_clock_by_node = clocks
551
-
552
- def _lazy_saturated_roc3_clock_name(self, site: "TACallSite") -> str:
553
- self._prepare_lazy_saturated_roc3_sites()
554
- return self._lazy_saturated_roc3_clock_by_node[id(site.node)]
555
-
556
- def _lazy_saturated_roc3_expr(self, site: "TACallSite") -> str:
557
- """Lower a reached lazy ROC site through its per-callsite clock."""
558
- clock = self._lazy_saturated_roc3_clock_name(site)
559
- history = self._lazy_saturated_roc3_history_name
560
- return (
561
- f"{clock}.evaluate(current_bar_.close, "
562
- f"{history}[3], bar_index_)"
501
+ self._lazy_source_clock_type_name = allocate("_PFLazySourceClock", reserved_types)
502
+
503
+ clocks: dict[int, dict] = {}
504
+ routed = self._lazy_edge_ta_hoist_plan()["source_clock"]
505
+ for index, (node_id, info) in enumerate(routed.items(), start=1):
506
+ chart_source = self._lazy_source_clock_chart_source(info["site"])
507
+ clocks[node_id] = {
508
+ "clock": allocate(f"_pf_lazy_src_clock_{index}", reserved_members),
509
+ "hist": allocate(f"_pf_lazy_src_hist_{index}", reserved_members),
510
+ "chart": (
511
+ allocate(f"_pf_lazy_src_chart_{index}", reserved_members)
512
+ if chart_source is not None
513
+ else None
514
+ ),
515
+ "chart_source": chart_source,
516
+ "site": info["site"],
517
+ "node": info["node"],
518
+ "length_literal": info["length_literal"],
519
+ }
520
+ self._lazy_source_clock_by_node = clocks
521
+
522
+ def _lazy_source_clock_expr(self, site: "TACallSite", node) -> str:
523
+ """Lower a reached change/mom/roc site through its hold-last source clock."""
524
+ info = self._lazy_source_clock_by_node[id(node)]
525
+ source = self._visit_expr(site.compute_args[0])
526
+ length_node = self._lazy_source_clock_length_node(node)
527
+ literal = self._lazy_source_clock_length_literal(length_node)
528
+ chart = info["chart"]
529
+ if literal is not None:
530
+ length_expr = str(literal)
531
+ held = f"{info['hist']}[{literal - 1}]" if literal >= 1 else "na<double>()"
532
+ eager = f"{chart}[{literal}]" if chart is not None else held
533
+ else:
534
+ length_expr = f"(int)({self._visit_expr(length_node)})"
535
+ held = (
536
+ f"(({length_expr}) >= 1 ? {info['hist']}[({length_expr}) - 1] "
537
+ f": na<double>())"
538
+ )
539
+ eager = f"{chart}[{length_expr}]" if chart is not None else held
540
+ previous = (
541
+ f"{info['clock']}.previous_source({held}, {eager}, {length_expr}, "
542
+ f"bar_index_)"
563
543
  )
544
+ method = "roc" if self._ta_name_from_site(site) == "roc" else "change"
545
+ return f"{info['clock']}.{method}({source}, {previous})"
564
546
 
565
- def _emit_lazy_saturated_roc3_helper(self, lines: list[str]) -> None:
547
+ def _emit_lazy_source_clock_helper(self, lines: list[str]) -> None:
566
548
  """Emit the value-copyable generated runtime helper when needed."""
567
- self._prepare_lazy_saturated_roc3_sites()
568
- if not self._lazy_saturated_roc3_clock_by_node:
549
+ self._prepare_lazy_source_clock_sites()
550
+ if not self._lazy_source_clock_by_node:
569
551
  return
570
- type_name = self._lazy_saturated_roc3_type_name
552
+ type_name = self._lazy_source_clock_type_name
571
553
  lines.extend(
572
554
  [
555
+ "// TradingView keeps a lazily executed call's `source[k]` history per",
556
+ "// call: the source is written only on bars where the call executes,",
557
+ "// the last executed value is held on the bars it skips, and the",
558
+ "// history is na before the first execution. The paired hist Series",
559
+ "// holds `bar_base_source` (the value committed by earlier bars) once",
560
+ "// per chart bar, so `hist[length - 1]` is the source at the most",
561
+ "// recent execution at or before bar-length. Between two executions",
562
+ "// closer than `length` bars the tapes do not distinguish that read",
563
+ "// from the eager chart `source[length]`, so the #64 eager read is",
564
+ "// kept there for chart-builtin sources.",
573
565
  f"struct {type_name} {{",
574
566
  " double committed_source = na<double>();",
575
567
  " int committed_bar = -1;",
@@ -585,22 +577,40 @@ class TaSiteHelper:
585
577
  " working_bar = -1;",
586
578
  " }",
587
579
  "",
588
- " double evaluate(double source, double eager_previous, int bar) {",
580
+ " // Once per bar before the script body; a same-bar recalculation",
581
+ " // keeps the base frozen so the evaluation stays idempotent.",
582
+ " void begin_bar(int bar) {",
589
583
  " if (working_bar != bar) {",
590
584
  " bar_base_source = committed_source;",
591
585
  " bar_base_bar = committed_bar;",
592
586
  " working_bar = bar;",
593
587
  " }",
594
- " const bool saturated = bar_base_bar >= 0 &&",
595
- " bar - bar_base_bar >= 3;",
596
- " const double previous = saturated ? bar_base_source : eager_previous;",
597
- " double result = na<double>();",
598
- " if (!is_na(source) && !is_na(previous) && previous != 0.0) {",
599
- " result = (source - previous) / previous * 100.0;",
588
+ " }",
589
+ "",
590
+ " double previous_source(double held, double eager, int length,",
591
+ " int bar) const {",
592
+ " if (bar_base_bar < 0 || length < 1) {",
593
+ " return na<double>();",
594
+ " }",
595
+ " return bar - bar_base_bar >= length ? held : eager;",
596
+ " }",
597
+ "",
598
+ " double change(double source, double previous) {",
599
+ " committed_source = source;",
600
+ " committed_bar = working_bar;",
601
+ " if (is_na(source) || is_na(previous)) {",
602
+ " return na<double>();",
600
603
  " }",
604
+ " return source - previous;",
605
+ " }",
606
+ "",
607
+ " double roc(double source, double previous) {",
601
608
  " committed_source = source;",
602
- " committed_bar = bar;",
603
- " return result;",
609
+ " committed_bar = working_bar;",
610
+ " if (is_na(source) || is_na(previous) || previous == 0.0) {",
611
+ " return na<double>();",
612
+ " }",
613
+ " return (source - previous) / previous * 100.0;",
604
614
  " }",
605
615
  "};",
606
616
  "",
@@ -618,16 +628,39 @@ class TaSiteHelper:
618
628
  all-``na``. Opting that site out preserves correctness; it simply uses
619
629
  the ordinary stateful TA object during ``on_bar``.
620
630
 
621
- A chart-context ``ta.sma`` nested under an ``and`` RHS is also opted
622
- out: precalc advances it every bar, which is eager and TV-incorrect for
623
- the pinned dual-volume-SMA case. Recursive chart ``ta.ema`` sites below
624
- any Pine-v6 lazy edge follow the same call-clock rule. Other TA families
631
+ A site hoisted to unconditional per-bar evaluation (top-level lazy
632
+ ``and``/``or`` RHS or ternary arm -- see
633
+ ``_lazy_edge_ta_hoist_plan``) advances on every chart bar by the pinned
634
+ TV rule, which is exactly what precalc computes; the lazy-edge opt-outs
635
+ below therefore never apply to it. A top-level lazy-edge site routed to
636
+ the hold-last source clock (change/mom/roc) or left on its
637
+ per-execution inline compute (cum/barssince/valuewhen/cross*/rising/...)
638
+ must never precalc.
639
+
640
+ Re-pinned 2026-09-03: the old opt-out for a chart ``ta.sma`` under an
641
+ ``and`` RHS (pf-probe-oliver-dual-vol-sma, "eager precalc is
642
+ TV-incorrect") and the matching recursive ``ta.ema`` rule encoded a
643
+ per-call clock that ``lab tv`` refuted for top-level shapes (NYSE:F 1D,
644
+ ``... and close > ta.sma(close,5)[1]``: every-bar 25/25 vs per-call 28;
645
+ ``ta.ema``: 23/23 vs 27). The analyzer never marks a site inside an
646
+ ``if``/loop/function scope static, so after hoisting the opt-outs
647
+ only reach a static lazy-edge ``ta.sma``/``ta.ema`` that is not a
648
+ top-level statement operand -- in practice a UDT field default
649
+ (``test_ta_precalc_walks_type_field_defaults``), whose clock is the
650
+ ``Type.new()`` call site's and may sit in a block. Other TA families
625
651
  and request.security sites retain their existing behavior."""
626
652
  if not getattr(site, "is_static", False):
627
653
  return False
628
- if self._is_lazy_saturated_roc3_site(site):
629
- return False
630
654
  node = getattr(site, "node", None)
655
+ plan = self._lazy_edge_ta_hoist_plan()
656
+ if node is not None and (
657
+ id(node) in plan["source_clock"] or id(node) in plan["per_execution_nodes"]
658
+ ):
659
+ # Hold-last / per-execution clocks: precalc would advance the site
660
+ # on every bar, which the tapes refute for these families.
661
+ return False
662
+ if node is not None and id(node) in plan["call_nodes"]:
663
+ return all(self._expr_safe_for_ta_precalc(arg) for arg in site.compute_args)
631
664
  ta_name = self._ta_name_from_site(site)
632
665
  if (
633
666
  ta_name == "sma"
@@ -643,6 +676,264 @@ class TaSiteHelper:
643
676
  return False
644
677
  return all(self._expr_safe_for_ta_precalc(arg) for arg in site.compute_args)
645
678
 
679
+ # ------------------------------------------------------------------
680
+ # Every-bar hoisting of stateful ``ta.*`` sites below top-level lazy edges
681
+ # ------------------------------------------------------------------
682
+ #
683
+ # TradingView rule, pinned 2026-09-03 with ``lab tv`` on NYSE:F 1D (tapes
684
+ # out-pin-ring-lazyand / lazyand-sma / lazyand-ema / ring-ternary): a
685
+ # stateful ``ta.*`` call inside ANY expression operand of a top-level
686
+ # statement -- the RHS of a Pine-v6 lazy ``and``/``or``, either branch of a
687
+ # ternary, nested comparisons -- is evaluated on EVERY bar. Short-circuiting
688
+ # and branch selection gate only the *value*, never the built-in's state,
689
+ # and ``[1]`` on such a call is the previous BAR's value:
690
+ #
691
+ # c = bar_index % 7 == 3 and close > ta.highest(high,5)[1] 9/9 entries
692
+ # c = bar_index % 7 == 3 and close > ta.sma(close,5)[1] 25/25
693
+ # c = bar_index % 7 == 3 and close > ta.ema(close,5)[1] 23/23
694
+ # v = bar_index % 7 == 3 ? ta.highest(high,5)[1] : na 38/39
695
+ #
696
+ # (the per-call / "previous evaluation" model predicts 8/9, 28, 27 and
697
+ # 2/39). Production instance: robmagnaye14 ``bullMSS = setupAlive and
698
+ # dir == 1 and close > ta.highest(high, 10)[1]``.
699
+ #
700
+ # A stateful call INSIDE an ``if``/local-block/function body that does not
701
+ # execute every bar IS execution-gated on TV (pinned separately) and keeps
702
+ # the in-block compute + ``_hist_call_*`` push lowering untouched.
703
+ #
704
+ # Lowering: every eligible site below a lazy edge of a top-level statement
705
+ # is evaluated once, unconditionally, in a ``const auto _pf_every_bar_ta_N``
706
+ # local emitted BEFORE the statement (in dynamic mode too -- this is
707
+ # independent of ``_use_precalc``). A direct ``[k]`` on the hoisted call
708
+ # pushes its ``_hist_call_*`` Series there as well, so ``[1]`` reads the
709
+ # previous chart bar. The statement's expression then reads the local /
710
+ # the Series instead of stepping the indicator when the operand is reached.
711
+
712
+ _LAZY_EDGE_HOIST_NAME_PREFIX = "_pf_every_bar_ta_"
713
+
714
+ def _lazy_edge_hoist_block_reason(
715
+ self, site: "TACallSite", history_read: bool = False
716
+ ) -> str | None:
717
+ """Why an otherwise-eligible lazy-edge site is left on its lowering."""
718
+ if getattr(site, "owner_func", None) is not None:
719
+ return "site belongs to a user function body"
720
+ if getattr(site, "returns_tuple", False):
721
+ return "tuple-returning site"
722
+ family = self._ta_name_from_site(site)
723
+ if family in self.LAZY_PER_EXECUTION_TA:
724
+ return (
725
+ "per-execution native: the reached-only inline compute is "
726
+ "TradingView's clock (lab tv 2026-09-03)"
727
+ )
728
+ if family in self.LAZY_SOURCE_CLOCK_TA:
729
+ return "hold-last source-clock family with a non-numeric source"
730
+ if family not in self.LAZY_EVERY_BAR_TA:
731
+ return self.UNPINNED_LAZY_EDGE_REASON
732
+ if not history_read:
733
+ return self.NO_HISTORY_READ_REASON
734
+ return None
735
+
736
+ def _lazy_edge_ta_hoist_plan(self) -> dict:
737
+ """Plan (once) which top-level lazy-edge TA sites are hoisted.
738
+
739
+ Returns ``{"by_stmt": {id(stmt): [unit, ...]}, "call_nodes": set,
740
+ "source_clock": {id(node): {"node", "site", "length_literal"}},
741
+ "per_execution_nodes": set, "skipped": [(node, site, reason), ...]}``.
742
+ Only ``LAZY_EVERY_BAR_TA`` families whose call carries a direct
743
+ ``[k]`` history read (``Subscript`` on the call) become hoist units.
744
+ ``source_clock`` sites (change/mom/roc) lower through
745
+ ``_lazy_source_clock_expr``; ``per_execution_nodes`` keep the inline
746
+ compute; both are opted out of precalc. Every other family is listed
747
+ in ``skipped`` with ``UNPINNED_LAZY_EDGE_REASON`` and keeps its
748
+ existing lowering. A hoist unit is either
749
+ ``{"kind": "call", "node": FuncCall, "site": TACallSite, "name": str}``
750
+ or ``{"kind": "hist", "node": Subscript}`` (a direct ``[k]`` on a hoisted
751
+ call). Units are in evaluation order: a nested hoisted call precedes the
752
+ call whose argument it is, and a ``hist`` unit follows its call.
753
+
754
+ Only top-level statement expressions are scanned (``VarDecl`` values
755
+ except ``var``/``varip`` initializers, ``Assignment`` target/value,
756
+ ``TupleAssign`` value, ``ExprStmt`` expression, and the head condition
757
+ of an ``IfStmt``). ``if``/``for``/``while``/``switch`` bodies, ``else
758
+ if`` conditions (Pine's ``else if`` is an ``if`` inside the ``else``
759
+ local block) and user-function bodies are never hoisted. Inside an
760
+ expression, the walk descends into every operand and call argument
761
+ except a ``request.security*`` payload, which its own evaluator runs.
762
+ Block-local arguments cannot occur at top level, so the skip list only
763
+ carries the shapes named in ``_lazy_edge_hoist_block_reason``.
764
+ """
765
+ cached = getattr(self, "_lazy_edge_hoist_plan_cache", None)
766
+ if cached is not None:
767
+ return cached
768
+
769
+ by_stmt: dict[int, list[dict]] = {}
770
+ call_nodes: set[int] = set()
771
+ source_clock: dict[int, dict] = {}
772
+ per_execution_nodes: set[int] = set()
773
+ skipped: list[tuple] = []
774
+ counter = [0]
775
+
776
+ def scan(
777
+ expr, under_lazy: bool, units: list[dict], history_read: bool = False
778
+ ) -> None:
779
+ if expr is None:
780
+ return
781
+ if isinstance(expr, FuncCall):
782
+ callee = expr.callee
783
+ is_security = (
784
+ isinstance(callee, MemberAccess)
785
+ and isinstance(callee.object, Identifier)
786
+ and callee.object.name == "request"
787
+ and callee.member in ("security", "security_lower_tf")
788
+ )
789
+ # Chained receivers (``label.new(...).get_y()``) are evaluated
790
+ # expressions too; namespace/identifier callees are leaves.
791
+ scan(callee, under_lazy, units)
792
+ for idx, arg in enumerate(getattr(expr, "args", ()) or ()):
793
+ if is_security and idx == 2:
794
+ continue
795
+ scan(arg, under_lazy, units)
796
+ for key, value in (getattr(expr, "kwargs", None) or {}).items():
797
+ if is_security and key == "expression":
798
+ continue
799
+ scan(value, under_lazy, units)
800
+ if not under_lazy:
801
+ return
802
+ site = self._get_ta_site(expr)
803
+ if site is None:
804
+ return
805
+ family = self._ta_name_from_site(site)
806
+ if family in self.LAZY_SOURCE_CLOCK_TA and self._lazy_source_clock_eligible(site):
807
+ source_clock[id(expr)] = {
808
+ "node": expr,
809
+ "site": site,
810
+ "length_literal": self._lazy_source_clock_length_literal(
811
+ self._lazy_source_clock_length_node(expr)
812
+ ),
813
+ }
814
+ return
815
+ reason = self._lazy_edge_hoist_block_reason(site, history_read)
816
+ if reason is not None:
817
+ if family in self.LAZY_PER_EXECUTION_TA:
818
+ per_execution_nodes.add(id(expr))
819
+ skipped.append((expr, site, reason))
820
+ return
821
+ counter[0] += 1
822
+ units.append({
823
+ "kind": "call",
824
+ "node": expr,
825
+ "site": site,
826
+ "name": f"{self._LAZY_EDGE_HOIST_NAME_PREFIX}{counter[0]}",
827
+ })
828
+ call_nodes.add(id(expr))
829
+ return
830
+ if isinstance(expr, BinOp):
831
+ if expr.op in ("and", "or"):
832
+ scan(expr.left, under_lazy, units)
833
+ scan(expr.right, True, units)
834
+ else:
835
+ scan(expr.left, under_lazy, units)
836
+ scan(expr.right, under_lazy, units)
837
+ return
838
+ if isinstance(expr, Ternary):
839
+ scan(expr.condition, under_lazy, units)
840
+ scan(expr.true_val, True, units)
841
+ scan(expr.false_val, True, units)
842
+ return
843
+ if isinstance(expr, UnaryOp):
844
+ scan(expr.operand, under_lazy, units)
845
+ return
846
+ if isinstance(expr, Subscript):
847
+ # ``call(...)[k]``: the call's own history is referenced.
848
+ scan(expr.object, under_lazy, units, history_read=True)
849
+ scan(expr.index, under_lazy, units)
850
+ if isinstance(expr.object, FuncCall) and id(expr.object) in call_nodes:
851
+ units.append({"kind": "hist", "node": expr})
852
+ return
853
+ if isinstance(expr, MemberAccess):
854
+ scan(expr.object, under_lazy, units)
855
+ return
856
+ if isinstance(expr, TupleLiteral):
857
+ for element in expr.elements:
858
+ scan(element, under_lazy, units)
859
+ return
860
+ # ``x = if cond ... else ...`` / ``x = switch ...`` value forms: the
861
+ # head expression is top-level, the bodies are local blocks.
862
+ if isinstance(expr, IfStmt):
863
+ scan(expr.condition, under_lazy, units)
864
+ return
865
+ if isinstance(expr, SwitchStmt):
866
+ scan(expr.expr, under_lazy, units)
867
+ return
868
+ # Literals, identifiers and anything else are leaves.
869
+
870
+ def roots(stmt) -> list:
871
+ if isinstance(stmt, VarDecl):
872
+ if stmt.is_var or stmt.is_varip:
873
+ return []
874
+ return [stmt.value]
875
+ if isinstance(stmt, Assignment):
876
+ return [stmt.target, stmt.value]
877
+ if isinstance(stmt, TupleAssign):
878
+ return [stmt.value]
879
+ if isinstance(stmt, ExprStmt):
880
+ return [stmt.expr]
881
+ if isinstance(stmt, IfStmt):
882
+ return [stmt.condition]
883
+ return []
884
+
885
+ ast = getattr(self.ctx, "ast", None)
886
+ for stmt in getattr(ast, "body", ()) or ():
887
+ units: list[dict] = []
888
+ for root in roots(stmt):
889
+ scan(root, False, units)
890
+ if units:
891
+ by_stmt[id(stmt)] = units
892
+
893
+ plan = {
894
+ "by_stmt": by_stmt,
895
+ "call_nodes": call_nodes,
896
+ "source_clock": source_clock,
897
+ "per_execution_nodes": per_execution_nodes,
898
+ "skipped": skipped,
899
+ }
900
+ self._lazy_edge_hoist_plan_cache = plan
901
+ return plan
902
+
903
+ def _lazy_edge_hoisted_ta_call_nodes(self) -> set[int]:
904
+ return self._lazy_edge_ta_hoist_plan()["call_nodes"]
905
+
906
+ def _emit_lazy_edge_ta_hoists(self, stmt, lines: list[str], indent: int) -> None:
907
+ """Emit the every-bar evaluations a top-level statement depends on.
908
+
909
+ Must be paired with ``_clear_lazy_edge_ta_hoists`` after the statement
910
+ is visited: the maps make ``_visit_func_call`` / ``_visit_subscript``
911
+ read the hoisted local / Series while the statement is lowered.
912
+ """
913
+ units = self._lazy_edge_ta_hoist_plan()["by_stmt"].get(id(stmt))
914
+ if not units:
915
+ return
916
+ pad = " " * indent
917
+ lines.append(
918
+ f"{pad}// Pine v6 lazy operand: TA state advances every bar, only the "
919
+ "value is gated."
920
+ )
921
+ for unit in units:
922
+ node = unit["node"]
923
+ if unit["kind"] == "call":
924
+ rendered = self._visit_expr(node)
925
+ lines.append(f"{pad}const auto {unit['name']} = {rendered};")
926
+ self._hoisted_ta_values[id(node)] = unit["name"]
927
+ else:
928
+ member = self._inline_history_member("hist_call", node)
929
+ value = self._hoisted_ta_values[id(node.object)]
930
+ self._emit_history_series_write(lines, pad, member, value)
931
+ self._hoisted_hist_reads[id(node)] = member
932
+
933
+ def _clear_lazy_edge_ta_hoists(self) -> None:
934
+ self._hoisted_ta_values.clear()
935
+ self._hoisted_hist_reads.clear()
936
+
646
937
  def _security_ta_compute_args_for_site(
647
938
  self,
648
939
  sec_id: int,