diff --git a/AGENTS.md b/AGENTS.md index f839737..4353d9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,6 +301,40 @@ you delete or weaken the special case, the test will tell you. in Pine v6; both lack `direction`. `TRADE_ACCESSOR_METHODS` is kept as the union for back-compat but new code should prefer the side- specific constant. +9. **Top-level lazy-edge `ta.*` sites whose history is read are hoisted to + every-bar evaluation.** TradingView (pinned 2026-09-03 with `lab tv`, + NYSE:F 1D) advances a stateful `ta.*` call on EVERY bar when it sits below + a Pine-v6 lazy `and`/`or` RHS or a ternary arm of a top-level statement + AND the call's own history is referenced (`ta.sma(close, 5)[1]`; the + bare twins of every tape are per-execution); + short-circuiting gates only the value, and `[1]` on it is the previous + BAR. Without a `[k]` read the reached-only inline compute is TV's clock + (oliver1002 / louislapis9 / ycelestine77 / quantbyboji / miemomo3 exact at + 100% on it, 2026-09-04). For a `[k]`-read site codegen emits + `const auto _pf_every_bar_ta_N = ;` (plus the site's `_hist_call_*` + push for a direct `[k]`) BEFORE the statement, in dynamic mode too + (`codegen/ta.py::_lazy_edge_ta_hoist_plan`, `_emit_lazy_edge_ta_hoists`; + `tests/test_lazy_edge_ta_every_bar.py`). The rule is per family + (cadence-7 ternary/lazy-and probes, same tapes) and the hoist is an + ALLOW-LIST (`LAZY_EVERY_BAR_TA` = highest/lowest/sma/ema) gated on the + `[k]` read: a broad hoist of every family cost 170 tiers / 30 hard lanes + on Cloud Run (2026-09-04), so an unpinned family keeps its existing + lowering until a tape pins it. `change`/`mom`/`roc` (`LAZY_SOURCE_CLOCK_TA`) read the + call's OWN held `source[length]` -- written only when the call executes, + held on skipped bars, na before the first execution -- through the + generated `_PFLazySourceClock` + `_pf_lazy_src_hist_N` members + (`tests/test_lazy_source_clock*.py`; this replaced the #64 roc3-only + clock, whose eager first-execution fallback the tapes refute; its eager + chart `source[length]` read between executions closer than `length` bars + is kept for chart-builtin sources via `_pf_lazy_src_chart_N`); + `cum`/`barssince`/`valuewhen`/`cross*`/`rising`/`falling`/`math.sum` + (`LAZY_PER_EXECUTION_TA`) keep the reached-only inline compute, which is + TradingView's per-execution clock, and never precalc. + Sites inside `if`/loop/function bodies, `else if` conditions, `var` + initializers, `request.security` payloads and tuple-returning sites keep + their existing lowering. The old "lazy SMA/EMA must not precalc" pins + (pf-probe-oliver-dual-vol-sma) encoded the refuted per-call clock and were + re-pinned in `test_codegen_validation_fixes.py`. ## How to add a new Pine v6 function diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 3c849ae..e3eed5f 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -310,6 +310,11 @@ def func_var_storage(owner: str, raw_name: str) -> str: # Set of var/series member names that belong to user functions (need cloning) self._func_var_members_set: set[str] = set() self._precalc_loop_active: bool = False + # Top-level lazy-edge TA sites hoisted to every-bar evaluation for the + # statement currently being lowered: FuncCall id -> local name, and + # Subscript id -> ``_hist_call_*`` member (see ``ta.py``). + self._hoisted_ta_values: dict[int, str] = {} + self._hoisted_hist_reads: dict[int, str] = {} # Names of ``var`` members that live in a callable scope (not global). # Their exact declaration statements own initialization; they must not # be initialized by the constructor or the global on_bar preamble. @@ -3802,7 +3807,7 @@ def generate(self) -> str: # request.security helper-call results read at a history offset # (``myHelper()[k]``). Maps (sec_id, node-id) -> backing Series metadata. self._security_expr_hist_by_node: dict[tuple[int, int], dict] = {} - self._prepare_lazy_saturated_roc3_sites() + self._prepare_lazy_source_clock_sites() lines: list[str] = [] @@ -4035,11 +4040,11 @@ def generate(self) -> str: ) lines.append("") - # Source-shaped lazy ROC call clocks are generated support types, not - # script state themselves. Their per-callsite instances are declared - # below inside GeneratedStrategy and therefore join the automatic COOF - # checkpoint inventory. - self._emit_lazy_saturated_roc3_helper(lines) + # Hold-last source clocks (change/mom/roc below a top-level lazy edge) + # are generated support types, not script state themselves. Their + # per-callsite instances are declared below inside GeneratedStrategy + # and therefore join the automatic COOF checkpoint inventory. + self._emit_lazy_source_clock_helper(lines) # 2. Open class lines.append("class GeneratedStrategy : public BacktestEngine {") @@ -4195,20 +4200,21 @@ def generate(self) -> str: lines.append(f" std::vector<{vtype}> _precalc_{site.member_name};") lines.append(" bool _use_precalc = false;") - for clock_name in self._lazy_saturated_roc3_clock_by_node.values(): + for info in self._lazy_source_clock_by_node.values(): lines.append( - f" {self._lazy_saturated_roc3_type_name} {clock_name};" - ) - if self._lazy_saturated_roc3_clock_by_node: - # Dedicated eager close[3] fallback. Its fixed four-slot capacity - # is independent of the user's max_bars_back directive, which may - # legitimately be smaller than the offset this generated route - # requires. It is ordinary copyable script state and therefore - # joins the automatic COOF checkpoint below. - lines.append( - " Series " - f"{self._lazy_saturated_roc3_history_name}{{4}};" + f" {self._lazy_source_clock_type_name} {info['clock']};" ) + # Held-source history on the chart clock. A literal length sizes + # it exactly (independent of the user's max_bars_back directive, + # which may legitimately be smaller than this generated route + # needs); a runtime length uses the Series default capacity. Both + # are ordinary copyable script state and join the automatic COOF + # checkpoint below. + literal = info["length_literal"] + capacity = f"{{{literal + 1}}}" if literal is not None and literal >= 1 else "" + lines.append(f" Series {info['hist']}{capacity};") + if info["chart"] is not None: + lines.append(f" Series {info['chart']}{capacity};") # Security evaluator TA members (cloned from expression dependencies) # Skip for user function call expressions — their TA deps are internal to the function diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index b8eb2f9..61e03fd 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -934,28 +934,38 @@ def _emit_on_bar(self, lines: list[str]) -> None: # A GeneratedStrategy handle may execute multiple batch runs or # streaming lifecycles. BacktestEngine resets broker/base state, but - # generated members survive. Reset source-shaped lazy ROC clocks and - # their forced eager-fallback close history at the first genuine slot - # of each lifecycle. This lives in on_bar rather than a generated run - # wrapper because stream_begin() enters the base run path directly. - # COOF post-close recalculations have history_advances_new_bar()==false - # and therefore preserve the current committed clock/base. - if self._lazy_saturated_roc3_clock_by_node: + # generated members survive. Reset the hold-last source clocks + # (change/mom/roc below a top-level lazy edge) and their held-source + # Series at the first genuine slot of each lifecycle, then freeze this + # bar's base and record it once per chart bar. This lives in on_bar + # rather than a generated run wrapper because stream_begin() enters the + # base run path directly. COOF post-close recalculations have + # history_advances_new_bar()==false and therefore keep the same base. + if self._lazy_source_clock_by_node: lines.append( " if (history_advances_new_bar() && bar_index_ == 0) {" ) - for clock_name in self._lazy_saturated_roc3_clock_by_node.values(): - lines.append(f" {clock_name}.reset();") - lines.append( - f" {self._lazy_saturated_roc3_history_name}.clear();" - ) + for info in self._lazy_source_clock_by_node.values(): + lines.append(f" {info['clock']}.reset();") + lines.append(f" {info['hist']}.clear();") + if info["chart"] is not None: + lines.append(f" {info['chart']}.clear();") lines.append(" }") - self._emit_history_series_write( - lines, - " ", - self._lazy_saturated_roc3_history_name, - "current_bar_.close", - ) + for info in self._lazy_source_clock_by_node.values(): + lines.append(f" {info['clock']}.begin_bar(bar_index_);") + self._emit_history_series_write( + lines, + " ", + info["hist"], + f"{info['clock']}.bar_base_source", + ) + if info["chart"] is not None: + self._emit_history_series_write( + lines, + " ", + info["chart"], + self._visit_expr(info["chart_source"]), + ) # reset_run_state() owns engine/broker state, while these generated # Series members belong to the strategy object. Clear all of them on @@ -1218,9 +1228,15 @@ def _emit_on_bar(self, lines: list[str]) -> None: # an input (the default-sized construction already matches Pine). self._emit_ta_runtime_reset(lines, indent=2) - # d. Visit each statement + # d. Visit each statement. A stateful ``ta.*`` site below a lazy + # ``and``/``or`` RHS or ternary arm of a top-level statement is + # evaluated every bar BEFORE the statement (TV rule, see ``ta.py``). for stmt in self.ctx.ast.body: - self._visit_stmt(stmt, lines, indent=2) + self._emit_lazy_edge_ta_hoists(stmt, lines, indent=2) + try: + self._visit_stmt(stmt, lines, indent=2) + finally: + self._clear_lazy_edge_ta_hoists() # e. ``// @pf-trace`` pragma block — emitted last so trace values # reflect every assignment / strategy call made earlier in the diff --git a/pineforge_codegen/codegen/ta.py b/pineforge_codegen/codegen/ta.py index ed31be0..d43f714 100644 --- a/pineforge_codegen/codegen/ta.py +++ b/pineforge_codegen/codegen/ta.py @@ -25,10 +25,11 @@ from typing import TYPE_CHECKING from ..ast_nodes import ( - Assignment, BinOp, BoolLiteral, ColorLiteral, ExprStmt, FuncCall, - ForInStmt, ForStmt, FuncDef, Identifier, MemberAccess, NaLiteral, - NumberLiteral, StringLiteral, Subscript, Ternary, TupleAssign, - TupleLiteral, TypeDecl, UnaryOp, VarDecl, + ASTNode, Assignment, BinOp, BoolLiteral, ColorLiteral, EnumDecl, ExprStmt, + FuncCall, ForInStmt, ForStmt, FuncDef, Identifier, IfStmt, MemberAccess, + MethodDef, NaLiteral, NumberLiteral, StringLiteral, Subscript, SwitchStmt, + Ternary, TupleAssign, TupleLiteral, TypeDecl, TypeField, UnaryOp, VarDecl, + WhileStmt, ) from .tables import TA_IMPLICIT_APPEND, TA_IMPLICIT_COMPUTE_FULL @@ -200,18 +201,26 @@ def _expr_safe_for_ta_precalc(self, expr) -> bool: return False return False - def _ta_call_nodes_by_lazy_scope(self) -> tuple[set[int], set[int], set[int]]: - """Classify chart ``ta.*`` sites below Pine-v6 lazy-expression edges. - - The first set contains sites below an ``and`` RHS, preserving the - already accepted lazy-SMA scope. The second contains sites below any - Pine-v6 lazy edge: an ``and``/``or`` RHS or either ``?:`` branch. The - broader set is consumed only by the recursive-EMA route; - other TA families retain their current precalculation behavior. The - third is the narrow ``and``-only subset: the site is below an ``and`` - RHS and is not nested anywhere inside an ``or`` or ternary expression. - It gives source-shaped lazy call clocks a stable AST identity without - inspecting generated member names. + # ------------------------------------------------------------------ + # Lazy-edge classification in every scope -- consumed by the precalc gate + # ------------------------------------------------------------------ + + _LAZY_SCOPE_STMT_TYPES = ( + VarDecl, Assignment, TupleAssign, ExprStmt, IfStmt, ForStmt, ForInStmt, + WhileStmt, SwitchStmt, FuncDef, MethodDef, TypeDecl, EnumDecl, + ) + + def _ta_call_nodes_by_lazy_scope(self) -> tuple[set[int], set[int]]: + """Classify chart ``ta.*`` call nodes below Pine-v6 lazy edges, everywhere. + + Returns ``(and_rhs, lazy_rhs)``: sites below an ``and`` RHS, and sites + below any lazy edge (an ``and``/``or`` RHS or a ``?:`` arm), walking the + top level, control-flow bodies, user-function bodies and UDT field + defaults. Entering a statement resets both flags, expression nodes + propagate them, and a ``request.security*`` payload is skipped because + its own evaluator lowers it. Consumed only by the block-scope opt-outs + in ``_ta_site_uses_precalc``; top-level statement operands are governed + by ``_lazy_edge_ta_hoist_plan`` instead. """ cached = getattr(self, "_lazy_scope_ta_call_nodes", None) if cached is not None: @@ -219,198 +228,78 @@ def _ta_call_nodes_by_lazy_scope(self) -> tuple[set[int], set[int], set[int]]: and_rhs: set[int] = set() lazy_rhs: set[int] = set() - plain_and_rhs: set[int] = set() - def note_ta_calls( - expr, - under_and_rhs: bool, - under_lazy_rhs: bool, - under_disallowed_shape: bool, - ) -> None: - if expr is None: + def note(value, under_and: bool, under_lazy: bool) -> None: + if value is None: return - if isinstance(expr, FuncCall): - callee = expr.callee + if isinstance(value, (list, tuple)): + for item in value: + note(item, under_and, under_lazy) + return + if isinstance(value, dict): + for item in value.values(): + note(item, under_and, under_lazy) + return + if isinstance(value, TypeField): + note(value.default, False, False) + return + if not isinstance(value, ASTNode): + return + if isinstance(value, self._LAZY_SCOPE_STMT_TYPES): + for child in vars(value).values(): + note(child, False, False) + return + if isinstance(value, FuncCall): + callee = value.callee is_security = ( isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier) and callee.object.name == "request" and callee.member in ("security", "security_lower_tf") ) - if isinstance(expr.callee, MemberAccess): - obj = expr.callee.object - if isinstance(obj, Identifier) and obj.name == "ta": - if under_and_rhs: - and_rhs.add(id(expr)) - if under_lazy_rhs: - lazy_rhs.add(id(expr)) - if under_and_rhs and not under_disallowed_shape: - plain_and_rhs.add(id(expr)) - # A call target may itself be an evaluated expression, as in - # ``array.new_float(...).get(0)``. Walk the callee subtree so - # TA nested in a chained receiver inherits the surrounding - # lazy context. Plain identifiers and namespace receivers are - # leaves, so ordinary ``ta.sma`` / ``request.security`` calls - # remain unaffected here. - note_ta_calls( - callee, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - for idx, arg in enumerate(getattr(expr, "args", ()) or ()): - # The third request.security* argument is evaluated by its - # own security evaluator. Symbol, timeframe, and remaining - # options are chart-context expressions and must still be - # inspected for lazy chart TA. + # A chained receiver (``label.new(...).get_y()``) is an + # evaluated expression too; namespace/identifier callees are + # leaves. + note(callee, under_and, under_lazy) + for idx, arg in enumerate(getattr(value, "args", ()) or ()): if is_security and idx == 2: continue - note_ta_calls( - arg, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - for key, value in (getattr(expr, "kwargs", None) or {}).items(): + note(arg, under_and, under_lazy) + for key, kw_value in (getattr(value, "kwargs", None) or {}).items(): if is_security and key == "expression": continue - note_ta_calls( - value, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - return - if isinstance(expr, BinOp): - if expr.op == "and": - # LHS always runs first; RHS is short-circuit conditional. - note_ta_calls( - expr.left, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - note_ta_calls( - expr.right, - True, - True, - under_disallowed_shape, - ) - elif expr.op == "or": - # ``or`` preserves an enclosing ``and`` scope, but its own - # RHS is a Pine-v6 lazy edge for the EMA classifier. - note_ta_calls(expr.left, under_and_rhs, under_lazy_rhs, True) - note_ta_calls(expr.right, under_and_rhs, True, True) - else: - note_ta_calls( - expr.left, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - note_ta_calls( - expr.right, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - return - if isinstance(expr, Ternary): - note_ta_calls(expr.condition, under_and_rhs, under_lazy_rhs, True) - note_ta_calls(expr.true_val, under_and_rhs, True, True) - note_ta_calls(expr.false_val, under_and_rhs, True, True) - return - if isinstance(expr, UnaryOp): - note_ta_calls( - expr.operand, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - return - if isinstance(expr, (MemberAccess, Subscript)): - note_ta_calls( - getattr(expr, "object", None), - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - note_ta_calls( - getattr(expr, "index", None), - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - return - if isinstance(expr, TupleLiteral): - for elem in expr.elements: - note_ta_calls( - elem, - under_and_rhs, - under_lazy_rhs, - under_disallowed_shape, - ) - return - - def walk_stmt(stmt) -> None: - if stmt is None: - return - if isinstance(stmt, VarDecl): - note_ta_calls(stmt.value, False, False, False) - return - if isinstance(stmt, ExprStmt): - note_ta_calls( - getattr(stmt, "value", None) or getattr(stmt, "expr", None), - False, - False, - False, - ) + note(kw_value, under_and, under_lazy) + if ( + isinstance(callee, MemberAccess) + and isinstance(callee.object, Identifier) + and callee.object.name == "ta" + ): + if under_and: + and_rhs.add(id(value)) + if under_lazy: + lazy_rhs.add(id(value)) return - if isinstance(stmt, Assignment): - note_ta_calls(getattr(stmt, "target", None), False, False, False) - note_ta_calls(getattr(stmt, "value", None), False, False, False) + if isinstance(value, BinOp) and value.op == "and": + note(value.left, under_and, under_lazy) + note(value.right, True, True) return - if isinstance(stmt, TupleAssign): - note_ta_calls(getattr(stmt, "value", None), False, False, False) + if isinstance(value, BinOp) and value.op == "or": + note(value.left, under_and, under_lazy) + note(value.right, under_and, True) return - if isinstance(stmt, TypeDecl): - for field in getattr(stmt, "fields", ()) or (): - note_ta_calls( - getattr(field, "default", None), False, False, False - ) + if isinstance(value, Ternary): + note(value.condition, under_and, under_lazy) + note(value.true_val, under_and, True) + note(value.false_val, under_and, True) return - # If / for / while / assign-like — best-effort field walk - for attr in ("condition", "body", "else_body", "else_ifs", "value", "target", "iterable"): - child = getattr(stmt, attr, None) - if child is None: - continue - if isinstance(child, list): - for item in child: - if isinstance(item, (list, tuple)): - for sub in item: - walk_stmt(sub) - elif hasattr(item, "body") or hasattr(item, "name") or hasattr(item, "value"): - walk_stmt(item) - else: - note_ta_calls(item, False, False, False) - elif hasattr(child, "op") or hasattr(child, "args") or hasattr(child, "left"): - note_ta_calls(child, False, False, False) - elif hasattr(child, "body") or hasattr(child, "value") or hasattr(child, "condition"): - walk_stmt(child) + for child in vars(value).values(): + note(child, under_and, under_lazy) - ast = getattr(self.ctx, "ast", None) - for stmt in getattr(ast, "body", ()) or (): - walk_stmt(stmt) - # User function bodies (original sites live here; clones share node ids - # only when they reuse the same FuncCall object — still best-effort). + note(getattr(self.ctx, "ast", None), False, False) for finfo in getattr(self.ctx, "func_infos", None) or []: - node = getattr(finfo, "node", None) - body = getattr(node, "body", None) if node is not None else None - if body: - for stmt in body: - walk_stmt(stmt) + note(getattr(finfo, "node", None), False, False) - result = (and_rhs, lazy_rhs, plain_and_rhs) + result = (and_rhs, lazy_rhs) self._lazy_scope_ta_call_nodes = result return result @@ -420,23 +309,83 @@ def _ta_call_nodes_under_and_rhs(self) -> set[int]: def _ta_call_nodes_under_lazy_rhs(self) -> set[int]: return self._ta_call_nodes_by_lazy_scope()[1] - def _ta_call_nodes_under_plain_and_rhs(self) -> set[int]: - return self._ta_call_nodes_by_lazy_scope()[2] - - def _ta_call_nodes_in_top_level_var_values(self) -> set[int]: - cached = getattr(self, "_top_level_var_value_ta_nodes", None) - if cached is not None: - return cached - result: set[int] = set() - ast = getattr(self.ctx, "ast", None) - for stmt in getattr(ast, "body", ()) or (): - if not isinstance(stmt, VarDecl): - continue - for child in self._walk_ast(stmt.value): - if isinstance(child, FuncCall): - result.add(id(child)) - self._top_level_var_value_ta_nodes = result - return result + # ------------------------------------------------------------------ + # TradingView's per-family clocks below a top-level lazy edge + # ------------------------------------------------------------------ + # + # Pinned 2026-09-03 with ``lab tv`` on NYSE:F 1D (range 2025-04-01 .. + # 2026-05-01; cadence-7 ternary probes ``v = bar_index % 7 == 3 ? : + # na`` exposing the value through the entry size, plus lazy-``and`` + # probes), each scored per call against three models: + # + # every-bar natives highest 9/9, sma 25/25, ema 23/23 -- the runtime + # advances the built-in on every chart bar; only the + # value is gated (``_lazy_edge_ta_hoist_plan``). + # hold-last source roc 38/38 (+39/39 entries), change 39/39, mom + # 39/39 -- TradingView computes these from the CALL'S + # OWN ``source[length]`` history: the source is + # written only on bars where the call executes, the + # last executed value is held on skipped bars, and + # the history is na before the first execution + # (call 1 of every value probe has no TV entry). + # every-bar 0/38..0/39, ring-of-executions 0..1/39. + # per-execution cum, barssince, valuewhen, cross, crossover 39/39, + # rising 39/39 (strictly monotonic over its executed + # samples) and math.sum 39/39 (na until three + # executed samples) -- the native only ever sees the + # samples of bars where the call executes, which is + # exactly the reached-only inline ``compute`` lowering + # (every-bar 0..31/39). + # + # The hoist is an ALLOW-LIST and requires a direct ``[k]`` read. A broad + # every-bar hoist of every stateful family measured on Cloud Run against + # the full population (2026-09-04, like-for-like vs the same lab bundle) + # fixed the pinned shapes but cost 170 tiers / 30 hard-lane regressions, + # and hoisting allow-listed families WITHOUT a history read still broke + # four ETH hard-lane probes that main matched at 100%: ``adxVal > 30 and + # adxVal > ta.sma(adxVal, 7)`` (louislapis9), ``volume < ta.sma(volume, + # 20)`` under ``and`` (oliver1002 -- the original pf-probe-oliver-dual- + # vol-sma pin), ``ta.change(ta.sma(close, 50)) > 0`` under ``and`` + # (ycelestine77), five ``ta.ema(close, 200)`` under ``or`` (quantbyboji), + # ``ta.highest(high, n) / entry > 1.05`` under ``and`` (miemomo3). Every + # every-bar tape (highest/sma/ema ``[1]`` under ``and``, the ternary + # ``ta.highest(high, 5)[1]``, robmagnaye) reads ``[k]`` on the call, and + # the bare twins of those tapes (2026-09-04, same cadence-7 probes) are + # per-execution: ``close > ta.sma(close, 5)`` ring 39/39 (every-bar 23), + # ``close > ta.ema(close, 5)`` ring 39/39 (26), ``? ta.sma(close, 5) : na`` + # ring 39/39 (2, na until five executed samples), ``? ta.highest(high, 5) + # : na`` ring 38/39 (11; TV is na with one executed sample, then the + # partial maximum). So: TradingView keeps a lazily executed call on the + # reached-only clock unless the call's own history is referenced (``[k]``), + # in which case it materialises a continuous series and the call is + # evaluated on every bar. Only ``LAZY_EVERY_BAR_TA`` families + # (``lowest`` is ``highest``'s mirror) with such a read hoist. + # ``LAZY_SOURCE_CLOCK_TA`` routes to the hold-last clock; + # ``LAZY_PER_EXECUTION_TA`` (the seven taped families -- ``math.sum`` + # 39/39 per-execution, na until three executed samples -- plus the exact + # mirrors ``crossunder``/``falling``) keeps the inline compute and never + # precalcs. Every other family keeps its existing lowering unchanged + # (inline compute when reached; precalc when static) until a tape pins + # it -- ``_lazy_edge_hoist_plan()["skipped"]`` lists them per script. + LAZY_EVERY_BAR_TA = frozenset({"highest", "lowest", "sma", "ema"}) + LAZY_SOURCE_CLOCK_TA = frozenset({"change", "mom", "roc"}) + LAZY_PER_EXECUTION_TA = frozenset({ + "cum", "barssince", "valuewhen", "cross", "crossover", "crossunder", + "rising", "falling", "sum", + }) + UNPINNED_LAZY_EDGE_REASON = ( + "family not pinned every-bar by tape (LAZY_EVERY_BAR_TA allow-list): " + "existing lowering" + ) + NO_HISTORY_READ_REASON = ( + "allow-listed family without a direct [k] history read: TradingView " + "keeps the reached-only clock (louislapis9/oliver1002/miemomo3/" + "ycelestine77/quantbyboji exact on main, 2026-09-04)" + ) + + _LAZY_SOURCE_CLOCK_BAR_SOURCES = frozenset({ + "open", "high", "low", "close", "volume", "hl2", "hlc3", "ohlc4", "hlcc4", + }) def _script_has_user_binding(self, name: str) -> bool: cache = getattr(self, "_user_binding_names", None) @@ -460,44 +409,53 @@ def _script_has_user_binding(self, name: str) -> bool: self._user_binding_names = cache return name in cache - def _is_lazy_saturated_roc3_site(self, site: "TACallSite") -> bool: - """Return whether ``site`` has the oracle-backed lazy ROC source shape. - - Selection is deliberately syntactic and callsite-local: a direct - top-level variable value containing ``ta.roc(close, 3)`` below a plain - ``and`` RHS, where ``close`` is not shadowed by any user binding. User - functions, control-flow bodies, request.security evaluators, - aliases/other sources, named or dynamic lengths, shadowed built-ins, - and any surrounding ``or``/ternary shape remain on their existing eager - route. Comparator polarity is intentionally absent; two otherwise - identical callsites must not acquire different history semantics merely - because one compares above zero and one below it. + def _lazy_source_clock_chart_source(self, site: "TACallSite"): + """The unshadowed bar-builtin source node, or None. + + Between two executions closer than ``length`` bars the pin does not + distinguish the held read from #64's eager chart ``source[length]``; + the eager read is kept where the source is a plain chart builtin. """ - node = getattr(site, "node", None) + source = site.compute_args[0] if site.compute_args else None if ( - node is None - or getattr(site, "owner_func", None) is not None - or self._ta_name_from_site(site) != "roc" - or id(node) not in self._ta_call_nodes_under_plain_and_rhs() - or id(node) not in self._ta_call_nodes_in_top_level_var_values() - or self._script_has_user_binding("close") - or not isinstance(node, FuncCall) - or getattr(node, "kwargs", None) - or len(getattr(node, "args", ())) != 2 + isinstance(source, Identifier) + and source.name in self._LAZY_SOURCE_CLOCK_BAR_SOURCES + and not self._script_has_user_binding(source.name) ): + return source + return None + + @staticmethod + def _lazy_source_clock_length_node(node): + kwargs = getattr(node, "kwargs", None) or {} + if "length" in kwargs: + return kwargs["length"] + args = getattr(node, "args", ()) or () + return args[1] if len(args) > 1 else None + + @staticmethod + def _lazy_source_clock_length_literal(length_node) -> int | None: + if length_node is None: + return 1 + if ( + isinstance(length_node, NumberLiteral) + and not isinstance(length_node.value, bool) + and float(length_node.value) == int(length_node.value) + ): + return int(length_node.value) + return None + + def _lazy_source_clock_eligible(self, site: "TACallSite") -> bool: + """change/mom/roc with a numeric source; anything else keeps its lowering.""" + if getattr(site, "owner_func", None) is not None or getattr(site, "returns_tuple", False): return False - source, length = node.args - return ( - isinstance(source, Identifier) - and source.name == "close" - and isinstance(length, NumberLiteral) - and not isinstance(length.value, bool) - and length.value == 3 - ) + if not site.compute_args: + return False + return self._infer_type(site.compute_args[0]) in ("double", "int", "int64_t") - def _prepare_lazy_saturated_roc3_sites(self) -> None: - """Assign one copyable clock member to each selected AST callsite.""" - if hasattr(self, "_lazy_saturated_roc3_clock_by_node"): + def _prepare_lazy_source_clock_sites(self) -> None: + """Allocate one clock + one held-source Series per routed site (idempotent).""" + if hasattr(self, "_lazy_source_clock_by_node"): return def allocate(base: str, reserved: set[str]) -> str: @@ -531,45 +489,70 @@ def allocate(base: str, reserved: set[str]) -> str: reserved_members.add(f"{emitted}_cs{callsite}") for instance in getattr(self, "_fresh_instances", ()): reserved_members.add(instance["name"]) - self._lazy_saturated_roc3_type_name = allocate( - "_PFLazySaturatedROC3Clock", reserved_types - ) - self._lazy_saturated_roc3_history_name = allocate( - "_pf_lazy_saturated_roc3_close_history", reserved_members - ) - - clocks: dict[int, str] = {} - for index, site in enumerate(self.ctx.ta_call_sites): - if index in getattr(self, "_dead_ta_indices", set()): - continue - if self._is_lazy_saturated_roc3_site(site): - clocks[id(site.node)] = allocate( - f"_pf_lazy_saturated_roc3_clock_{len(clocks) + 1}", - reserved_members, - ) - self._lazy_saturated_roc3_clock_by_node = clocks - - def _lazy_saturated_roc3_clock_name(self, site: "TACallSite") -> str: - self._prepare_lazy_saturated_roc3_sites() - return self._lazy_saturated_roc3_clock_by_node[id(site.node)] - - def _lazy_saturated_roc3_expr(self, site: "TACallSite") -> str: - """Lower a reached lazy ROC site through its per-callsite clock.""" - clock = self._lazy_saturated_roc3_clock_name(site) - history = self._lazy_saturated_roc3_history_name - return ( - f"{clock}.evaluate(current_bar_.close, " - f"{history}[3], bar_index_)" + self._lazy_source_clock_type_name = allocate("_PFLazySourceClock", reserved_types) + + clocks: dict[int, dict] = {} + routed = self._lazy_edge_ta_hoist_plan()["source_clock"] + for index, (node_id, info) in enumerate(routed.items(), start=1): + chart_source = self._lazy_source_clock_chart_source(info["site"]) + clocks[node_id] = { + "clock": allocate(f"_pf_lazy_src_clock_{index}", reserved_members), + "hist": allocate(f"_pf_lazy_src_hist_{index}", reserved_members), + "chart": ( + allocate(f"_pf_lazy_src_chart_{index}", reserved_members) + if chart_source is not None + else None + ), + "chart_source": chart_source, + "site": info["site"], + "node": info["node"], + "length_literal": info["length_literal"], + } + self._lazy_source_clock_by_node = clocks + + def _lazy_source_clock_expr(self, site: "TACallSite", node) -> str: + """Lower a reached change/mom/roc site through its hold-last source clock.""" + info = self._lazy_source_clock_by_node[id(node)] + source = self._visit_expr(site.compute_args[0]) + length_node = self._lazy_source_clock_length_node(node) + literal = self._lazy_source_clock_length_literal(length_node) + chart = info["chart"] + if literal is not None: + length_expr = str(literal) + held = f"{info['hist']}[{literal - 1}]" if literal >= 1 else "na()" + eager = f"{chart}[{literal}]" if chart is not None else held + else: + length_expr = f"(int)({self._visit_expr(length_node)})" + held = ( + f"(({length_expr}) >= 1 ? {info['hist']}[({length_expr}) - 1] " + f": na())" + ) + eager = f"{chart}[{length_expr}]" if chart is not None else held + previous = ( + f"{info['clock']}.previous_source({held}, {eager}, {length_expr}, " + f"bar_index_)" ) + method = "roc" if self._ta_name_from_site(site) == "roc" else "change" + return f"{info['clock']}.{method}({source}, {previous})" - def _emit_lazy_saturated_roc3_helper(self, lines: list[str]) -> None: + def _emit_lazy_source_clock_helper(self, lines: list[str]) -> None: """Emit the value-copyable generated runtime helper when needed.""" - self._prepare_lazy_saturated_roc3_sites() - if not self._lazy_saturated_roc3_clock_by_node: + self._prepare_lazy_source_clock_sites() + if not self._lazy_source_clock_by_node: return - type_name = self._lazy_saturated_roc3_type_name + type_name = self._lazy_source_clock_type_name lines.extend( [ + "// TradingView keeps a lazily executed call's `source[k]` history per", + "// call: the source is written only on bars where the call executes,", + "// the last executed value is held on the bars it skips, and the", + "// history is na before the first execution. The paired hist Series", + "// holds `bar_base_source` (the value committed by earlier bars) once", + "// per chart bar, so `hist[length - 1]` is the source at the most", + "// recent execution at or before bar-length. Between two executions", + "// closer than `length` bars the tapes do not distinguish that read", + "// from the eager chart `source[length]`, so the #64 eager read is", + "// kept there for chart-builtin sources.", f"struct {type_name} {{", " double committed_source = na();", " int committed_bar = -1;", @@ -585,22 +568,40 @@ def _emit_lazy_saturated_roc3_helper(self, lines: list[str]) -> None: " working_bar = -1;", " }", "", - " double evaluate(double source, double eager_previous, int bar) {", + " // Once per bar before the script body; a same-bar recalculation", + " // keeps the base frozen so the evaluation stays idempotent.", + " void begin_bar(int bar) {", " if (working_bar != bar) {", " bar_base_source = committed_source;", " bar_base_bar = committed_bar;", " working_bar = bar;", " }", - " const bool saturated = bar_base_bar >= 0 &&", - " bar - bar_base_bar >= 3;", - " const double previous = saturated ? bar_base_source : eager_previous;", - " double result = na();", - " if (!is_na(source) && !is_na(previous) && previous != 0.0) {", - " result = (source - previous) / previous * 100.0;", + " }", + "", + " double previous_source(double held, double eager, int length,", + " int bar) const {", + " if (bar_base_bar < 0 || length < 1) {", + " return na();", + " }", + " return bar - bar_base_bar >= length ? held : eager;", + " }", + "", + " double change(double source, double previous) {", + " committed_source = source;", + " committed_bar = working_bar;", + " if (is_na(source) || is_na(previous)) {", + " return na();", " }", + " return source - previous;", + " }", + "", + " double roc(double source, double previous) {", " committed_source = source;", - " committed_bar = bar;", - " return result;", + " committed_bar = working_bar;", + " if (is_na(source) || is_na(previous) || previous == 0.0) {", + " return na();", + " }", + " return (source - previous) / previous * 100.0;", " }", "};", "", @@ -618,16 +619,39 @@ def _ta_site_uses_precalc(self, site: "TACallSite") -> bool: all-``na``. Opting that site out preserves correctness; it simply uses the ordinary stateful TA object during ``on_bar``. - A chart-context ``ta.sma`` nested under an ``and`` RHS is also opted - out: precalc advances it every bar, which is eager and TV-incorrect for - the pinned dual-volume-SMA case. Recursive chart ``ta.ema`` sites below - any Pine-v6 lazy edge follow the same call-clock rule. Other TA families + A site hoisted to unconditional per-bar evaluation (top-level lazy + ``and``/``or`` RHS or ternary arm -- see + ``_lazy_edge_ta_hoist_plan``) advances on every chart bar by the pinned + TV rule, which is exactly what precalc computes; the lazy-edge opt-outs + below therefore never apply to it. A top-level lazy-edge site routed to + the hold-last source clock (change/mom/roc) or left on its + per-execution inline compute (cum/barssince/valuewhen/cross*/rising/...) + must never precalc. + + Re-pinned 2026-09-03: the old opt-out for a chart ``ta.sma`` under an + ``and`` RHS (pf-probe-oliver-dual-vol-sma, "eager precalc is + TV-incorrect") and the matching recursive ``ta.ema`` rule encoded a + per-call clock that ``lab tv`` refuted for top-level shapes (NYSE:F 1D, + ``... and close > ta.sma(close,5)[1]``: every-bar 25/25 vs per-call 28; + ``ta.ema``: 23/23 vs 27). The analyzer never marks a site inside an + ``if``/loop/function scope static, so after hoisting the opt-outs + only reach a static lazy-edge ``ta.sma``/``ta.ema`` that is not a + top-level statement operand -- in practice a UDT field default + (``test_ta_precalc_walks_type_field_defaults``), whose clock is the + ``Type.new()`` call site's and may sit in a block. Other TA families and request.security sites retain their existing behavior.""" if not getattr(site, "is_static", False): return False - if self._is_lazy_saturated_roc3_site(site): - return False node = getattr(site, "node", None) + plan = self._lazy_edge_ta_hoist_plan() + if node is not None and ( + id(node) in plan["source_clock"] or id(node) in plan["per_execution_nodes"] + ): + # Hold-last / per-execution clocks: precalc would advance the site + # on every bar, which the tapes refute for these families. + return False + if node is not None and id(node) in plan["call_nodes"]: + return all(self._expr_safe_for_ta_precalc(arg) for arg in site.compute_args) ta_name = self._ta_name_from_site(site) if ( ta_name == "sma" @@ -643,6 +667,264 @@ def _ta_site_uses_precalc(self, site: "TACallSite") -> bool: return False return all(self._expr_safe_for_ta_precalc(arg) for arg in site.compute_args) + # ------------------------------------------------------------------ + # Every-bar hoisting of stateful ``ta.*`` sites below top-level lazy edges + # ------------------------------------------------------------------ + # + # TradingView rule, pinned 2026-09-03 with ``lab tv`` on NYSE:F 1D (tapes + # out-pin-ring-lazyand / lazyand-sma / lazyand-ema / ring-ternary): a + # stateful ``ta.*`` call inside ANY expression operand of a top-level + # statement -- the RHS of a Pine-v6 lazy ``and``/``or``, either branch of a + # ternary, nested comparisons -- is evaluated on EVERY bar. Short-circuiting + # and branch selection gate only the *value*, never the built-in's state, + # and ``[1]`` on such a call is the previous BAR's value: + # + # c = bar_index % 7 == 3 and close > ta.highest(high,5)[1] 9/9 entries + # c = bar_index % 7 == 3 and close > ta.sma(close,5)[1] 25/25 + # c = bar_index % 7 == 3 and close > ta.ema(close,5)[1] 23/23 + # v = bar_index % 7 == 3 ? ta.highest(high,5)[1] : na 38/39 + # + # (the per-call / "previous evaluation" model predicts 8/9, 28, 27 and + # 2/39). Production instance: robmagnaye14 ``bullMSS = setupAlive and + # dir == 1 and close > ta.highest(high, 10)[1]``. + # + # A stateful call INSIDE an ``if``/local-block/function body that does not + # execute every bar IS execution-gated on TV (pinned separately) and keeps + # the in-block compute + ``_hist_call_*`` push lowering untouched. + # + # Lowering: every eligible site below a lazy edge of a top-level statement + # is evaluated once, unconditionally, in a ``const auto _pf_every_bar_ta_N`` + # local emitted BEFORE the statement (in dynamic mode too -- this is + # independent of ``_use_precalc``). A direct ``[k]`` on the hoisted call + # pushes its ``_hist_call_*`` Series there as well, so ``[1]`` reads the + # previous chart bar. The statement's expression then reads the local / + # the Series instead of stepping the indicator when the operand is reached. + + _LAZY_EDGE_HOIST_NAME_PREFIX = "_pf_every_bar_ta_" + + def _lazy_edge_hoist_block_reason( + self, site: "TACallSite", history_read: bool = False + ) -> str | None: + """Why an otherwise-eligible lazy-edge site is left on its lowering.""" + if getattr(site, "owner_func", None) is not None: + return "site belongs to a user function body" + if getattr(site, "returns_tuple", False): + return "tuple-returning site" + family = self._ta_name_from_site(site) + if family in self.LAZY_PER_EXECUTION_TA: + return ( + "per-execution native: the reached-only inline compute is " + "TradingView's clock (lab tv 2026-09-03)" + ) + if family in self.LAZY_SOURCE_CLOCK_TA: + return "hold-last source-clock family with a non-numeric source" + if family not in self.LAZY_EVERY_BAR_TA: + return self.UNPINNED_LAZY_EDGE_REASON + if not history_read: + return self.NO_HISTORY_READ_REASON + return None + + def _lazy_edge_ta_hoist_plan(self) -> dict: + """Plan (once) which top-level lazy-edge TA sites are hoisted. + + Returns ``{"by_stmt": {id(stmt): [unit, ...]}, "call_nodes": set, + "source_clock": {id(node): {"node", "site", "length_literal"}}, + "per_execution_nodes": set, "skipped": [(node, site, reason), ...]}``. + Only ``LAZY_EVERY_BAR_TA`` families whose call carries a direct + ``[k]`` history read (``Subscript`` on the call) become hoist units. + ``source_clock`` sites (change/mom/roc) lower through + ``_lazy_source_clock_expr``; ``per_execution_nodes`` keep the inline + compute; both are opted out of precalc. Every other family is listed + in ``skipped`` with ``UNPINNED_LAZY_EDGE_REASON`` and keeps its + existing lowering. A hoist unit is either + ``{"kind": "call", "node": FuncCall, "site": TACallSite, "name": str}`` + or ``{"kind": "hist", "node": Subscript}`` (a direct ``[k]`` on a hoisted + call). Units are in evaluation order: a nested hoisted call precedes the + call whose argument it is, and a ``hist`` unit follows its call. + + Only top-level statement expressions are scanned (``VarDecl`` values + except ``var``/``varip`` initializers, ``Assignment`` target/value, + ``TupleAssign`` value, ``ExprStmt`` expression, and the head condition + of an ``IfStmt``). ``if``/``for``/``while``/``switch`` bodies, ``else + if`` conditions (Pine's ``else if`` is an ``if`` inside the ``else`` + local block) and user-function bodies are never hoisted. Inside an + expression, the walk descends into every operand and call argument + except a ``request.security*`` payload, which its own evaluator runs. + Block-local arguments cannot occur at top level, so the skip list only + carries the shapes named in ``_lazy_edge_hoist_block_reason``. + """ + cached = getattr(self, "_lazy_edge_hoist_plan_cache", None) + if cached is not None: + return cached + + by_stmt: dict[int, list[dict]] = {} + call_nodes: set[int] = set() + source_clock: dict[int, dict] = {} + per_execution_nodes: set[int] = set() + skipped: list[tuple] = [] + counter = [0] + + def scan( + expr, under_lazy: bool, units: list[dict], history_read: bool = False + ) -> None: + if expr is None: + return + if isinstance(expr, FuncCall): + callee = expr.callee + is_security = ( + isinstance(callee, MemberAccess) + and isinstance(callee.object, Identifier) + and callee.object.name == "request" + and callee.member in ("security", "security_lower_tf") + ) + # Chained receivers (``label.new(...).get_y()``) are evaluated + # expressions too; namespace/identifier callees are leaves. + scan(callee, under_lazy, units) + for idx, arg in enumerate(getattr(expr, "args", ()) or ()): + if is_security and idx == 2: + continue + scan(arg, under_lazy, units) + for key, value in (getattr(expr, "kwargs", None) or {}).items(): + if is_security and key == "expression": + continue + scan(value, under_lazy, units) + if not under_lazy: + return + site = self._get_ta_site(expr) + if site is None: + return + family = self._ta_name_from_site(site) + if family in self.LAZY_SOURCE_CLOCK_TA and self._lazy_source_clock_eligible(site): + source_clock[id(expr)] = { + "node": expr, + "site": site, + "length_literal": self._lazy_source_clock_length_literal( + self._lazy_source_clock_length_node(expr) + ), + } + return + reason = self._lazy_edge_hoist_block_reason(site, history_read) + if reason is not None: + if family in self.LAZY_PER_EXECUTION_TA: + per_execution_nodes.add(id(expr)) + skipped.append((expr, site, reason)) + return + counter[0] += 1 + units.append({ + "kind": "call", + "node": expr, + "site": site, + "name": f"{self._LAZY_EDGE_HOIST_NAME_PREFIX}{counter[0]}", + }) + call_nodes.add(id(expr)) + return + if isinstance(expr, BinOp): + if expr.op in ("and", "or"): + scan(expr.left, under_lazy, units) + scan(expr.right, True, units) + else: + scan(expr.left, under_lazy, units) + scan(expr.right, under_lazy, units) + return + if isinstance(expr, Ternary): + scan(expr.condition, under_lazy, units) + scan(expr.true_val, True, units) + scan(expr.false_val, True, units) + return + if isinstance(expr, UnaryOp): + scan(expr.operand, under_lazy, units) + return + if isinstance(expr, Subscript): + # ``call(...)[k]``: the call's own history is referenced. + scan(expr.object, under_lazy, units, history_read=True) + scan(expr.index, under_lazy, units) + if isinstance(expr.object, FuncCall) and id(expr.object) in call_nodes: + units.append({"kind": "hist", "node": expr}) + return + if isinstance(expr, MemberAccess): + scan(expr.object, under_lazy, units) + return + if isinstance(expr, TupleLiteral): + for element in expr.elements: + scan(element, under_lazy, units) + return + # ``x = if cond ... else ...`` / ``x = switch ...`` value forms: the + # head expression is top-level, the bodies are local blocks. + if isinstance(expr, IfStmt): + scan(expr.condition, under_lazy, units) + return + if isinstance(expr, SwitchStmt): + scan(expr.expr, under_lazy, units) + return + # Literals, identifiers and anything else are leaves. + + def roots(stmt) -> list: + if isinstance(stmt, VarDecl): + if stmt.is_var or stmt.is_varip: + return [] + return [stmt.value] + if isinstance(stmt, Assignment): + return [stmt.target, stmt.value] + if isinstance(stmt, TupleAssign): + return [stmt.value] + if isinstance(stmt, ExprStmt): + return [stmt.expr] + if isinstance(stmt, IfStmt): + return [stmt.condition] + return [] + + ast = getattr(self.ctx, "ast", None) + for stmt in getattr(ast, "body", ()) or (): + units: list[dict] = [] + for root in roots(stmt): + scan(root, False, units) + if units: + by_stmt[id(stmt)] = units + + plan = { + "by_stmt": by_stmt, + "call_nodes": call_nodes, + "source_clock": source_clock, + "per_execution_nodes": per_execution_nodes, + "skipped": skipped, + } + self._lazy_edge_hoist_plan_cache = plan + return plan + + def _lazy_edge_hoisted_ta_call_nodes(self) -> set[int]: + return self._lazy_edge_ta_hoist_plan()["call_nodes"] + + def _emit_lazy_edge_ta_hoists(self, stmt, lines: list[str], indent: int) -> None: + """Emit the every-bar evaluations a top-level statement depends on. + + Must be paired with ``_clear_lazy_edge_ta_hoists`` after the statement + is visited: the maps make ``_visit_func_call`` / ``_visit_subscript`` + read the hoisted local / Series while the statement is lowered. + """ + units = self._lazy_edge_ta_hoist_plan()["by_stmt"].get(id(stmt)) + if not units: + return + pad = " " * indent + lines.append( + f"{pad}// Pine v6 lazy operand: TA state advances every bar, only the " + "value is gated." + ) + for unit in units: + node = unit["node"] + if unit["kind"] == "call": + rendered = self._visit_expr(node) + lines.append(f"{pad}const auto {unit['name']} = {rendered};") + self._hoisted_ta_values[id(node)] = unit["name"] + else: + member = self._inline_history_member("hist_call", node) + value = self._hoisted_ta_values[id(node.object)] + self._emit_history_series_write(lines, pad, member, value) + self._hoisted_hist_reads[id(node)] = member + + def _clear_lazy_edge_ta_hoists(self) -> None: + self._hoisted_ta_values.clear() + self._hoisted_hist_reads.clear() + def _security_ta_compute_args_for_site( self, sec_id: int, diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 6eff667..8358994 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -1420,13 +1420,21 @@ def _visit_func_call(self, node: FuncCall) -> str: # ta.* calls -> member.compute(...) site = self._get_ta_site(node) if site is not None: + # A top-level lazy-edge site already evaluated for this bar in a + # ``_pf_every_bar_ta_N`` local (see ``_lazy_edge_ta_hoist_plan``): + # read it instead of stepping the indicator again. + hoisted = self._hoisted_ta_values.get(id(node)) + if hoisted is not None: + return hoisted compute_args = self._ta_compute_args_for_site(site) ta_mem = self._ta_member_name(site) uses_precalc = self._ta_site_uses_precalc(site) if getattr(self, "_precalc_loop_active", False) and uses_precalc: return f"_precalc_{ta_mem}[i]" - if self._is_lazy_saturated_roc3_site(site): - return self._lazy_saturated_roc3_expr(site) + if id(node) in getattr(self, "_lazy_source_clock_by_node", {}): + # change/mom/roc below a top-level lazy edge: TradingView reads + # the call's own held ``source[length]`` (see ``ta.py``). + return self._lazy_source_clock_expr(site, node) if uses_precalc: return ( f"(_use_precalc ? _precalc_{ta_mem}[bar_index_] : " diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 7bd78ec..48a8fe7 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -1118,6 +1118,12 @@ def _visit_subscript(self, node: Subscript) -> str: # sites never share history, and on_bar clears every synthetic member at # run-start even when this particular expression is conditional. if isinstance(node.object, FuncCall): + # ``[k]`` on a hoisted top-level lazy-edge TA call: its Series was + # pushed unconditionally before the statement, so ``[1]`` is the + # previous chart bar in every run mode (``_lazy_edge_ta_hoist_plan``). + hoisted_member = self._hoisted_hist_reads.get(id(node)) + if hoisted_member is not None: + return f"{hoisted_member}[(int)({idx})]" inner = self._visit_expr(node.object) cpp_t = self._infer_type(node.object) if cpp_t not in ("double", "int", "int64_t", "bool"): diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index 80eabc6..690dccd 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -779,11 +779,43 @@ def test_ta_precalc_skips_user_series_alias_source(): assert "_ta_stdev_1.compute(ha_close)" in cpp + +_EVERY_BAR_RULE_NOTE = """Pinned 2026-09-03/04 with ``lab tv`` (NYSE:F 1D) and Cloud Run: a chart +``ta.*`` call below a Pine-v6 lazy edge of a TOP-LEVEL statement stays on the +reached-only clock UNLESS the call's own history is read. With ``[1]`` on the +call the every-bar model is exact -- ``c = bar_index % 7 == 3 and close > +ta.sma(close, 5)[1]`` 25/25 TV trades (per-call clock 28), ``ta.ema`` 23/23 +(27), ``ta.highest(high, 5)[1]`` 9/9 (8), the ternary ``v = bar_index % 7 == 3 +? ta.highest(high, 5)[1] : na`` 38/39 (2) -- and codegen hoists such +highest/lowest/sma/ema sites into a ``const auto _pf_every_bar_ta_N`` local +before the statement (``_lazy_edge_ta_hoist_plan``), where precalc (the same +every-bar evaluation) is valid again. WITHOUT a history read the reached-only +inline compute is TradingView's clock: ``volume < ta.sma(volume, 20)`` under +``and`` (oliver1002 / pf-probe-oliver-dual-vol-sma), ``adxVal > ta.sma(adxVal, +7)`` (louislapis9), ``ta.change(ta.sma(close, 50))`` (ycelestine77), five +``ta.ema(close, 200)`` under ``or`` (quantbyboji) all match TV at 100% on that +lowering and regressed when hoisted.""" + + +def _stmt_line(cpp: str, prefix: str) -> str: + return next( + ln for ln in cpp.splitlines() if ln.strip().startswith(prefix) + ) + + +def _hoist_line(cpp: str, name: str) -> str: + return _stmt_line(cpp, f"const auto {name} = ") + + def test_ta_precalc_skips_short_circuit_and_rhs_call_sites(): """Dual ta.sma under ``and`` must not use full-history precalc (lazy-stale). Campaign pin: pf-probe-oliver-dual-vol-sma — TV dual-callsite volume SMAs disagree with a hoisted SMA; eager precalc erased that independence. + Re-confirmed 2026-09-04: hoisting these sites broke oliver1002 (100 -> 75.2 + on ETH). Without a ``[k]`` read on the call the reached-only clock is TV's + (``_EVERY_BAR_RULE_NOTE``); ``... > ta.sma(close, 5)[1]`` is the every-bar + shape. """ cpp = _cpp( "stPred = close > open\n" @@ -803,19 +835,33 @@ def test_ta_precalc_skips_short_circuit_and_rhs_call_sites(): assert "_precalc__ta_sma_3" in cpp -def test_ta_precalc_skips_nested_sma_below_and_rhs(): - """The SMA keeps the enclosing lazy context through an outer ta.change.""" + +def test_nested_ta_below_and_rhs_keeps_inline_sma_and_clocks_outer_change(): + """``ta.change(ta.sma(...))`` under an ``and`` RHS (ycelestine77): the SMA + has no history read, so it stays on the reached-only inline compute (no + precalc); ``ta.change`` follows TradingView's hold-last source clock over + that value (lab tv 2026-09-03: change 39/39; length 1 == previous + execution).""" cpp = _cpp( "base = ta.mom(close, 20) > 0 and ta.change(ta.sma(close, 50)) > 0\n" "plot(base ? 1 : 0)" ) + assert "_pf_every_bar_ta_" not in cpp assert "std::vector _precalc__ta_sma" not in cpp - assert "_use_precalc ? _precalc__ta_sma" not in cpp - assert "_ta_sma_" in cpp and ".compute(current_bar_.close)" in cpp + base_line = _stmt_line(cpp, "base = (") + assert "_ta_sma_" in base_line and ".compute(current_bar_.close)" in base_line + assert "_pf_lazy_src_clock_1.change(" in base_line + assert ( + "previous_source(_pf_lazy_src_hist_1[0], _pf_lazy_src_hist_1[0], 1, bar_index_)" + in base_line + ) + assert "_ta_change_" not in base_line + assert "_ta_mom_" in base_line def test_ta_precalc_lazy_scope_routes_recursive_ema_only(): - """EMA follows Pine-v6 lazy edges while unrelated TA stays precalculated.""" + """EMA follows Pine-v6 lazy edges while unrelated TA stays precalculated; + ``ta.roc`` takes the hold-last source clock (tests/test_lazy_source_clock*.py).""" cpp = _cpp( "pred = close > open\n" "a = pred and ta.ema(close, 20) > close\n" @@ -833,45 +879,69 @@ def test_ta_precalc_lazy_scope_routes_recursive_ema_only(): assert f"std::vector _precalc__ta_{name}_" in cpp assert f"_use_precalc ? _precalc__ta_{name}_" in cpp assert "std::vector _precalc__ta_roc_" not in cpp - assert "_PFLazySaturatedROC3Clock" in cpp + assert "struct _PFLazySourceClock {" in cpp assert ( - ".evaluate(current_bar_.close, " - "_pf_lazy_saturated_roc3_close_history[3], bar_index_)" - ) in cpp + "_pf_lazy_src_clock_1.roc(current_bar_.close, " + "_pf_lazy_src_clock_1.previous_source(_pf_lazy_src_hist_1[2], " + "_pf_lazy_src_chart_1[3], 3, bar_index_))" + ) in _stmt_line(cpp, "b = (") + # No site reads its own history, so nothing is hoisted. + assert "_pf_every_bar_ta_" not in cpp assert len(re.findall(r"std::vector _precalc__ta_ema_", cpp)) == 1 assert len(re.findall(r"std::vector _precalc__ta_sma_", cpp)) == 2 assert len(re.findall(r"_use_precalc \? _precalc__ta_sma_", cpp)) >= 2 def test_precalculated_extrema_direct_history_uses_chart_bar_clock(): - """``ta.highest/lowest(...)[k]`` must index their precalculated series. + """``ta.highest/lowest(...)[k]`` reads the chart-bar clock in every run mode. - The surrounding Pine-v6 boolean remains lazy. Only the already-computed - direct-call history changes from a sparse evaluation clock to the chart-bar - clock used by the same TA site's ``_precalc_*`` values. + Below a lazy edge the site is hoisted: its ``_hist_call_*`` Series is pushed + unconditionally before the statement, so ``[k]`` is ``k`` chart bars ago in + precalc and dynamic runs alike (the surrounding boolean still gates the + value). An eager direct-history site keeps the precalc-indexed lambda with + the synthetic-history fallback for dynamic runs. """ cpp = _cpp( "pred = close > open\n" "lo = pred and close < ta.lowest(low, 20)[1]\n" "hi = pred and close > ta.highest(high, 10)[2]\n" - "plot((lo or hi) ? 1 : 0)" + "flat = close > ta.highest(high, 30)[3]\n" + "plot((lo or hi or flat) ? 1 : 0)" ) - assert "pred &&" in cpp assert "std::vector _precalc__ta_lowest_1" in cpp assert "std::vector _precalc__ta_highest_2" in cpp - assert "static_cast(bar_index_) - _pf_hist_offset" in cpp - assert "_pf_hist_offset_numeric < 0.0L" in cpp - assert "static_cast(bar_index_)" in cpp - assert "return _precalc__ta_lowest_1[(std::size_t)_pf_hist_bar]" in cpp - assert "return _precalc__ta_highest_2[(std::size_t)_pf_hist_bar]" in cpp - # Dynamic/non-precalculated execution retains the rollback-safe synthetic - # history fallback rather than silently becoming eager. - assert cpp.count("if (history_advances_new_bar()) _hist_call_") >= 2 - - -def test_nonprecalculated_extrema_direct_history_keeps_evaluation_clock(): - """Unsafe extrema inputs must retain their call-local history clock.""" + assert "std::vector _precalc__ta_highest_3" in cpp + lines = cpp.splitlines() + for idx, member in ((1, "_hist_call_1"), (2, "_hist_call_2")): + hoist = _hoist_line(cpp, f"_pf_every_bar_ta_{idx}") + assert hoist.startswith(" const auto") + push = ( + f" if (history_advances_new_bar()) {member}.push(" + f"_pf_every_bar_ta_{idx});" + ) + assert push in lines + assert f" else {member}.update(_pf_every_bar_ta_{idx});" in lines + lo_line = _stmt_line(cpp, "lo = (") + hi_line = _stmt_line(cpp, "hi = (") + assert "pred &&" in lo_line and "_hist_call_1[(int)(1)]" in lo_line + assert "pred &&" in hi_line and "_hist_call_2[(int)(2)]" in hi_line + assert "_pf_hist_bar" not in lo_line and "_pf_hist_bar" not in hi_line + # Eager direct history: precalc-indexed read, synthetic-history fallback. + flat_line = _stmt_line(cpp, "flat = (") + assert "static_cast(bar_index_) - _pf_hist_offset" in flat_line + assert "return _precalc__ta_highest_3[(std::size_t)_pf_hist_bar]" in flat_line + assert "if (history_advances_new_bar()) _hist_call_3.push(_hv)" in flat_line + + + +def test_nonprecalculated_extrema_direct_history_uses_every_bar_clock(): + """Precalc-unsafe extrema below a lazy edge still advance every bar. + + ``src = close`` is not replayed by ``precalculate()``, so the site cannot + read ``_precalc_*`` -- it is hoisted to an inline every-bar compute and its + direct history is pushed every bar (``_EVERY_BAR_RULE_NOTE``). + """ cpp = _cpp( "src = close\n" "pred = close > open\n" @@ -881,8 +951,17 @@ def test_nonprecalculated_extrema_direct_history_keeps_evaluation_clock(): assert "std::vector _precalc__ta_highest" not in cpp assert "_pf_hist_bar" not in cpp - assert "_ta_highest_1.compute(src)" in cpp - assert "if (history_advances_new_bar()) _hist_call_" in cpp + hoist = _hoist_line(cpp, "_pf_every_bar_ta_1") + assert "_ta_highest_1.compute(src)" in hoist + assert "_use_precalc" not in hoist + assert ( + " if (history_advances_new_bar()) _hist_call_1.push(_pf_every_bar_ta_1);" + in cpp.splitlines() + ) + signal_line = _stmt_line(cpp, "signal = (") + assert "pred &&" in signal_line and "_hist_call_1[(int)(1)]" in signal_line + assert ".compute(" not in signal_line + def test_recursive_ema_lazy_edges_preserve_eager_operand_positions(): @@ -945,6 +1024,7 @@ def test_lazy_udf_ema_uses_callsite_state_without_precalc(): assert "eager = f_cs1();" in cpp + def test_ta_precalc_keeps_security_context_and_rhs_sma(): """Security-local TA is distinct; a referenced chart expression is not.""" cpp = _cpp( diff --git a/tests/test_compile_smoke.py b/tests/test_compile_smoke.py index ba1c4b2..21cf899 100644 --- a/tests/test_compile_smoke.py +++ b/tests/test_compile_smoke.py @@ -67,43 +67,47 @@ def test_minimal_strategy_compiles(): compile_cpp(cpp, label="minimal_strategy") -def test_lazy_saturated_roc_call_clocks_compile_and_are_copyable(): - """Both structurally identical long/short clocks compile with COOF state.""" +def test_lazy_source_clocks_compile(): + """change/mom/roc below a top-level lazy edge lower through the generated + hold-last source clock (tests/test_lazy_source_clock*.py).""" skip_if_no_compile_env() cpp = transpile('''//@version=6 -strategy("lazy ROC clocks", calc_on_order_fills=true) +strategy("lazy source clocks") +len = input.int(3, "len") gate = close > open longish = gate and ta.roc(close, 3) > 0 -shortish = gate and ta.roc(close, 3) < 0 -if longish or shortish +shortish = gate and ta.roc(close, len) < 0 +ch = gate ? ta.change(close, 3) : na +mo = gate or ta.mom(close, 2) > 0 +if longish or shortish or mo or not na(ch) strategy.entry("L", strategy.long) ''') - compile_cpp(cpp, label="lazy_saturated_roc_call_clocks") + compile_cpp(cpp, label="lazy_source_clocks") -def test_lazy_saturated_roc_generated_names_compile_with_user_collisions(): +def test_lazy_source_clock_generated_names_compile_with_user_collisions(): skip_if_no_compile_env() cpp = transpile('''//@version=6 -strategy("lazy ROC name collisions") -type _PFLazySaturatedROC3Clock +strategy("lazy source clock name collisions") +type _PFLazySourceClock float value -float _pf_lazy_saturated_roc3_clock_1 = 0.0 -float _pf_lazy_saturated_roc3_close_history = 0.0 +float _pf_lazy_src_clock_1 = 0.0 +float _pf_lazy_src_hist_1 = 0.0 gate = close > open signal = gate and ta.roc(close, 3) > 0 ''') - compile_cpp(cpp, label="lazy_saturated_roc_name_collisions") + compile_cpp(cpp, label="lazy_source_clock_name_collisions") -def test_lazy_saturated_roc_clock_name_compiles_with_udf_collision(): +def test_lazy_source_clock_name_compiles_with_udf_collision(): skip_if_no_compile_env() cpp = transpile('''//@version=6 -strategy("lazy ROC UDF collision") -_pf_lazy_saturated_roc3_clock_1() => 1.0 -other = _pf_lazy_saturated_roc3_clock_1() +strategy("lazy source clock UDF collision") +_pf_lazy_src_clock_1() => 1.0 +other = _pf_lazy_src_clock_1() signal = close > open and ta.roc(close, 3) > 0 ''') - compile_cpp(cpp, label="lazy_saturated_roc_udf_collision") + compile_cpp(cpp, label="lazy_source_clock_udf_collision") def test_calc_on_order_fills_mixed_script_state_checkpoint_compiles(): diff --git a/tests/test_lazy_edge_ta_every_bar.py b/tests/test_lazy_edge_ta_every_bar.py new file mode 100644 index 0000000..95f709a --- /dev/null +++ b/tests/test_lazy_edge_ta_every_bar.py @@ -0,0 +1,685 @@ +"""Top-level lazy-edge ``ta.*`` sites advance every bar (TradingView rule). + +Pinned 2026-09-03 with ``lab tv`` on NYSE:F 1D (campaign scratch tapes +``out-pin-ring-lazyand`` / ``out-pin-lazyand-sma`` / ``out-pin-lazyand-ema`` / +``out-pin-ring-ternary``; feed ``lab bars NYSE:F 1D --around 2025-10-15``): + +* ``c = bar_index % 7 == 3 and close > ta.highest(high, 5)[1]`` -- the + every-bar model reproduces TV's 9/9 entries; the per-call model is wrong on + 8 of them. +* ``... and close > ta.sma(close, 5)[1]`` -- every-bar 25/25 (per-call 28). +* ``... and close > ta.ema(close, 5)[1]`` -- every-bar 23/23 (per-call 27). +* ``v = bar_index % 7 == 3 ? ta.highest(high, 5)[1] : na`` -- every-bar 38/39 + (ring / previous-execution model 2/39). + +Rule: a stateful ``ta.*`` call inside ANY expression operand of a top-level +statement -- the RHS of a Pine-v6 lazy ``and``/``or``, either ternary arm, +nested comparisons -- WHOSE OWN HISTORY IS READ (``[k]`` on the call) is +evaluated on EVERY bar. Short-circuiting and branch selection gate only the +value, never the built-in's state, and ``[1]`` on such a call is the previous +BAR's value. Every one of the every-bar tapes reads ``[1]`` on the call. +Without a history read the call stays on the reached-only clock: hoisting +``volume < ta.sma(volume, 20)`` (oliver1002), ``adxVal > ta.sma(adxVal, 7)`` +(louislapis9), ``ta.change(ta.sma(close, 50)) > 0`` (ycelestine77), five +``ta.ema(close, 200)`` under ``or`` (quantbyboji) and ``ta.highest(high, n) / +entry_price > 1.05`` (miemomo3) turned 100%-exact ETH/BTC probes weak on +Cloud Run (2026-09-04). The hoist is also an allow-list of the families pinned +every-bar (highest, lowest, sma, ema). A stateful call inside an ``if`` / local +block / function body that does not execute every bar stays execution-gated +(pinned separately; the in-block compute + ``_hist_call_*`` push is untouched). + +Production instance: robmagnaye14 ``bullMSS = setupAlive and dir == 1 and +close > ta.highest(high, 10)[1]`` (+ BOS / bear variants), which diverged on +~12 lanes because the engine's window spanned setups days apart. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from pineforge_codegen import transpile +from tests import _compile as compile_env + + +_HEADER = ( + '//@version=6\n' + 'strategy("lazy-edge", initial_capital=1000000000, pyramiding=1, ' + 'default_qty_type=strategy.fixed, default_qty_value=1)\n' +) + + +def _cpp(body: str) -> str: + return transpile(_HEADER + body + "\n") + + +def _on_bar(cpp: str) -> str: + return cpp.split("void on_bar(", 1)[1].split("\n }\n", 1)[0] + + +def _lines(cpp: str) -> list[str]: + return cpp.splitlines() + + +def _stmt(cpp: str, prefix: str) -> str: + return next(ln for ln in _lines(cpp) if ln.strip().startswith(prefix)) + + +def _hoist(cpp: str, n: int) -> str: + return _stmt(cpp, f"const auto _pf_every_bar_ta_{n} = ") + + +def _index(cpp: str, line: str) -> int: + return _lines(cpp).index(line) + + +# --------------------------------------------------------------------------- +# The four lab tv pins (verbatim strategy.pine bodies) + the production shape +# --------------------------------------------------------------------------- + +PIN_RING_LAZYAND = ( + "// v6 lazy `and`: the RHS ta.highest is only evaluated on bars where the LHS holds\n" + "c = bar_index % 7 == 3 and close > ta.highest(high, 5)[1]\n" + "if c\n" + " strategy.entry(\"L\", strategy.long)\n" + "if bar_index % 7 == 4\n" + " strategy.close(\"L\")" +) +PIN_LAZYAND_SMA = ( + "c = bar_index % 7 == 3 and close > ta.sma(close, 5)[1]\n" + "if c\n" + " strategy.entry(\"L\", strategy.long)\n" + "if bar_index % 7 == 4\n" + " strategy.close(\"L\")" +) +PIN_LAZYAND_EMA = ( + "c = bar_index % 7 == 3 and close > ta.ema(close, 5)[1]\n" + "if c\n" + " strategy.entry(\"L\", strategy.long)\n" + "if bar_index % 7 == 4\n" + " strategy.close(\"L\")" +) +PIN_RING_TERNARY = ( + "v = bar_index % 7 == 3 ? ta.highest(high, 5)[1] : na\n" + "if not na(v)\n" + " strategy.entry(\"L\", strategy.long, qty=math.round(v*100))\n" + "if bar_index % 7 == 4\n" + " strategy.close(\"L\")" +) +ROBMAGNAYE_SHAPE = ( + "var bool setupAlive = false\n" + "var int dir = 0\n" + "var bool mssFound = false\n" + "if bar_index % 5 == 0\n" + " setupAlive := true\n" + " dir := close > open ? 1 : -1\n" + "bullMSS = setupAlive and dir == 1 and close > ta.highest(high, 10)[1]\n" + "bearMSS = setupAlive and dir == -1 and close < ta.lowest(low, 10)[1]\n" + "if bullMSS or bearMSS\n" + " mssFound := true\n" + "bullBOS = setupAlive and dir == 1 and mssFound and close > ta.highest(high, 20)[1]\n" + "bearBOS = setupAlive and dir == -1 and mssFound and close < ta.lowest(low, 20)[1]\n" + "if bullBOS or bearBOS\n" + " strategy.entry(\"L\", strategy.long)\n" + " mssFound := false" +) + + +def _assert_every_bar_history_read(cpp: str, n: int, member: str, var: str, + ta_member: str, compute_arg: str, + offset: int = 1) -> None: + """The lazy-edge ``ta.x(...)[k]`` shape: compute + push before, read in.""" + hoist = _hoist(cpp, n) + push = f" if (history_advances_new_bar()) {member}.push(_pf_every_bar_ta_{n});" + update = f" else {member}.update(_pf_every_bar_ta_{n});" + stmt = _stmt(cpp, f"{var} = (") + lines = _lines(cpp) + assert hoist.startswith(" const auto"), hoist + assert f"{ta_member}.compute({compute_arg})" in hoist + assert f"{ta_member}.recompute({compute_arg})" in hoist + assert push in lines and update in lines + assert _index(cpp, hoist) < lines.index(push) < lines.index(update) < _index(cpp, stmt) + assert f"{member}[(int)({offset})]" in stmt + assert ".compute(" not in stmt and ".push(" not in stmt + # Exactly one step of the indicator per bar, in the hoist only. + on_bar = _on_bar(cpp) + assert on_bar.count(f"{ta_member}.compute(") == 1 + assert on_bar.count(f"{member}.push(") == 1 + + +def test_pin_ring_lazyand_highest_advances_every_bar(): + cpp = _cpp(PIN_RING_LAZYAND) + _assert_every_bar_history_read( + cpp, 1, "_hist_call_1", "c", "_ta_highest_1", "current_bar_.high" + ) + c_line = _stmt(cpp, "c = (") + assert "&&" in c_line and "_hist_call_1[(int)(1)]" in c_line + + +def test_pin_lazyand_sma_advances_every_bar(): + cpp = _cpp(PIN_LAZYAND_SMA) + _assert_every_bar_history_read( + cpp, 1, "_hist_call_1", "c", "_ta_sma_1", "current_bar_.close" + ) + + +def test_pin_lazyand_ema_advances_every_bar(): + cpp = _cpp(PIN_LAZYAND_EMA) + _assert_every_bar_history_read( + cpp, 1, "_hist_call_1", "c", "_ta_ema_1", "current_bar_.close" + ) + + +def test_pin_ring_ternary_highest_advances_every_bar(): + cpp = _cpp(PIN_RING_TERNARY) + _assert_every_bar_history_read( + cpp, 1, "_hist_call_1", "v", "_ta_highest_1", "current_bar_.high" + ) + v_line = _stmt(cpp, "v = (") + assert "? (_hist_call_1[(int)(1)]) : (na())" in v_line + + +def test_robmagnaye_mss_bos_windows_advance_every_bar(): + cpp = _cpp(ROBMAGNAYE_SHAPE) + _assert_every_bar_history_read( + cpp, 1, "_hist_call_1", "bullMSS", "_ta_highest_1", "current_bar_.high" + ) + _assert_every_bar_history_read( + cpp, 2, "_hist_call_2", "bearMSS", "_ta_lowest_2", "current_bar_.low" + ) + _assert_every_bar_history_read( + cpp, 3, "_hist_call_3", "bullBOS", "_ta_highest_3", "current_bar_.high" + ) + _assert_every_bar_history_read( + cpp, 4, "_hist_call_4", "bearBOS", "_ta_lowest_4", "current_bar_.low" + ) + # Each hoist sits directly before its own statement, after the setup block. + lines = _lines(cpp) + setup_close = next( + i for i, ln in enumerate(lines) if ln.strip().startswith("dir = ") + ) + assert setup_close < _index(cpp, _hoist(cpp, 1)) < _index(cpp, _stmt(cpp, "bullMSS = (")) + assert _index(cpp, _stmt(cpp, "bullMSS = (")) < _index(cpp, _hoist(cpp, 2)) + + +def test_hoist_is_independent_of_run_mode(): + """Both ``_use_precalc`` branches live in the hoist: dynamic mode (bar + magnifier / input_tf / script_tf, ``_use_precalc = false``) steps the + indicator inline every bar; static mode reads the equivalent precalc.""" + cpp = _cpp(PIN_RING_LAZYAND) + hoist = _hoist(cpp, 1) + assert "_use_precalc ? _precalc__ta_highest_1[bar_index_] :" in hoist + assert "history_advances_new_bar() ? _ta_highest_1.compute(current_bar_.high)" in hoist + assert "if (needs_dynamic) {\n _use_precalc = false;" in cpp + + +# --------------------------------------------------------------------------- +# The bare twins (2026-09-04, same cadence-7 probes on NYSE:F 1D): without a +# history read TradingView runs the call per execution. +# c = bar_index % 7 == 3 and close > ta.sma(close, 5) ring 39/39, every-bar 23/39 +# c = bar_index % 7 == 3 and close > ta.ema(close, 5) ring 39/39, every-bar 26/39 +# v = bar_index % 7 == 3 ? ta.sma(close, 5) : na ring 39/39, every-bar 2/39 +# v = bar_index % 7 == 3 ? ta.highest(high, 5) : na ring 38/39, every-bar 11/39 +# (their ``[1]`` twins: every-bar 39/39 each) +# --------------------------------------------------------------------------- + +PIN_LAZYAND_SMA_BARE = ( + "c = bar_index % 7 == 3 and close > ta.sma(close, 5)\n" + "if c\n" + " strategy.entry(\"L\", strategy.long)\n" + "if bar_index % 7 == 4\n" + " strategy.close(\"L\")" +) +PIN_LAZYAND_EMA_BARE = PIN_LAZYAND_SMA_BARE.replace("ta.sma(close, 5)", "ta.ema(close, 5)") +PIN_TERN_SMA_BARE = ( + "v = bar_index % 7 == 3 ? ta.sma(close, 5) : na\n" + "if not na(v)\n" + " strategy.entry(\"L\", strategy.long, qty = math.round(v * 100))\n" + "if bar_index % 7 == 4\n" + " strategy.close(\"L\")" +) +PIN_TERN_HIGHEST_BARE = PIN_TERN_SMA_BARE.replace("ta.sma(close, 5)", "ta.highest(high, 5)") + + +@pytest.mark.parametrize( + "label, body, member, arg", + [ + ("lazyand-sma-bare", PIN_LAZYAND_SMA_BARE, "_ta_sma_1", "current_bar_.close"), + ("lazyand-ema-bare", PIN_LAZYAND_EMA_BARE, "_ta_ema_1", "current_bar_.close"), + ("tern-sma-bare", PIN_TERN_SMA_BARE, "_ta_sma_1", "current_bar_.close"), + ("tern-highest-bare", PIN_TERN_HIGHEST_BARE, "_ta_highest_1", "current_bar_.high"), + ], +) +def test_bare_pins_keep_the_reached_only_inline_compute(label, body, member, arg): + cpp = _cpp(body) + assert "_pf_every_bar_ta_" not in cpp, label + stmt = _stmt(cpp, "c = (") if body.startswith("c =") else _stmt(cpp, "v = (") + assert f"{member}.compute({arg})" in stmt, label + assert _on_bar(cpp).count(f"{member}.compute(") == 1, label + # #58 behaviour is untouched: an ``and``-RHS sma/ema never precalcs. (A + # ternary-arm sma/ema and a static highest keep main's static-mode + # precalc eligibility; the lanes run dynamic mode.) + if body.startswith("c ="): + assert f"_precalc_{member}" not in cpp, label + + + +# --------------------------------------------------------------------------- +# Shapes: or-RHS, ternary arms, nesting depth, if conditions, expression stmts +# --------------------------------------------------------------------------- + + +def test_or_rhs_and_not_and_nested_comparison_depth(): + cpp = _cpp( + "pred = close > open\n" + "a = pred or close > ta.sma(close, 5)[1]\n" + "b = not (pred and (close > open or ta.lowest(low, 14)[1] < close))\n" + "c = pred and (bar_index > 5 and (close - ta.ema(close, 9)[2]) > 0)\n" + "plot(a or b or c ? 1 : 0)" + ) + for n, member, arg in ( + (1, "_ta_sma_1", "current_bar_.close"), + (2, "_ta_lowest_2", "current_bar_.low"), + (3, "_ta_ema_3", "current_bar_.close"), + ): + assert f"{member}.compute({arg})" in _hoist(cpp, n) + for var, n, offset in (("a", 1, 1), ("b", 2, 1), ("c", 3, 2)): + line = _stmt(cpp, f"{var} = ") + assert f"_hist_call_{n}[(int)({offset})]" in line and ".compute(" not in line + assert _stmt(cpp, "b = ").strip().startswith("b = !((pred &&") + + + +def test_ternary_both_arms_and_condition_stays_eager(): + cpp = _cpp( + "pred = close > open\n" + "x = pred ? ta.sma(close, 5)[1] : ta.ema(close, 5)[1]\n" + "y = ta.rsi(close, 14) > 50 ? close : open\n" + "plot(x + y)" + ) + assert "_ta_sma_1.compute" in _hoist(cpp, 1) + assert "_ta_ema_2.compute" in _hoist(cpp, 2) + x_line = _stmt(cpp, "x = (") + assert "_hist_call_1[(int)(1)]" in x_line and "_hist_call_2[(int)(1)]" in x_line + assert ".compute(" not in x_line + # The ternary condition is an eager operand: evaluated every bar already. + y_line = _stmt(cpp, "y = (") + assert "_ta_rsi_3.compute" in y_line and "_pf_every_bar_ta_" not in y_line + assert len(re.findall(r"const auto _pf_every_bar_ta_\d+ = ", cpp)) == 2 + + + +def test_nested_history_read_hoists_inner_only_and_outer_reads_its_series(): + """``ta.sma(ta.highest(high, 5)[1], 3)``: the inner call's history is read, + so it advances every bar; the outer SMA has no history read and keeps the + reached-only inline compute over the hoisted Series.""" + cpp = _cpp( + "pred = close > open\n" + "s = pred and ta.sma(ta.highest(high, 5)[1], 3) > close\n" + "plot(s ? 1 : 0)" + ) + inner = _hoist(cpp, 1) + push = " if (history_advances_new_bar()) _hist_call_1.push(_pf_every_bar_ta_1);" + assert "_ta_highest_1.compute(current_bar_.high)" in inner + lines = _lines(cpp) + s_line = _stmt(cpp, "s = (") + assert _index(cpp, inner) < lines.index(push) < _index(cpp, s_line) + assert "_ta_sma_2.compute(_hist_call_1[(int)(1)])" in s_line + assert "_pf_every_bar_ta_2" not in cpp + + +def test_top_level_if_condition_hoists_but_else_if_condition_does_not(): + cpp = _cpp( + "pred = close > open\n" + "if pred and close > ta.highest(high, 5)[1]\n" + " strategy.entry(\"L\", strategy.long)\n" + "else if pred and close < ta.lowest(low, 5)[1]\n" + " strategy.close(\"L\")" + ) + hoist = _hoist(cpp, 1) + assert "_ta_highest_1.compute(current_bar_.high)" in hoist + conds = [ln for ln in _lines(cpp) if ln.strip().startswith("if ((pred &&")] + assert len(conds) == 2 + if_line, else_if = conds + assert "_hist_call_1[(int)(1)]" in if_line and ".compute(" not in if_line + assert _index(cpp, hoist) < _index(cpp, if_line) + # ``else if`` is an ``if`` inside the else local block: execution-gated, + # so it keeps the in-block compute + call-local history push. + assert _lines(cpp)[_index(cpp, else_if) - 1].strip() == "} else" + assert "_ta_lowest_2.compute(current_bar_.low)" in else_if + assert "_hist_call_2.push(_hv)" in else_if + assert "_pf_every_bar_ta_2" not in cpp + + + +def test_expression_statement_and_strategy_call_arguments_hoist(): + cpp = _cpp( + "pred = close > open\n" + "plot(pred ? ta.sma(close, 5)[1] : na)\n" + "if pred\n" + " strategy.entry(\"L\", strategy.long)\n" + "strategy.close(\"L\", when = pred and ta.ema(close, 9)[1] > close)" + ) + assert "_ta_sma_1.compute(current_bar_.close)" in _hoist(cpp, 1) + assert "_ta_ema_2.compute(current_bar_.close)" in _hoist(cpp, 2) + on_bar = _on_bar(cpp) + assert on_bar.count("_ta_sma_1.compute(") == 1 + assert on_bar.count("_ta_ema_2.compute(") == 1 + + + +def test_assignment_values_hoist(): + cpp = _cpp( + "pred = close > open\n" + "x = 0.0\n" + "x := pred ? ta.sma(close, 5)[1] : x\n" + "plot(x)" + ) + assert "_ta_sma_1.compute(current_bar_.close)" in _hoist(cpp, 1) + x_line = _stmt(cpp, "x = ((pred)") + assert "_hist_call_1[(int)(1)]" in x_line and ".compute(" not in x_line + + +def test_allow_listed_family_without_history_read_keeps_inline_lowering(): + """The five 2026-09-04 hard-lane shapes: exact at 100% on the reached-only + inline compute, weak when hoisted. No ``[k]`` read -> no hoist.""" + cpp = _cpp( + "[plusDI, minusDI, adxVal] = ta.dmi(14, 14)\n" + "isAdxStrong = adxVal > 30 and adxVal > ta.sma(adxVal, 7)\n" + "isSpring = close > open and (volume < ta.sma(volume, 20))\n" + "baseLong = ta.mom(close, 20) > 0 and ta.change(ta.sma(close, 50)) > 0\n" + "emaX = ta.crossover(close, ta.ema(close, 200)) or low <= ta.ema(close, 200) and high >= ta.ema(close, 200)\n" + "timeExit = bar_index > 3 and ta.highest(high, 10) / close > 1.05\n" + "plot(isAdxStrong or isSpring or baseLong or emaX or timeExit ? 1 : 0)" + ) + assert "_pf_every_bar_ta_" not in cpp + assert "_ta_sma_" in _stmt(cpp, "isAdxStrong = (") and ".compute(adxVal)" in _stmt(cpp, "isAdxStrong = (") + assert ".compute(current_bar_.volume)" in _stmt(cpp, "isSpring = (") + assert ".compute(current_bar_.close)" in _stmt(cpp, "baseLong = (") + assert _stmt(cpp, "emaX = (").count("_ta_ema_") >= 3 + assert "_ta_highest_" in _stmt(cpp, "timeExit = (") + + +# --------------------------------------------------------------------------- +# Not hoisted: block bodies, UDF bodies, var initializers, pinned exceptions +# --------------------------------------------------------------------------- + +def test_if_body_and_loop_body_sites_keep_execution_gated_lowering(): + cpp = _cpp( + "pred = close > open\n" + "x = 0.0\n" + "if pred\n" + " x := close > open and close > ta.highest(high, 5)[1] ? 1.0 : 0.0\n" + "for i = 0 to 1\n" + " x += pred and ta.sma(close, 5) > close ? 1.0 : 0.0\n" + "plot(x)" + ) + assert "_pf_every_bar_ta_" not in cpp + on_bar = _on_bar(cpp) + # In-block compute + call-local history push, reached only when executed. + assert "_ta_highest_1.compute(current_bar_.high)" in on_bar + assert "if (history_advances_new_bar()) _hist_call_1.push(_hv)" in on_bar + assert "_ta_sma_2.compute(current_bar_.close)" in on_bar + + +def test_user_function_body_sites_are_not_hoisted(): + cpp = _cpp( + "f(p) =>\n" + " p and close > ta.highest(high, 5)[1]\n" + "pred = close > open\n" + "s = f(pred)\n" + "plot(s ? 1 : 0)" + ) + assert "_pf_every_bar_ta_" not in cpp + assert "_ta_highest_1.compute(current_bar_.high)" in cpp + + +def test_var_initializer_is_not_hoisted(): + cpp = _cpp( + "pred = close > open\n" + "var float seed = pred ? ta.sma(close, 5) : close\n" + "plot(seed)" + ) + assert "_pf_every_bar_ta_" not in cpp + + +# Per-family clocks below a lazy edge, pinned 2026-09-03 with cadence-7 ternary +# probes on NYSE:F 1D (``v = bar_index % 7 == 3 ? : na``, value exposed +# through the entry size) plus lazy-``and`` probes: +# hold-last source roc 38/38 (+39/39 entries), change 39/39, mom 39/39 +# (every-bar 0/38..0/39; ring-of-executions 0..1/39) +# per-execution cum, barssince, valuewhen, cross, crossover, rising 39/39, +# math.sum 39/39 (every-bar 0..31/39) + + +def test_source_clock_families_are_not_hoisted_and_use_held_source_history(): + cpp = _cpp( + "gate = close > open\n" + "longish = gate and ta.roc(close, 3) > 0\n" + "ch = gate ? ta.change(close, 3) : na\n" + "mo = gate or ta.mom(close, 3) > 0\n" + "plot(longish ? ch : mo ? 1 : 0)" + ) + assert "_pf_every_bar_ta_" not in cpp + assert "struct _PFLazySourceClock {" in cpp + for var, n, method in (("longish", 1, "roc"), ("ch", 2, "change"), ("mo", 3, "change")): + line = _stmt(cpp, f"{var} = (") + assert ( + f"_pf_lazy_src_clock_{n}.{method}(current_bar_.close, " + f"_pf_lazy_src_clock_{n}.previous_source(_pf_lazy_src_hist_{n}[2], " + f"_pf_lazy_src_chart_{n}[3], 3, bar_index_))" + ) in line + assert f"Series _pf_lazy_src_hist_{n}{{4}};" in cpp + assert f"Series _pf_lazy_src_chart_{n}{{4}};" in cpp + assert "_precalc__ta_roc" not in cpp + assert "_precalc__ta_change" not in cpp + assert "_precalc__ta_mom" not in cpp + + +def test_per_execution_families_keep_inline_compute_and_never_precalc(): + cpp = _cpp( + "sma5 = ta.sma(close, 5)\n" + "gate = bar_index % 7 == 3\n" + "a = gate ? ta.cum(close) : na\n" + "b = gate ? ta.barssince(close > sma5) : na\n" + "c = gate ? ta.valuewhen(close > sma5, close, 0) : na\n" + "d = gate and ta.crossover(close, sma5)\n" + "e = gate and ta.cross(close, sma5)\n" + "f = gate and ta.rising(close, 3)\n" + "g = gate ? math.sum(close, 3) : na\n" + "plot(a + b + c + g + (d or e or f ? 1 : 0))" + ) + assert "_pf_every_bar_ta_" not in cpp + assert "_PFLazySourceClock" not in cpp + on_bar = _on_bar(cpp) + for var, member in ( + ("a", "_ta_cum_2"), ("b", "_ta_barssince_3"), ("c", "_ta_valuewhen_4"), + ("d", "_ta_crossover_5"), ("e", "_ta_cross_6"), ("f", "_ta_rising_7"), + ("g", "_ta_sum_8"), + ): + line = _stmt(cpp, f"{var} = (") + assert f"{member}.compute(" in line, (var, line) + assert "_use_precalc" not in line + assert f"_precalc_{member}" not in cpp + # The unconditional sma5 is untouched (eager, precalc-eligible). + assert "std::vector _precalc__ta_sma_1" in cpp + assert on_bar.count("_ta_sma_1.compute(") == 1 + + +def test_security_payload_sites_are_not_hoisted(): + cpp = _cpp( + "pred = close > open\n" + "htf = request.security(syminfo.tickerid, \"D\", pred and close > ta.sma(close, 5))\n" + "plot(htf ? 1 : 0)" + ) + assert "_pf_every_bar_ta_" not in cpp + assert "_sec0__ta_sma_1.compute(bar.close)" in cpp + + +def test_unpinned_families_keep_their_existing_lowering(): + """Allow-list: a family without an every-bar tape is neither hoisted nor + re-routed (2026-09-04 Cloud Run measurement of the broad hoist: -170 + tiers / 30 hard lanes). Inline compute when reached; precalc as before.""" + cpp = _cpp( + "pred = close > open\n" + "a = pred and ta.rsi(close, 14) > 50\n" + "b = pred ? ta.atr(14) : na\n" + "c = pred or ta.stdev(close, 20) > 1\n" + "d = pred and ta.wma(close, 9) > close\n" + "plot(a or c or d ? b : 0)" + ) + assert "_pf_every_bar_ta_" not in cpp + assert "_PFLazySourceClock" not in cpp + for var, member in (("a", "_ta_rsi_1"), ("b", "_ta_atr_2"), ("c", "_ta_stdev_3"), ("d", "_ta_wma_4")): + assert f"{member}.compute(" in _stmt(cpp, f"{var} = ("), var + # Static sites keep main's precalc eligibility (static mode only). + assert "std::vector _precalc__ta_rsi_1" in cpp + + +def test_eager_top_level_sites_are_unchanged(): + cpp = _cpp( + "m = ta.sma(close, 5)\n" + "h = ta.highest(high, 5)[1]\n" + "c = close > ta.ema(close, 9) and close > open\n" + "plot(m + h + (c ? 1 : 0))" + ) + assert "_pf_every_bar_ta_" not in cpp + + +def test_hoist_plan_is_deterministic(): + assert _cpp(ROBMAGNAYE_SHAPE) == _cpp(ROBMAGNAYE_SHAPE) + + +# --------------------------------------------------------------------------- +# Compile-only (engine headers) and executable synthetic-bars checks +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "label, body", + [ + ("ring-lazyand", PIN_RING_LAZYAND), + ("lazyand-sma", PIN_LAZYAND_SMA), + ("lazyand-ema", PIN_LAZYAND_EMA), + ("ring-ternary", PIN_RING_TERNARY), + ("robmagnaye-shape", ROBMAGNAYE_SHAPE), + ], +) +def test_pins_compile_against_engine_headers(label, body): + compile_env.compile_cpp(_cpp(body), label=f"lazy-edge {label}") + + +_RUNTIME_PINE = _HEADER + """var int andHits = 0 +var int ternaryHits = 0 +var int smaHits = 0 +c = bar_index % 7 == 3 and close > ta.highest(high, 5)[1] +v = bar_index % 7 == 3 ? ta.highest(high, 5)[1] : na +s = bar_index % 7 == 3 and close > ta.sma(close, 5)[1] +if c + andHits += 1 +if not na(v) and close > v + ternaryHits += 1 +if s + smaHits += 1 +""" + +# 30 synthetic bars (high == close). Under the every-bar model the lazy RHS +# windows are fully warm at every ``bar_index % 7 == 3`` bar from 10 on: +# bar 10: close 120 > highest/sma(bars 5..9) -> hit +# bar 17: close 100 < highest/sma(bars 12..16) -> no hit +# bar 24: close 101 > highest/sma(bars 19..23) = 100 -> hit +# so every counter is 2. Under the per-call model the indicator has seen at +# most four samples (bars 3, 10, 17, 24) and ``[1]`` is still na: 0 hits. +_RUNTIME_CLOSES = ( + [100.0] * 5 + + [110.0, 111.0, 112.0, 113.0, 114.0] + + [120.0] + + [130.0, 129.0, 128.0, 127.0, 126.0, 125.0] + + [100.0] + + [100.0] * 6 + + [101.0] + + [100.0] * 5 +) +assert len(_RUNTIME_CLOSES) == 30 + +_RUNTIME_DRIVER = r""" +#include + +static Bar make_bar(double close, int64_t timestamp) { + return Bar{close, close, close, close, 1.0, timestamp}; +} + +int main() { + const double closes[] = {%(closes)s}; + const int n = sizeof(closes) / sizeof(closes[0]); + Bar bars[30]; + for (int i = 0; i < n; ++i) { + bars[i] = make_bar(closes[i], 1000 + static_cast(i) * 60000); + } + + GeneratedStrategy precalc; + precalc.run(bars, n); // static mode: _use_precalc path + + GeneratedStrategy dynamic; + dynamic.run(bars, n, "1", "1"); // dynamic mode: inline every-bar step + + std::cout << precalc.andHits << ' ' << precalc.ternaryHits << ' ' + << precalc.smaHits << ' ' << dynamic.andHits << ' ' + << dynamic.ternaryHits << ' ' << dynamic.smaHits << '\n'; + return 0; +} +""" + + +def _find_engine_library() -> Path | None: + explicit = os.environ.get("PINEFORGE_ENGINE_LIB") + if explicit: + path = Path(explicit).expanduser().resolve() + return path if path.is_file() else None + engine_inc = compile_env._ENGINE_INC + if engine_inc is None: + return None + candidates: list[Path] = [] + for pattern in ("build*/lib/libpineforge.a", "build*/lib/libpineforge.dylib"): + candidates.extend(sorted(engine_inc.parent.glob(pattern))) + return candidates[0].resolve() if candidates else None + + +def _compile_and_run(source: str) -> str: + compile_env.skip_if_no_compile_env() + engine_lib = _find_engine_library() + if engine_lib is None: + pytest.skip("set PINEFORGE_ENGINE_LIB to a built PineForge engine library") + compiler = compile_env._COMPILER + engine_inc = compile_env._ENGINE_INC + eigen_inc = compile_env._EIGEN_INC + assert compiler is not None and engine_inc is not None and eigen_inc is not None + with tempfile.TemporaryDirectory(prefix="pineforge-lazy-edge-") as tmp: + cpp = Path(tmp) / "lazy_edge.cpp" + exe = Path(tmp) / "lazy_edge" + cpp.write_text(source) + command = [compiler, "-std=c++17", "-O0", "-I", str(engine_inc), "-I", str(eigen_inc)] + if compile_env._GENERATED_INC is not None: + command += ["-I", str(compile_env._GENERATED_INC)] + command += [str(cpp), str(engine_lib), "-pthread", "-o", str(exe)] + built = subprocess.run(command, capture_output=True, text=True, timeout=180) + if built.returncode != 0: + raise AssertionError(built.stderr or built.stdout) + ran = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) + if ran.returncode != 0: + raise AssertionError(ran.stderr or ran.stdout) + return ran.stdout.strip() + + +def test_synthetic_bars_lazy_edge_windows_are_every_bar_in_both_run_modes(): + """Executable check on synthetic bars (no feed): the per-call model would + print ``0 0 0 0 0 0``; the every-bar rule gives 2 hits per shape in the + precalc run and in the dynamic (input_tf/script_tf) run alike.""" + driver = _RUNTIME_DRIVER % { + "closes": ", ".join(f"{c:.1f}" for c in _RUNTIME_CLOSES) + } + assert _compile_and_run(transpile(_RUNTIME_PINE) + driver) == "2 2 2 2 2 2" diff --git a/tests/test_lazy_saturated_roc.py b/tests/test_lazy_saturated_roc.py deleted file mode 100644 index eff433d..0000000 --- a/tests/test_lazy_saturated_roc.py +++ /dev/null @@ -1,198 +0,0 @@ -from __future__ import annotations - -import re - -from pineforge_codegen import transpile - - -def _cpp(body: str, *, header: str = "") -> str: - return transpile(f'//@version=6\nstrategy("lazy roc"{header})\n{body}\n') - - -def test_plain_and_rhs_direct_close_literal_three_gets_one_clock_per_callsite(): - cpp = _cpp( - "gate = close > open\n" - "longish = gate and ta.roc(close, 3) > 0\n" - "shortish = gate and ta.roc(close, 3) < 0\n" - "plot(longish ? 1 : shortish ? -1 : 0)" - ) - - assert "struct _PFLazySaturatedROC3Clock" in cpp - clocks = re.findall( - r"^ _PFLazySaturatedROC3Clock (_pf_lazy_saturated_roc3_clock_\d+);$", - cpp, - re.MULTILINE, - ) - assert clocks == [ - "_pf_lazy_saturated_roc3_clock_1", - "_pf_lazy_saturated_roc3_clock_2", - ] - assert "std::vector _precalc__ta_roc" not in cpp - assert "Series _pf_lazy_saturated_roc3_close_history{4};" in cpp - assert "_pf_lazy_saturated_roc3_close_history[3]" in cpp - assert cpp.count( - ".evaluate(current_bar_.close, " - "_pf_lazy_saturated_roc3_close_history[3], bar_index_)" - ) == 2 - - -def test_clock_has_saturated_q1_eager_fallback_and_same_bar_base_contract(): - cpp = _cpp("x = close > open and ta.roc(close, 3) > 0") - helper = cpp.split("struct _PFLazySaturatedROC3Clock", 1)[1].split("};", 1)[0] - - assert "if (working_bar != bar)" in helper - assert "bar_base_source = committed_source;" in helper - assert "bar_base_bar = committed_bar;" in helper - assert "bar - bar_base_bar >= 3" in helper - assert "saturated ? bar_base_source : eager_previous" in helper - assert "committed_source = source;" in helper - assert "committed_bar = bar;" in helper - assert "void reset()" in helper - for reset in ( - "committed_source = na();", - "committed_bar = -1;", - "bar_base_source = na();", - "bar_base_bar = -1;", - "working_bar = -1;", - ): - assert reset in helper - - -def test_clock_member_is_automatically_checkpointed_for_coof(): - cpp = _cpp( - "x = close > open and ta.roc(close, 3) > 0", - header=", calc_on_order_fills=true", - ) - match = re.search( - r"decltype\(GeneratedStrategy::(_pf_lazy_saturated_roc3_clock_1)\) " - r"_pf_value_(\d+);", - cpp, - ) - assert match is not None - member, index = match.groups() - assert re.search(rf"^ {member},$", cpp, re.MULTILINE) - assert ( - f"this->{member} = _pf_script_state_checkpoint_->_pf_value_{index};" - in cpp - ) - history_match = re.search( - r"decltype\(GeneratedStrategy::" - r"(_pf_lazy_saturated_roc3_close_history)\) _pf_value_(\d+);", - cpp, - ) - assert history_match is not None - history, history_index = history_match.groups() - assert re.search(rf"^ {history},$", cpp, re.MULTILINE) - assert ( - f"this->{history} = " - f"_pf_script_state_checkpoint_->_pf_value_{history_index};" - in cpp - ) - - -def test_on_bar_resets_clocks_and_fallback_history_before_first_push(): - cpp = _cpp("x = close > open and ta.roc(close, 3) > 0") - on_bar = cpp.split("void on_bar(const Bar& bar) override {", 1)[1].split( - "\n }", 1 - )[0] - reset_guard = "if (history_advances_new_bar() && bar_index_ == 0) {" - assert reset_guard in on_bar - assert "_pf_lazy_saturated_roc3_clock_1.reset();" in on_bar - assert "_pf_lazy_saturated_roc3_close_history.clear();" in on_bar - history_push = "_pf_lazy_saturated_roc3_close_history.push(current_bar_.close)" - assert history_push in on_bar - assert on_bar.index(reset_guard) < on_bar.index(history_push) - - -def test_non_oracle_shapes_keep_existing_precalc_route(): - cases = { - "eager": "x = ta.roc(close, 3) > 0", - "other_source": "x = close > open and ta.roc(open, 3) > 0", - "other_length": "x = close > open and ta.roc(close, 4) > 0", - "or_shape": "x = close > open and (high > low or ta.roc(close, 3) > 0)", - "ternary_shape": "x = close > open and (high > low ? ta.roc(close, 3) : 0) > 0", - "udf": ( - "f() =>\n" - " close > open and ta.roc(close, 3) > 0\n" - "x = f()" - ), - "security": ( - 'x = close > open and request.security(syminfo.tickerid, "60", ' - "close > open and ta.roc(close, 3) > 0)" - ), - "if_body": ( - "x = false\n" - "if close > open\n" - " x := high > low and ta.roc(close, 3) > 0" - ), - "loop_body": ( - "x = false\n" - "for i = 0 to 1\n" - " x := high > low and ta.roc(close, 3) > 0" - ), - } - for label, source in cases.items(): - cpp = _cpp(source) - assert "_PFLazySaturatedROC3Clock" not in cpp, label - assert "ta::ROC _ta_roc" in cpp, label - assert "_pf_lazy_saturated_roc3_close_history[3]" not in cpp, label - - -def test_named_length_is_not_silently_widened_into_literal_shape(): - cpp = _cpp("gate = close > open\nx = gate and ta.roc(source=close, length=3) > 0") - assert "_PFLazySaturatedROC3Clock" not in cpp - assert "std::vector _precalc__ta_roc" in cpp - - -def test_user_shadowed_close_stays_on_existing_eager_route(): - cpp = _cpp( - "float close = open\n" - "gate = bar_index == 0 or bar_index == 5\n" - "signal = gate and ta.roc(close, 3) > 0" - ) - assert "_PFLazySaturatedROC3Clock" not in cpp - assert "std::vector _precalc__ta_roc" in cpp - - -def test_generated_type_clock_and_history_names_avoid_pine_collisions(): - cpp = _cpp( - "type _PFLazySaturatedROC3Clock\n" - " float value\n" - "float _pf_lazy_saturated_roc3_clock_1 = 0.0\n" - "float _pf_lazy_saturated_roc3_close_history = 0.0\n" - "gate = close > open\n" - "signal = gate and ta.roc(close, 3) > 0" - ) - assert cpp.count("struct _PFLazySaturatedROC3Clock {") == 1 - assert "struct _PFLazySaturatedROC3Clock_2 {" in cpp - assert ( - "_PFLazySaturatedROC3Clock_2 " - "_pf_lazy_saturated_roc3_clock_1_2;" - ) in cpp - assert ( - "Series _pf_lazy_saturated_roc3_close_history_2{4};" - in cpp - ) - assert ( - "_pf_lazy_saturated_roc3_clock_1_2.evaluate(current_bar_.close, " - "_pf_lazy_saturated_roc3_close_history_2[3], bar_index_)" - in cpp - ) - - -def test_generated_clock_name_avoids_emitted_udf_method_name(): - cpp = _cpp( - "_pf_lazy_saturated_roc3_clock_1() => 1.0\n" - "other = _pf_lazy_saturated_roc3_clock_1()\n" - "signal = close > open and ta.roc(close, 3) > 0" - ) - assert "double _pf_lazy_saturated_roc3_clock_1()" in cpp - assert ( - "_PFLazySaturatedROC3Clock " - "_pf_lazy_saturated_roc3_clock_1_2;" - ) in cpp - assert ( - "_pf_lazy_saturated_roc3_clock_1_2.evaluate(current_bar_.close, " - "_pf_lazy_saturated_roc3_close_history[3], bar_index_)" - in cpp - ) diff --git a/tests/test_lazy_saturated_roc_runtime.py b/tests/test_lazy_saturated_roc_runtime.py deleted file mode 100644 index 3ce37f6..0000000 --- a/tests/test_lazy_saturated_roc_runtime.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Executable lifecycle coverage for generated lazy saturated ROC clocks.""" - -from __future__ import annotations - -import os -import subprocess -import tempfile -from pathlib import Path - -import pytest - -from pineforge_codegen import transpile -from tests import _compile as compile_env - - -_PINE = """//@version=6 -strategy("lazy ROC reuse") -gate = bar_index == 5 or (bar_index == 0 and close > 90) -signal = gate and ta.roc(close, 3) > 0 -""" - - -_DRIVER = r""" -#include - -static Bar make_bar(double close, int64_t timestamp) { - return Bar{close, close, close, close, 1.0, timestamp}; -} - -int main() { - Bar first[6]; - Bar second[6]; - for (int i = 0; i < 6; ++i) { - first[i] = make_bar(100.0 + i * 20.0, 1000 + i * 60000); - second[i] = make_bar(10.0 + i * 8.0, 2000 + i * 60000); - } - - GeneratedStrategy reused; - reused.run(first, 6); - const int first_signal = reused.signal ? 1 : 0; - reused.run(second, 6); - const int reused_signal = reused.signal ? 1 : 0; - - GeneratedStrategy fresh; - fresh.run(second, 6); - const int fresh_signal = fresh.signal ? 1 : 0; - - std::cout << first_signal << ' ' << reused_signal << ' ' - << fresh_signal << ' ' - << reused._pf_lazy_saturated_roc3_clock_1.bar_base_bar << ' ' - << fresh._pf_lazy_saturated_roc3_clock_1.bar_base_bar << '\n'; - return 0; -} -""" - - -_STREAM_DRIVER = r""" -#include - -static Bar make_bar(double close, int64_t timestamp) { - return Bar{close, close, close, close, 1.0, timestamp}; -} - -int main() { - Bar first[4]; - Bar second[4]; - for (int i = 0; i < 4; ++i) { - first[i] = make_bar(100.0 + i * 20.0, 1000 + i * 60000); - second[i] = make_bar(10.0 + i * 8.0, 1000000 + i * 60000); - } - - GeneratedStrategy reused; - const bool began_first = reused.stream_begin(first, 4, "1", "1"); - const bool tick_first_4 = reused.stream_push_tick( - TradeTick{241123, 1, 180.0, 1.0}); - const bool tick_first_5 = reused.stream_push_tick( - TradeTick{301123, 2, 200.0, 1.0}); - const bool tick_first_6 = reused.stream_push_tick( - TradeTick{361123, 3, 210.0, 1.0}); - const int first_signal = reused.signal ? 1 : 0; - const int first_base = reused._pf_lazy_saturated_roc3_clock_1.bar_base_bar; - const bool ended_first = reused.stream_end(); - - const bool began_second = reused.stream_begin(second, 4, "1", "1"); - const bool tick_second_4 = reused.stream_push_tick( - TradeTick{1240123, 1, 42.0, 1.0}); - const bool tick_second_5 = reused.stream_push_tick( - TradeTick{1300123, 2, 50.0, 1.0}); - const bool tick_second_6 = reused.stream_push_tick( - TradeTick{1360123, 3, 58.0, 1.0}); - const int reused_signal = reused.signal ? 1 : 0; - const int reused_base = reused._pf_lazy_saturated_roc3_clock_1.bar_base_bar; - const bool ended_second = reused.stream_end(); - - GeneratedStrategy fresh; - const bool began_fresh = fresh.stream_begin(second, 4, "1", "1"); - const bool tick_fresh_4 = fresh.stream_push_tick( - TradeTick{1240123, 1, 42.0, 1.0}); - const bool tick_fresh_5 = fresh.stream_push_tick( - TradeTick{1300123, 2, 50.0, 1.0}); - const bool tick_fresh_6 = fresh.stream_push_tick( - TradeTick{1360123, 3, 58.0, 1.0}); - const int fresh_signal = fresh.signal ? 1 : 0; - const int fresh_base = fresh._pf_lazy_saturated_roc3_clock_1.bar_base_bar; - const bool ended_fresh = fresh.stream_end(); - - std::cout << began_first << tick_first_4 << tick_first_5 << tick_first_6 - << ended_first << began_second << tick_second_4 << tick_second_5 - << tick_second_6 << ended_second << began_fresh << tick_fresh_4 - << tick_fresh_5 << tick_fresh_6 << ended_fresh << ' ' - << first_signal << ' ' << reused_signal << ' ' << fresh_signal - << ' ' << first_base << ' ' << reused_base << ' ' << fresh_base - << '\n'; - return 0; -} -""" - - -def _find_engine_library() -> Path | None: - explicit = os.environ.get("PINEFORGE_ENGINE_LIB") - if explicit: - path = Path(explicit).expanduser().resolve() - return path if path.is_file() else None - engine_inc = compile_env._ENGINE_INC - if engine_inc is None: - return None - candidates: list[Path] = [] - for pattern in ("build*/lib/libpineforge.a", "build*/lib/libpineforge.dylib"): - candidates.extend(sorted(engine_inc.parent.glob(pattern))) - return candidates[0].resolve() if candidates else None - - -def _compile_and_run(source: str) -> str: - compile_env.skip_if_no_compile_env() - engine_lib = _find_engine_library() - if engine_lib is None: - pytest.skip("set PINEFORGE_ENGINE_LIB to a built PineForge engine library") - compiler = compile_env._COMPILER - engine_inc = compile_env._ENGINE_INC - eigen_inc = compile_env._EIGEN_INC - assert compiler is not None and engine_inc is not None and eigen_inc is not None - - with tempfile.TemporaryDirectory(prefix="pineforge-lazy-roc-reuse-") as tmp: - cpp = Path(tmp) / "reuse.cpp" - exe = Path(tmp) / "reuse" - cpp.write_text(source) - command = [ - compiler, - "-std=c++17", - "-O0", - "-I", - str(engine_inc), - "-I", - str(eigen_inc), - ] - if compile_env._GENERATED_INC is not None: - command += ["-I", str(compile_env._GENERATED_INC)] - command += [str(cpp), str(engine_lib), "-pthread", "-o", str(exe)] - built = subprocess.run(command, capture_output=True, text=True, timeout=120) - if built.returncode != 0: - raise AssertionError(built.stderr or built.stdout) - ran = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) - if ran.returncode != 0: - raise AssertionError(ran.stderr or ran.stdout) - return ran.stdout.strip() - - -def test_same_handle_second_batch_run_matches_fresh_clock_state(): - # Without the bar-zero lifecycle reset, the second run sees working_bar=5 - # from run one, reuses its bar-zero base, and produces `1 0 1 0 -1`. - assert _compile_and_run(transpile(_PINE) + _DRIVER) == "1 1 1 -1 -1" - - -def test_same_handle_second_stream_warmup_matches_fresh_clock_state(): - # stream_begin enters BacktestEngine::run directly, so this specifically - # proves the reset is in generated on_bar rather than only in a wrapper. - assert _compile_and_run(transpile(_PINE) + _STREAM_DRIVER) == ( - "111111111111111 1 1 1 0 -1 -1" - ) - - -def test_clock_numeric_first_short_gaps_saturation_same_bar_and_na_zero(): - driver = r''' -#include -#include -#include - -int main() { - _PFLazySaturatedROC3Clock clock; - const double first = clock.evaluate(100.0, 80.0, 0); - const double gap1 = clock.evaluate(110.0, 90.0, 1); - const double gap2 = clock.evaluate(120.0, 100.0, 3); - const double gap3 = clock.evaluate(150.0, 140.0, 6); - const double same = clock.evaluate(180.0, 170.0, 6); - const double next = clock.evaluate(198.0, 190.0, 9); - - _PFLazySaturatedROC3Clock na_clock; - const double na_source = na_clock.evaluate(na(), 100.0, 0); - _PFLazySaturatedROC3Clock zero_clock; - (void)zero_clock.evaluate(0.0, 100.0, 0); - const double zero_previous = zero_clock.evaluate(10.0, 9.0, 3); - - std::cout << std::setprecision(17) - << first << ' ' << gap1 << ' ' << gap2 << ' ' - << gap3 << ' ' << same << ' ' << next << ' ' - << std::isnan(na_source) << ' ' - << std::isnan(zero_previous) << '\n'; - return 0; -} -''' - raw = _compile_and_run(transpile(_PINE) + driver).split() - assert len(raw) == 8 - observed = tuple(float(value) for value in raw[:6]) - assert observed == pytest.approx( - (25.0, 100.0 * 20.0 / 90.0, 20.0, 25.0, 50.0, 10.0), - rel=0.0, - abs=1e-12, - ) - assert raw[6:] == ["1", "1"] - - -def test_shadowed_close_preserves_existing_eager_full_bar_route(): - pine = '''//@version=6 -strategy("shadowed close eager") -float close = open -gate = bar_index == 0 or bar_index == 5 -signal = gate and ta.roc(close, 3) > 0 -''' - driver = r''' -#include -int main() { - Bar bars[6] = { - Bar{100, 100, 100, 100, 1, 1000}, - Bar{20, 20, 20, 20, 1, 2000}, - Bar{10, 10, 10, 10, 1, 3000}, - Bar{20, 20, 20, 20, 1, 4000}, - Bar{30, 30, 30, 30, 1, 5000}, - Bar{50, 50, 50, 50, 1, 6000}, - }; - GeneratedStrategy strategy; - strategy.run(bars, 6); - std::cout << (strategy.signal ? 1 : 0) << '\n'; - return 0; -} -''' - assert _compile_and_run(transpile(pine) + driver) == "1" - - -@pytest.mark.parametrize("cap", [1, 2, 3, 4]) -def test_eager_fallback_has_four_slots_independent_of_max_bars_back(cap: int): - pine = f'''//@version=6 -strategy("lazy ROC cap", max_bars_back={cap}) -signal = bar_index == 3 and ta.roc(close, 3) > 0 -''' - driver = r''' -#include -int main() { - Bar bars[4] = { - Bar{10, 10, 10, 10, 1, 1000}, - Bar{20, 20, 20, 20, 1, 2000}, - Bar{30, 30, 30, 30, 1, 3000}, - Bar{40, 40, 40, 40, 1, 4000}, - }; - GeneratedStrategy strategy; - strategy.run(bars, 4); - std::cout << (strategy.signal ? 1 : 0) << '\n'; - return 0; -} -''' - assert _compile_and_run(transpile(pine) + driver) == "1" diff --git a/tests/test_lazy_source_clock.py b/tests/test_lazy_source_clock.py new file mode 100644 index 0000000..300f478 --- /dev/null +++ b/tests/test_lazy_source_clock.py @@ -0,0 +1,266 @@ +"""Hold-last source clock for ``ta.change`` / ``ta.mom`` / ``ta.roc`` below a lazy edge. + +Pinned 2026-09-03 with ``lab tv`` on NYSE:F 1D (range 2025-04-01..2026-05-01, +cadence-7 probes, value exposed through the entry size). TradingView computes +these three from the CALL'S OWN ``source[length]`` history: the source is +written only on bars where the call executes, the last executed value is held +on the bars it skips, and the history is na before the first execution: + +* ``v = bar_index % 7 == 3 ? ta.roc(close, 3) : na`` -> 38/38 by value + (``... and ta.roc(close, 3) > 0`` -> 39/39 entries); every-bar 0/38, + ring-of-executions 0/38. +* ``ta.change(close, 3)`` / ``ta.mom(close, 3)`` -> 39/39 each (every-bar 0, + ring 1); call 1 (bar 3, the first execution) has no TV entry: na. + +This generalises the former ``_PFLazySaturatedROC3Clock`` (#64), which pinned +the same clock for the literal ``ta.roc(close, 3)`` under a plain ``and`` RHS +but fell back to the eager chart ``close[3]`` before the first execution and +between executions closer than the length. The tapes' call 1 refutes the +first fallback (na). The second regime is not distinguished by any tape, so +the #64 eager chart read is kept there for chart-builtin sources (a paired +``_pf_lazy_src_chart_N`` Series); other sources read the held history. +""" + +from __future__ import annotations + +import re + +from pineforge_codegen import transpile + + +def _cpp(body: str, *, header: str = "") -> str: + return transpile(f'//@version=6\nstrategy("lazy source clock"{header})\n{body}\n') + + +def _stmt(cpp: str, prefix: str) -> str: + return next(ln for ln in cpp.splitlines() if ln.strip().startswith(prefix)) + + +def test_one_clock_and_held_history_per_callsite(): + cpp = _cpp( + "gate = close > open\n" + "longish = gate and ta.roc(close, 3) > 0\n" + "shortish = gate and ta.roc(close, 3) < 0\n" + "plot(longish ? 1 : shortish ? -1 : 0)" + ) + assert "struct _PFLazySourceClock {" in cpp + clocks = re.findall( + r"^ _PFLazySourceClock (_pf_lazy_src_clock_\d+);$", cpp, re.MULTILINE + ) + assert clocks == ["_pf_lazy_src_clock_1", "_pf_lazy_src_clock_2"] + hists = re.findall( + r"^ Series (_pf_lazy_src_hist_\d+)\{4\};$", cpp, re.MULTILINE + ) + assert hists == ["_pf_lazy_src_hist_1", "_pf_lazy_src_hist_2"] + charts = re.findall( + r"^ Series (_pf_lazy_src_chart_\d+)\{4\};$", cpp, re.MULTILINE + ) + assert charts == ["_pf_lazy_src_chart_1", "_pf_lazy_src_chart_2"] + assert "std::vector _precalc__ta_roc" not in cpp + assert "_pf_lazy_src_clock_1.roc(current_bar_.close, _pf_lazy_src_clock_1.previous_source(_pf_lazy_src_hist_1[2], _pf_lazy_src_chart_1[3], 3, bar_index_))" in _stmt(cpp, "longish = (") + assert "_pf_lazy_src_clock_2.roc(current_bar_.close, _pf_lazy_src_clock_2.previous_source(_pf_lazy_src_hist_2[2], _pf_lazy_src_chart_2[3], 3, bar_index_))" in _stmt(cpp, "shortish = (") + + +def test_clock_contract_hold_last_base_and_na_guards(): + cpp = _cpp("x = close > open and ta.roc(close, 3) > 0") + helper = cpp.split("struct _PFLazySourceClock", 1)[1].split("};", 1)[0] + assert "if (working_bar != bar)" in helper + assert "bar_base_source = committed_source;" in helper + assert "bar_base_bar = committed_bar;" in helper + assert "void begin_bar(int bar)" in helper + assert "double previous_source(double held, double eager, int length," in helper + # na before the first execution (tape call 1); held once the previous + # execution is at least ``length`` bars back; #64's eager chart read in + # between. + assert "if (bar_base_bar < 0 || length < 1) {" in helper + assert "return bar - bar_base_bar >= length ? held : eager;" in helper + assert "double change(double source, double previous)" in helper + assert "double roc(double source, double previous)" in helper + assert helper.count("committed_source = source;") == 2 + assert helper.count("committed_bar = working_bar;") == 2 + assert "if (is_na(source) || is_na(previous) || previous == 0.0)" in helper + assert "return (source - previous) / previous * 100.0;" in helper + assert "return source - previous;" in helper + for reset in ( + "committed_source = na();", + "committed_bar = -1;", + "bar_base_source = na();", + "bar_base_bar = -1;", + "working_bar = -1;", + ): + assert reset in helper + + +def test_on_bar_resets_then_records_the_held_source_before_statements(): + cpp = _cpp("x = close > open and ta.roc(close, 3) > 0") + on_bar = cpp.split("void on_bar(const Bar& bar) override {", 1)[1].split( + "\n }", 1 + )[0] + reset_guard = "if (history_advances_new_bar() && bar_index_ == 0) {" + assert reset_guard in on_bar + assert "_pf_lazy_src_clock_1.reset();" in on_bar + assert "_pf_lazy_src_hist_1.clear();" in on_bar + assert "_pf_lazy_src_chart_1.clear();" in on_bar + begin = "_pf_lazy_src_clock_1.begin_bar(bar_index_);" + push = "if (history_advances_new_bar()) _pf_lazy_src_hist_1.push(_pf_lazy_src_clock_1.bar_base_source);" + update = "else _pf_lazy_src_hist_1.update(_pf_lazy_src_clock_1.bar_base_source);" + chart_push = "if (history_advances_new_bar()) _pf_lazy_src_chart_1.push(current_bar_.close);" + assert on_bar.index(reset_guard) < on_bar.index(begin) < on_bar.index(push) < on_bar.index(update) + assert on_bar.index(update) < on_bar.index(chart_push) < on_bar.index("x = (") + + +def test_clock_and_history_members_are_automatically_checkpointed_for_coof(): + cpp = _cpp( + "x = close > open and ta.roc(close, 3) > 0", + header=", calc_on_order_fills=true", + ) + for member in ("_pf_lazy_src_clock_1", "_pf_lazy_src_hist_1", "_pf_lazy_src_chart_1"): + match = re.search( + rf"decltype\(GeneratedStrategy::({member})\) _pf_value_(\d+);", cpp + ) + assert match is not None, member + name, index = match.groups() + assert re.search(rf"^ {name},$", cpp, re.MULTILINE) + assert ( + f"this->{name} = _pf_script_state_checkpoint_->_pf_value_{index};" + in cpp + ) + + +def test_change_mom_and_roc_route_in_every_top_level_lazy_position(): + cpp = _cpp( + "gate = close > open\n" + "a = gate and ta.change(close, 3) > 0\n" + "b = gate or ta.mom(close, 2) > 0\n" + "c = gate ? ta.roc(close, 3) : na\n" + "d = gate ? 0.0 : ta.change(close)\n" + "e = gate and ta.roc(source = close, length = 3) > 0\n" + "plot((a or b or e) ? c + d : 0)" + ) + assert "_pf_lazy_src_clock_1.change(current_bar_.close, _pf_lazy_src_clock_1.previous_source(_pf_lazy_src_hist_1[2], _pf_lazy_src_chart_1[3], 3, bar_index_))" in _stmt(cpp, "a = (") + assert "_pf_lazy_src_clock_2.change(current_bar_.close, _pf_lazy_src_clock_2.previous_source(_pf_lazy_src_hist_2[1], _pf_lazy_src_chart_2[2], 2, bar_index_))" in _stmt(cpp, "b = (") + assert "_pf_lazy_src_clock_3.roc(current_bar_.close, _pf_lazy_src_clock_3.previous_source(_pf_lazy_src_hist_3[2], _pf_lazy_src_chart_3[3], 3, bar_index_))" in _stmt(cpp, "c = (") + # ``ta.change(source)`` defaults to length 1: previous is the held value + # as of the previous bar (== the previous execution's source). + assert "_pf_lazy_src_clock_4.change(current_bar_.close, _pf_lazy_src_clock_4.previous_source(_pf_lazy_src_hist_4[0], _pf_lazy_src_chart_4[1], 1, bar_index_))" in _stmt(cpp, "d = (") + assert "Series _pf_lazy_src_hist_4{2};" in cpp + assert "Series _pf_lazy_src_chart_4{2};" in cpp + assert "_pf_lazy_src_clock_5.roc(current_bar_.close, _pf_lazy_src_clock_5.previous_source(_pf_lazy_src_hist_5[2], _pf_lazy_src_chart_5[3], 3, bar_index_))" in _stmt(cpp, "e = (") + for family in ("change", "mom", "roc"): + assert f"std::vector _precalc__ta_{family}" not in cpp + assert "_pf_every_bar_ta_" not in cpp + + +def test_runtime_length_reads_the_held_history_at_length_minus_one(): + cpp = _cpp( + "len = input.int(3, \"len\")\n" + "gate = close > open\n" + "x = gate and ta.roc(close, len) > 0\n" + "plot(x ? 1 : 0)" + ) + x_line = _stmt(cpp, "x = (") + assert ( + "_pf_lazy_src_clock_1.roc(current_bar_.close, _pf_lazy_src_clock_1.previous_source(" + "(((int)(len)) >= 1 ? _pf_lazy_src_hist_1[((int)(len)) - 1] : na()), " + "_pf_lazy_src_chart_1[(int)(len)], (int)(len), bar_index_))" + ) in x_line + assert re.search(r"^ Series _pf_lazy_src_hist_1;$", cpp, re.MULTILINE) + assert re.search(r"^ Series _pf_lazy_src_chart_1;$", cpp, re.MULTILINE) + + +def test_shadowed_close_and_other_sources_route_too(): + """A user-bound ``close`` and a computed source have no chart series: the + in-between regime reads the held history instead of the eager chart.""" + cpp = _cpp( + "float close = open\n" + "gate = bar_index == 0 or bar_index == 5\n" + "signal = gate and ta.roc(close, 3) > 0\n" + "other = gate and ta.change(hl2, 2) > 0\n" + "rsiv = ta.rsi(open, 14)\n" + "third = gate and ta.change(rsiv, 3) > 0\n" + "plot(signal or other or third ? 1 : 0)" + ) + signal_line = _stmt(cpp, "signal = (") + assert "_pf_lazy_src_clock_1.roc(" in signal_line + assert "_pf_lazy_src_clock_1.previous_source(_pf_lazy_src_hist_1[2], _pf_lazy_src_hist_1[2], 3, bar_index_)" in signal_line + assert "_pf_lazy_src_chart_1" not in cpp + other_line = _stmt(cpp, "other = (") + assert ( + "_pf_lazy_src_clock_2.change(((current_bar_.high + current_bar_.low) / 2.0), " + "_pf_lazy_src_clock_2.previous_source(_pf_lazy_src_hist_2[1], _pf_lazy_src_chart_2[2], 2, bar_index_))" + ) in other_line + assert "Series _pf_lazy_src_hist_2{3};" in cpp + assert "Series _pf_lazy_src_chart_2{3};" in cpp + third_line = _stmt(cpp, "third = (") + assert "_pf_lazy_src_clock_3.change(rsiv, _pf_lazy_src_clock_3.previous_source(_pf_lazy_src_hist_3[2], _pf_lazy_src_hist_3[2], 3, bar_index_))" in third_line + assert "_pf_lazy_src_chart_3" not in cpp + assert "std::vector _precalc__ta_roc" not in cpp + + +def test_non_top_level_and_eager_shapes_keep_the_existing_route(): + cases = { + "eager": "x = ta.roc(close, 3) > 0", + "eager_ternary_condition": "x = ta.change(close, 3) > 0 ? 1 : 0", + "udf": ( + "f() =>\n" + " close > open and ta.roc(close, 3) > 0\n" + "x = f()" + ), + "security": ( + 'x = close > open and request.security(syminfo.tickerid, "60", ' + "close > open and ta.roc(close, 3) > 0)" + ), + "if_body": ( + "x = false\n" + "if close > open\n" + " x := high > low and ta.roc(close, 3) > 0" + ), + "loop_body": ( + "x = false\n" + "for i = 0 to 1\n" + " x := high > low and ta.roc(close, 3) > 0" + ), + "var_init": "var float x = close > open ? ta.roc(close, 3) : 0.0", + "bool_source": "x = close > open and ta.change(close > open) != 0", + } + for label, source in cases.items(): + cpp = _cpp(source) + assert "_PFLazySourceClock" not in cpp, label + assert "_pf_lazy_src_hist_" not in cpp, label + assert "_pf_every_bar_ta_" not in cpp, label + assert "ta::ROC _ta_roc" in cpp or "ta::Change _ta_change" in cpp, label + + +def test_generated_type_clock_and_history_names_avoid_pine_collisions(): + cpp = _cpp( + "type _PFLazySourceClock\n" + " float value\n" + "float _pf_lazy_src_clock_1 = 0.0\n" + "float _pf_lazy_src_hist_1 = 0.0\n" + "float _pf_lazy_src_chart_1 = 0.0\n" + "gate = close > open\n" + "signal = gate and ta.roc(close, 3) > 0" + ) + assert cpp.count("struct _PFLazySourceClock {") == 1 + assert "struct _PFLazySourceClock_2 {" in cpp + assert "_PFLazySourceClock_2 _pf_lazy_src_clock_1_2;" in cpp + assert "Series _pf_lazy_src_hist_1_2{4};" in cpp + assert "Series _pf_lazy_src_chart_1_2{4};" in cpp + assert ( + "_pf_lazy_src_clock_1_2.roc(current_bar_.close, _pf_lazy_src_clock_1_2.previous_source(" + "_pf_lazy_src_hist_1_2[2], _pf_lazy_src_chart_1_2[3], 3, bar_index_))" + ) in cpp + + +def test_generated_clock_name_avoids_emitted_udf_method_name(): + cpp = _cpp( + "_pf_lazy_src_clock_1() => 1.0\n" + "other = _pf_lazy_src_clock_1()\n" + "signal = close > open and ta.roc(close, 3) > 0" + ) + assert "double _pf_lazy_src_clock_1()" in cpp + assert "_PFLazySourceClock _pf_lazy_src_clock_1_2;" in cpp + assert ( + "_pf_lazy_src_clock_1_2.roc(current_bar_.close, _pf_lazy_src_clock_1_2.previous_source(" + "_pf_lazy_src_hist_1[2], _pf_lazy_src_chart_1[3], 3, bar_index_))" + ) in cpp diff --git a/tests/test_lazy_source_clock_runtime.py b/tests/test_lazy_source_clock_runtime.py new file mode 100644 index 0000000..89301f5 --- /dev/null +++ b/tests/test_lazy_source_clock_runtime.py @@ -0,0 +1,219 @@ +"""Executable synthetic-bars coverage for the lazy-edge TA clocks. + +Links the generated strategy against a built ``libpineforge`` (set +``PINEFORGE_ENGINE_LIB``; otherwise the sibling engine's ``build*/lib`` is +used) and drives it with synthetic bars -- no feed, no probe. The expected +numbers are the TradingView models pinned 2026-09-03 with ``lab tv`` on +NYSE:F 1D (see tests/test_lazy_source_clock.py and +tests/test_lazy_edge_ta_every_bar.py): change/mom/roc read the call's own +held ``source[length]`` (na before the first execution); cum/barssince only +see the samples of bars where the call executes. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from pineforge_codegen import transpile +from tests import _compile as compile_env + + +# Cadence-4 lazy edges over closes 100, 102, ..., 122 (12 bars): the calls +# execute on bars 1, 5 and 9. +# hold-last source (change/mom/roc, length 3): +# bar 1: no execution at or before bar -2 -> na (naCount 1) +# bar 5: held source as of bar 2 = close[1] = 102 -> change 8, roc 7.843... +# bar 9: held source as of bar 6 = close[5] = 110 -> change 8, roc 7.2727... +# (every-bar would read close[b-3]: change 6, roc 5.769...; a ring of +# executions would still be na at bar 9) +# per-execution (cum, barssince(close < 105)): +# cum at bar 9 = 102 + 110 + 118 = 330 (every-bar: 1090) +# barssince at bar 9 = 2 executions since bar 1 (every-bar: 7 bars) +_PINE = """//@version=6 +strategy("lazy edge clocks") +var float lastRoc = na +var float lastChange = na +var int momHits = 0 +var int naCount = 0 +var float lastCum = na +var float lastBarsSince = na +gate = bar_index % 4 == 1 +v = gate ? ta.roc(close, 3) : na +c = gate ? ta.change(close, 3) : na +m = gate and ta.mom(close, 3) > 0 +cu = gate ? ta.cum(close) : na +bs = gate ? ta.barssince(close < 105) + 0.0 : na +if gate and na(v) + naCount += 1 +if not na(v) + lastRoc := v +if not na(c) + lastChange := c +if m + momHits += 1 +if not na(cu) + lastCum := cu +if not na(bs) + lastBarsSince := bs +""" + +_DRIVER = r""" +#include +#include + +static Bar make_bar(double close, int64_t timestamp) { + return Bar{close, close, close, close, 1.0, timestamp}; +} + +static void report(const GeneratedStrategy& s) { + std::cout << std::setprecision(10) << s.lastRoc << ' ' << s.lastChange << ' ' + << s.momHits << ' ' << s.naCount << ' ' << s.lastCum << ' ' + << s.lastBarsSince; +} + +int main() { + Bar bars[12]; + for (int i = 0; i < 12; ++i) { + bars[i] = make_bar(100.0 + 2.0 * i, 1000 + static_cast(i) * 60000); + } + GeneratedStrategy precalc; + precalc.run(bars, 12); // static mode (_use_precalc path) + report(precalc); + std::cout << " | "; + GeneratedStrategy dynamic; + dynamic.run(bars, 12, "1", "1"); // dynamic mode (inline path) + report(dynamic); + std::cout << '\n'; + return 0; +} +""" + +_EXPECTED_ONE = "7.272727273 8 2 1 330 2" + + +def _find_engine_library() -> Path | None: + explicit = os.environ.get("PINEFORGE_ENGINE_LIB") + if explicit: + path = Path(explicit).expanduser().resolve() + return path if path.is_file() else None + engine_inc = compile_env._ENGINE_INC + if engine_inc is None: + return None + candidates: list[Path] = [] + for pattern in ("build*/lib/libpineforge.a", "build*/lib/libpineforge.dylib"): + candidates.extend(sorted(engine_inc.parent.glob(pattern))) + return candidates[0].resolve() if candidates else None + + +def _compile_and_run(source: str) -> str: + compile_env.skip_if_no_compile_env() + engine_lib = _find_engine_library() + if engine_lib is None: + pytest.skip("set PINEFORGE_ENGINE_LIB to a built PineForge engine library") + compiler = compile_env._COMPILER + engine_inc = compile_env._ENGINE_INC + eigen_inc = compile_env._EIGEN_INC + assert compiler is not None and engine_inc is not None and eigen_inc is not None + with tempfile.TemporaryDirectory(prefix="pineforge-lazy-source-clock-") as tmp: + cpp = Path(tmp) / "clock.cpp" + exe = Path(tmp) / "clock" + cpp.write_text(source) + command = [compiler, "-std=c++17", "-O0", "-I", str(engine_inc), "-I", str(eigen_inc)] + if compile_env._GENERATED_INC is not None: + command += ["-I", str(compile_env._GENERATED_INC)] + command += [str(cpp), str(engine_lib), "-pthread", "-o", str(exe)] + built = subprocess.run(command, capture_output=True, text=True, timeout=180) + if built.returncode != 0: + raise AssertionError(built.stderr or built.stdout) + ran = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) + if ran.returncode != 0: + raise AssertionError(ran.stderr or ran.stdout) + return ran.stdout.strip() + + +def test_hold_last_and_per_execution_clocks_in_both_run_modes(): + assert _compile_and_run(transpile(_PINE) + _DRIVER) == " | ".join([_EXPECTED_ONE] * 2) + + +def test_first_execution_is_na_and_stream_lifecycle_resets_the_clock(): + """The first execution has no held history (TV call 1: no entry); a second + stream lifecycle on the same handle starts from that same na state.""" + pine = """//@version=6 +strategy("lazy source clock stream") +gate = bar_index == 2 or bar_index == 5 +signal = gate and ta.roc(close, 3) > 0 +""" + driver = r""" +#include + +static Bar make_bar(double close, int64_t timestamp) { + return Bar{close, close, close, close, 1.0, timestamp}; +} + +int main() { + Bar bars[4]; + for (int i = 0; i < 4; ++i) { + bars[i] = make_bar(100.0 + i * 20.0, 1000 + i * 60000); + } + GeneratedStrategy reused; + // History bars 0..3: the first execution (bar 2) has no held source -> na. + const bool began_first = reused.stream_begin(bars, 4, "1", "1"); + const int after_history = reused.signal ? 1 : 0; + // Ticks open bars 4, 5, 6; each closes the previous bar. Bar 5 executes + // with the held source as of bar 2 (close 140) against close 200. + reused.stream_push_tick(TradeTick{241123, 1, 180.0, 1.0}); + reused.stream_push_tick(TradeTick{301123, 2, 200.0, 1.0}); + reused.stream_push_tick(TradeTick{361123, 3, 300.0, 1.0}); + const int after_bar5 = reused.signal ? 1 : 0; + const bool ended_first = reused.stream_end(); + + const bool began_second = reused.stream_begin(bars, 4, "1", "1"); + const int second_after_history = reused.signal ? 1 : 0; + const bool ended_second = reused.stream_end(); + + std::cout << began_first << after_history << after_bar5 << ended_first + << began_second << second_after_history << ended_second << '\n'; + return 0; +} +""" + assert _compile_and_run(transpile(pine) + driver) == "1011101" + + +def test_executions_closer_than_length_read_the_eager_chart_source(): + """#64's regime the tapes do not distinguish: the previous execution is + 2 bars back for length 3, so the previous source is the chart close[3] + (bar 3 = 106) rather than the held history (na: no execution at or before + bar 3).""" + pine = """//@version=6 +strategy("lazy source clock close executions") +var float lastRoc = na +gate = bar_index == 4 or bar_index == 6 +v = gate ? ta.roc(close, 3) : na +if not na(v) + lastRoc := v +""" + driver = r""" +#include +#include + +static Bar make_bar(double close, int64_t timestamp) { + return Bar{close, close, close, close, 1.0, timestamp}; +} + +int main() { + Bar bars[8]; + for (int i = 0; i < 8; ++i) { + bars[i] = make_bar(100.0 + 2.0 * i, 1000 + static_cast(i) * 60000); + } + GeneratedStrategy s; + s.run(bars, 8); + std::cout << std::setprecision(10) << s.lastRoc << '\n'; // (112 - 106) / 106 * 100 + return 0; +} +""" + assert _compile_and_run(transpile(pine) + driver) == "5.660377358" diff --git a/tests/test_security_lazy_ta_under_conditionals.py b/tests/test_security_lazy_ta_under_conditionals.py index 7186c9e..bbc6246 100644 --- a/tests/test_security_lazy_ta_under_conditionals.py +++ b/tests/test_security_lazy_ta_under_conditionals.py @@ -224,8 +224,11 @@ def test_mutable_global_security_stays_eager(): # ---------------------------------------------------------------------- -def test_chart_context_conditional_ta_is_inline(): - """The chart path never hoisted; its ``?:`` / ``&&`` are the short-circuit.""" +def test_chart_context_conditional_ta_is_inline_without_history_read(): + """The chart path keeps its reached-only ``?:`` / ``&&`` compute unless the + call's own history is read (2026-09-04: hoisting ``c ? ta.ema(...) : 0`` + shapes broke quantbyboji/ycelestine77/oliver1002/louislapis9 on ETH, all + exact at 100% on this lowering). Never ``_secval_`` on the chart path.""" cpp = transpile(_strategy( "c = close > open\n" "v = c ? ta.ema(close, 20) : 0.0\n" @@ -236,6 +239,23 @@ def test_chart_context_conditional_ta_is_inline(): assert "_secval_" not in assign w_assign = next(ln for ln in cpp.splitlines() if ln.strip().startswith("w = (")) assert "_ta_rsi_2.compute" in w_assign + assert "_pf_every_bar_ta_" not in cpp + + +def test_chart_context_conditional_ta_with_history_read_is_hoisted_every_bar(): + """With ``[1]`` on the call TradingView advances the built-in every bar + (lab tv 2026-09-03, ``... and close > ta.ema(close, 5)[1]`` 23/23 vs + per-call 27): codegen hoists it before the statement.""" + cpp = transpile(_strategy( + "c = close > open\n" + "v = c ? ta.ema(close, 20)[1] : 0.0" + )) + lines = cpp.splitlines() + hoist = next(ln for ln in lines if ln.strip().startswith("const auto _pf_every_bar_ta_1 = ")) + assert "_ta_ema_1.compute" in hoist + assign = next(ln for ln in lines if ln.strip().startswith("v = (")) + assert assign.strip() == "v = ((c) ? (_hist_call_1[(int)(1)]) : (0.0));" + assert lines.index(hoist) < lines.index(assign) def test_chart_context_unconditional_ta_unchanged():