@pineforge/codegen-pyodide 0.10.0 → 0.10.2

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.
@@ -1,8 +1,7 @@
1
1
  """TA (technical analysis) call-site helpers for the codegen.
2
2
 
3
3
  Holds the eval-free TA helpers: call-site lookup, ``.compute()`` arg
4
- construction, the TA-hoisting machinery for if-bodies, and a small
5
- ``_is_compile_time_value`` predicate. The runtime-reset chain that
4
+ construction, and a small ``_is_compile_time_value`` predicate. The runtime-reset chain that
6
5
  depends on Python's compile-time expression evaluator
7
6
  (``_resolve_known`` / ``_runtime_ctor_arg_for_reset`` /
8
7
  ``_collect_ta_runtime_resets`` / ``_emit_ta_runtime_reset``) stays
@@ -13,8 +12,6 @@ Mixin contract — host class must provide:
13
12
 
14
13
  - ``self._ta_site_map`` (``dict[int, TACallSite]``).
15
14
  - ``self._active_ta_remap`` (``dict[str, str] | None``).
16
- - ``self._hoist_var_counter`` (``int``, optional — auto-managed).
17
-
18
15
  Sibling-mixin methods consumed via ``self``:
19
16
 
20
17
  - ``self._visit_expr`` / ``self._visit_stmt`` (visitor mixins, currently
@@ -29,8 +26,9 @@ from typing import TYPE_CHECKING
29
26
 
30
27
  from ..ast_nodes import (
31
28
  Assignment, BinOp, BoolLiteral, ColorLiteral, ExprStmt, FuncCall,
32
- Identifier, MemberAccess, NaLiteral, NumberLiteral, StringLiteral,
33
- Subscript, Ternary, TupleLiteral, UnaryOp, VarDecl,
29
+ ForInStmt, ForStmt, FuncDef, Identifier, MemberAccess, NaLiteral,
30
+ NumberLiteral, StringLiteral, Subscript, Ternary, TupleAssign,
31
+ TupleLiteral, TypeDecl, UnaryOp, VarDecl,
34
32
  )
35
33
  from .tables import TA_IMPLICIT_APPEND, TA_IMPLICIT_COMPUTE_FULL
36
34
 
@@ -39,7 +37,7 @@ if TYPE_CHECKING:
39
37
 
40
38
 
41
39
  class TaSiteHelper:
42
- """TA call-site lookups, .compute() argument construction, and TA hoisting in if-bodies."""
40
+ """TA call-site lookups and ``.compute()`` argument construction."""
43
41
 
44
42
  # TA functions where an explicit source argument REPLACES the implicit
45
43
  # bar-data default (vs. ATR / supertrend / DMI where bar OHLC must
@@ -92,97 +90,6 @@ class TaSiteHelper:
92
90
  f"'_ta_<name>_<n>' convention. Internal codegen bug."
93
91
  )
94
92
 
95
- # ------------------------------------------------------------------
96
- # TA hoisting in if-bodies (computations unconditional, result conditional)
97
- # ------------------------------------------------------------------
98
-
99
- def _if_body_has_ta(self, stmts: list) -> bool:
100
- """True if any statement in ``stmts`` references a TA call-site (recursively)."""
101
- for s in stmts:
102
- if isinstance(s, VarDecl) and s.value is not None:
103
- if self._expr_contains_ta(s.value):
104
- return True
105
- if isinstance(s, Assignment) and hasattr(s, "value"):
106
- if self._expr_contains_ta(s.value):
107
- return True
108
- if isinstance(s, ExprStmt):
109
- if self._expr_contains_ta(s.expr):
110
- return True
111
- return False
112
-
113
- def _is_result_assignment(self, stmt) -> bool:
114
- """True iff ``stmt`` is an assignment to the synthetic ``result`` variable.
115
-
116
- ``result`` is the function-body return target injected when a Pine
117
- function body becomes a C++ function-call site; assignments to it
118
- carry semantic weight in TA hoisting (they are the conditional-emit
119
- targets)."""
120
- if isinstance(stmt, Assignment):
121
- target_name = self._get_target_name(stmt.target)
122
- if target_name == "result":
123
- return True
124
- return False
125
-
126
- def _expr_contains_ta(self, expr) -> bool:
127
- """Recursive check: does any subnode of ``expr`` resolve to a TA site?"""
128
- if expr is None:
129
- return False
130
- if self._get_ta_site(expr) is not None:
131
- return True
132
- if isinstance(expr, BinOp):
133
- return self._expr_contains_ta(expr.left) or self._expr_contains_ta(expr.right)
134
- if isinstance(expr, UnaryOp):
135
- return self._expr_contains_ta(expr.operand)
136
- if isinstance(expr, Ternary):
137
- return (self._expr_contains_ta(expr.true_val)
138
- or self._expr_contains_ta(expr.false_val))
139
- if isinstance(expr, FuncCall):
140
- return any(self._expr_contains_ta(a) for a in expr.args)
141
- return False
142
-
143
- def _hoist_if_body(self, stmts: list, cond: str, lines: list[str], pad: str, indent: int) -> None:
144
- """Emit an if-body with TA hoisting.
145
-
146
- Pine evaluates TA on every bar regardless of branch; C++ TA
147
- instances must compute() unconditionally to keep their state
148
- in sync. We split each result-assignment whose RHS contains a
149
- TA call into:
150
-
151
- - an unconditional ``double _hoist_<n> = <rhs>;`` line,
152
- - a conditional ``if (<cond>) { result = _hoist_<n>; }``.
153
-
154
- Non-result statements are emitted unconditionally inside an
155
- opening scope block; result assignments without a TA reference
156
- stay fully conditional."""
157
- lines.append(f"{pad}{{")
158
- conditional_stmts: list = []
159
- _hoist_counter = getattr(self, "_hoist_var_counter", 0)
160
-
161
- for s in stmts:
162
- if self._is_result_assignment(s):
163
- rhs = s.value if hasattr(s, "value") else None
164
- if rhs is not None and self._expr_contains_ta(rhs):
165
- _hoist_counter += 1
166
- tmp_var = f"_hoist_{_hoist_counter}"
167
- compute_expr = self._visit_expr(rhs)
168
- lines.append(f"{pad} double {tmp_var} = {compute_expr};")
169
- conditional_stmts.append(("result", tmp_var))
170
- else:
171
- conditional_stmts.append(("stmt", s))
172
- else:
173
- self._visit_stmt(s, lines, indent + 1)
174
-
175
- if conditional_stmts:
176
- lines.append(f"{pad} if ({cond}) {{")
177
- for item in conditional_stmts:
178
- if item[0] == "result":
179
- lines.append(f"{pad} result = {item[1]};")
180
- else:
181
- self._visit_stmt(item[1], lines, indent + 2)
182
- lines.append(f"{pad} }}")
183
- lines.append(f"{pad}}}")
184
- self._hoist_var_counter = _hoist_counter
185
-
186
93
  # ------------------------------------------------------------------
187
94
  # .compute() arg-string construction
188
95
  # ------------------------------------------------------------------
@@ -293,6 +200,413 @@ class TaSiteHelper:
293
200
  return False
294
201
  return False
295
202
 
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.
215
+ """
216
+ cached = getattr(self, "_lazy_scope_ta_call_nodes", None)
217
+ if cached is not None:
218
+ return cached
219
+
220
+ and_rhs: set[int] = set()
221
+ lazy_rhs: set[int] = set()
222
+ plain_and_rhs: set[int] = set()
223
+
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:
231
+ return
232
+ if isinstance(expr, FuncCall):
233
+ callee = expr.callee
234
+ is_security = (
235
+ isinstance(callee, MemberAccess)
236
+ and isinstance(callee.object, Identifier)
237
+ and callee.object.name == "request"
238
+ and callee.member in ("security", "security_lower_tf")
239
+ )
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.
266
+ if is_security and idx == 2:
267
+ 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():
275
+ if is_security and key == "expression":
276
+ 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
+ )
368
+ 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)
372
+ return
373
+ if isinstance(stmt, TupleAssign):
374
+ note_ta_calls(getattr(stmt, "value", None), False, False, False)
375
+ 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
+ )
381
+ 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)
400
+
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).
406
+ 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)
412
+
413
+ result = (and_rhs, lazy_rhs, plain_and_rhs)
414
+ self._lazy_scope_ta_call_nodes = result
415
+ return result
416
+
417
+ def _ta_call_nodes_under_and_rhs(self) -> set[int]:
418
+ return self._ta_call_nodes_by_lazy_scope()[0]
419
+
420
+ def _ta_call_nodes_under_lazy_rhs(self) -> set[int]:
421
+ return self._ta_call_nodes_by_lazy_scope()[1]
422
+
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
440
+
441
+ def _script_has_user_binding(self, name: str) -> bool:
442
+ cache = getattr(self, "_user_binding_names", None)
443
+ if cache is None:
444
+ cache = set()
445
+ ast = getattr(self.ctx, "ast", None)
446
+ for node in self._walk_ast(ast):
447
+ if isinstance(node, VarDecl):
448
+ cache.add(node.name)
449
+ elif isinstance(node, TupleAssign):
450
+ cache.update(node.names)
451
+ elif isinstance(node, ForStmt):
452
+ cache.add(node.var)
453
+ elif isinstance(node, ForInStmt):
454
+ if node.var:
455
+ cache.add(node.var)
456
+ cache.update(node.vars or ())
457
+ elif isinstance(node, FuncDef):
458
+ cache.add(node.name)
459
+ cache.update(node.params)
460
+ self._user_binding_names = cache
461
+ return name in cache
462
+
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.
475
+ """
476
+ node = getattr(site, "node", None)
477
+ 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
487
+ ):
488
+ 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
+ )
497
+
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"):
501
+ return
502
+
503
+ def allocate(base: str, reserved: set[str]) -> str:
504
+ candidate = base
505
+ suffix = 2
506
+ while candidate in reserved:
507
+ candidate = f"{base}_{suffix}"
508
+ suffix += 1
509
+ reserved.add(candidate)
510
+ return candidate
511
+
512
+ # Pine permits leading-underscore identifiers. Reserve every emitted
513
+ # user member, not only history/var members already tracked by
514
+ # _all_member_names, before minting generated support names.
515
+ reserved_members = set(getattr(self, "_all_member_names", set()))
516
+ reserved_members.update(
517
+ self._safe_name(name)
518
+ for name, _ptype in getattr(self.ctx, "global_var_decls", ())
519
+ )
520
+ reserved_members.update(
521
+ self._safe_name(name)
522
+ for name, _ptype, _init in getattr(self.ctx, "var_members", ())
523
+ )
524
+ reserved_types = set(getattr(self, "_udt_defs", {}))
525
+ for info in getattr(self.ctx, "func_infos", ()):
526
+ emitted = self._func_cpp_base_name(info.name)
527
+ reserved_members.add(emitted)
528
+ reserved_types.add(emitted)
529
+ total = self.ctx.func_call_site_counts.get(info.name, 0)
530
+ for callsite in range(total):
531
+ reserved_members.add(f"{emitted}_cs{callsite}")
532
+ for instance in getattr(self, "_fresh_instances", ()):
533
+ 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_)"
563
+ )
564
+
565
+ def _emit_lazy_saturated_roc3_helper(self, lines: list[str]) -> None:
566
+ """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:
569
+ return
570
+ type_name = self._lazy_saturated_roc3_type_name
571
+ lines.extend(
572
+ [
573
+ f"struct {type_name} {{",
574
+ " double committed_source = na<double>();",
575
+ " int committed_bar = -1;",
576
+ " double bar_base_source = na<double>();",
577
+ " int bar_base_bar = -1;",
578
+ " int working_bar = -1;",
579
+ "",
580
+ " void reset() {",
581
+ " committed_source = na<double>();",
582
+ " committed_bar = -1;",
583
+ " bar_base_source = na<double>();",
584
+ " bar_base_bar = -1;",
585
+ " working_bar = -1;",
586
+ " }",
587
+ "",
588
+ " double evaluate(double source, double eager_previous, int bar) {",
589
+ " if (working_bar != bar) {",
590
+ " bar_base_source = committed_source;",
591
+ " bar_base_bar = committed_bar;",
592
+ " working_bar = bar;",
593
+ " }",
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;",
600
+ " }",
601
+ " committed_source = source;",
602
+ " committed_bar = bar;",
603
+ " return result;",
604
+ " }",
605
+ "};",
606
+ "",
607
+ ]
608
+ )
609
+
296
610
  def _ta_site_uses_precalc(self, site: "TACallSite") -> bool:
