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