297
611
  """Whether a static TA site can safely read from ``_precalc_*``.
298
612
 
@@ -302,9 +616,31 @@ class TaSiteHelper:
302
616
  ``ha_close = close`` is static in that analyzer sense, yet its Series is
303
617
  empty during precompute, so ``ta.stdev(ha_close, 20)`` precalculates as
304
618
  all-``na``. Opting that site out preserves correctness; it simply uses
305
- the ordinary stateful TA object during ``on_bar``."""
619
+ the ordinary stateful TA object during ``on_bar``.
620
+
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
625
+ and request.security sites retain their existing behavior."""
306
626
  if not getattr(site, "is_static", False):
307
627
  return False
628
+ if self._is_lazy_saturated_roc3_site(site):
629
+ return False
630
+ node = getattr(site, "node", None)
631
+ ta_name = self._ta_name_from_site(site)
632
+ if (
633
+ ta_name == "sma"
634
+ and node is not None
635
+ and id(node) in self._ta_call_nodes_under_and_rhs()
636
+ ):
637
+ return False
638
+ if (
639
+ ta_name == "ema"
640
+ and node is not None
641
+ and id(node) in self._ta_call_nodes_under_lazy_rhs()
642
+ ):
643
+ return False
308
644
  return all(self._expr_safe_for_ta_precalc(arg) for arg in site.compute_args)
309
645
 
310
646
  def _security_ta_compute_args_for_site(