diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 449df8b..51028bc 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -2115,6 +2115,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() lines: list[str] = [] @@ -2176,6 +2177,12 @@ 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) + # 2. Open class lines.append("class GeneratedStrategy : public BacktestEngine {") lines.append("public:") @@ -2293,6 +2300,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(): + 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}};" + ) + # Security evaluator TA members (cloned from expression dependencies) # Skip for user function call expressions — their TA deps are internal to the function for info in self._security_eval_info: diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 8fe498d..58ab140 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -610,6 +610,31 @@ def _emit_history_series_write( def _emit_on_bar(self, lines: list[str]) -> None: lines.append(" void on_bar(const Bar& bar) override {") + # 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: + 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();" + ) + lines.append(" }") + self._emit_history_series_write( + lines, + " ", + self._lazy_saturated_roc3_history_name, + "current_bar_.close", + ) + # reset_run_state() owns engine/broker state, while these generated # Series members belong to the strategy object. Clear all of them on # the first genuine history slot of bar zero, unconditionally: a site diff --git a/pineforge_codegen/codegen/ta.py b/pineforge_codegen/codegen/ta.py index 7fa6b2f..f5d5aa6 100644 --- a/pineforge_codegen/codegen/ta.py +++ b/pineforge_codegen/codegen/ta.py @@ -29,8 +29,9 @@ from ..ast_nodes import ( Assignment, BinOp, BoolLiteral, ColorLiteral, ExprStmt, FuncCall, - Identifier, MemberAccess, NaLiteral, NumberLiteral, StringLiteral, - Subscript, Ternary, TupleAssign, TupleLiteral, TypeDecl, UnaryOp, VarDecl, + ForInStmt, ForStmt, FuncDef, Identifier, MemberAccess, NaLiteral, + NumberLiteral, StringLiteral, Subscript, Ternary, TupleAssign, + TupleLiteral, TypeDecl, UnaryOp, VarDecl, ) from .tables import TA_IMPLICIT_APPEND, TA_IMPLICIT_COMPUTE_FULL @@ -293,14 +294,18 @@ 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]]: + 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. + 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. """ cached = getattr(self, "_lazy_scope_ta_call_nodes", None) if cached is not None: @@ -308,11 +313,13 @@ def _ta_call_nodes_by_lazy_scope(self) -> tuple[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: return @@ -331,13 +338,20 @@ def note_ta_calls( 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) + 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 @@ -345,70 +359,119 @@ def note_ta_calls( # inspected for lazy chart TA. if is_security and idx == 2: continue - note_ta_calls(arg, under_and_rhs, under_lazy_rhs) + note_ta_calls( + arg, + under_and_rhs, + under_lazy_rhs, + under_disallowed_shape, + ) for key, value in (getattr(expr, "kwargs", None) or {}).items(): if is_security and key == "expression": continue - note_ta_calls(value, under_and_rhs, under_lazy_rhs) + 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) - note_ta_calls(expr.right, True, True) + 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) - note_ta_calls(expr.right, under_and_rhs, True) + 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) - note_ta_calls(expr.right, under_and_rhs, under_lazy_rhs) + 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) - note_ta_calls(expr.true_val, under_and_rhs, True) - note_ta_calls(expr.false_val, under_and_rhs, True) + 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) + 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 + 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 + 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) + 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) + 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, ) return if isinstance(stmt, Assignment): - note_ta_calls(getattr(stmt, "target", None), False, False) - note_ta_calls(getattr(stmt, "value", None), False, False) + note_ta_calls(getattr(stmt, "target", None), False, False, False) + note_ta_calls(getattr(stmt, "value", None), False, False, False) return if isinstance(stmt, TupleAssign): - note_ta_calls(getattr(stmt, "value", None), False, False) + note_ta_calls(getattr(stmt, "value", None), False, False, False) return if isinstance(stmt, TypeDecl): for field in getattr(stmt, "fields", ()) or (): - note_ta_calls(getattr(field, "default", None), False, False) + note_ta_calls( + getattr(field, "default", None), False, False, False + ) return # If / for / while / assign-like — best-effort field walk for attr in ("condition", "body", "else_body", "else_ifs", "value", "target", "iterable"): @@ -423,9 +486,9 @@ def walk_stmt(stmt) -> None: elif hasattr(item, "body") or hasattr(item, "name") or hasattr(item, "value"): walk_stmt(item) else: - note_ta_calls(item, False, False) + 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) + note_ta_calls(child, False, False, False) elif hasattr(child, "body") or hasattr(child, "value") or hasattr(child, "condition"): walk_stmt(child) @@ -441,7 +504,7 @@ def walk_stmt(stmt) -> None: for stmt in body: walk_stmt(stmt) - result = (and_rhs, lazy_rhs) + result = (and_rhs, lazy_rhs, plain_and_rhs) self._lazy_scope_ta_call_nodes = result return result @@ -451,6 +514,193 @@ 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 + + def _script_has_user_binding(self, name: str) -> bool: + cache = getattr(self, "_user_binding_names", None) + if cache is None: + cache = set() + ast = getattr(self.ctx, "ast", None) + for node in self._walk_ast(ast): + if isinstance(node, VarDecl): + cache.add(node.name) + elif isinstance(node, TupleAssign): + cache.update(node.names) + elif isinstance(node, ForStmt): + cache.add(node.var) + elif isinstance(node, ForInStmt): + if node.var: + cache.add(node.var) + cache.update(node.vars or ()) + elif isinstance(node, FuncDef): + cache.add(node.name) + cache.update(node.params) + 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. + """ + node = getattr(site, "node", 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 + ): + 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 + ) + + 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"): + return + + def allocate(base: str, reserved: set[str]) -> str: + candidate = base + suffix = 2 + while candidate in reserved: + candidate = f"{base}_{suffix}" + suffix += 1 + reserved.add(candidate) + return candidate + + # Pine permits leading-underscore identifiers. Reserve every emitted + # user member, not only history/var members already tracked by + # _all_member_names, before minting generated support names. + reserved_members = set(getattr(self, "_all_member_names", set())) + reserved_members.update( + self._safe_name(name) + for name, _ptype in getattr(self.ctx, "global_var_decls", ()) + ) + reserved_members.update( + self._safe_name(name) + for name, _ptype, _init in getattr(self.ctx, "var_members", ()) + ) + reserved_types = set(getattr(self, "_udt_defs", {})) + for info in getattr(self.ctx, "func_infos", ()): + emitted = self._func_cpp_base_name(info.name) + reserved_members.add(emitted) + reserved_types.add(emitted) + total = self.ctx.func_call_site_counts.get(info.name, 0) + for callsite in range(total): + 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_)" + ) + + def _emit_lazy_saturated_roc3_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: + return + type_name = self._lazy_saturated_roc3_type_name + lines.extend( + [ + f"struct {type_name} {{", + " double committed_source = na();", + " int committed_bar = -1;", + " double bar_base_source = na();", + " int bar_base_bar = -1;", + " int working_bar = -1;", + "", + " void reset() {", + " committed_source = na();", + " committed_bar = -1;", + " bar_base_source = na();", + " bar_base_bar = -1;", + " working_bar = -1;", + " }", + "", + " double evaluate(double source, double eager_previous, 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;", + " }", + " committed_source = source;", + " committed_bar = bar;", + " return result;", + " }", + "};", + "", + ] + ) + def _ta_site_uses_precalc(self, site: "TACallSite") -> bool: """Whether a static TA site can safely read from ``_precalc_*``. @@ -469,6 +719,8 @@ def _ta_site_uses_precalc(self, site: "TACallSite") -> bool: 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) ta_name = self._ta_name_from_site(site) if ( diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 961e16f..5150784 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -466,6 +466,8 @@ def _visit_func_call(self, node: FuncCall) -> str: 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 uses_precalc: return ( f"(_use_precalc ? _precalc_{ta_mem}[bar_index_] : " diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index 5246a62..e515679 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -546,9 +546,15 @@ def test_ta_precalc_lazy_scope_routes_recursive_ema_only(): "i = ta.ema(close, 23)\n" "plot((a or b or c or d or e or g) ? f + h + i : close)" ) - for name in ("roc", "lowest", "highest"): + for name in ("lowest", "highest"): 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 ( + ".evaluate(current_bar_.close, " + "_pf_lazy_saturated_roc3_close_history[3], bar_index_)" + ) 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 diff --git a/tests/test_compile_smoke.py b/tests/test_compile_smoke.py index 8108133..780fd45 100644 --- a/tests/test_compile_smoke.py +++ b/tests/test_compile_smoke.py @@ -67,6 +67,45 @@ 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.""" + skip_if_no_compile_env() + cpp = transpile('''//@version=6 +strategy("lazy ROC clocks", calc_on_order_fills=true) +gate = close > open +longish = gate and ta.roc(close, 3) > 0 +shortish = gate and ta.roc(close, 3) < 0 +if longish or shortish + strategy.entry("L", strategy.long) +''') + compile_cpp(cpp, label="lazy_saturated_roc_call_clocks") + + +def test_lazy_saturated_roc_generated_names_compile_with_user_collisions(): + skip_if_no_compile_env() + cpp = transpile('''//@version=6 +strategy("lazy ROC name collisions") +type _PFLazySaturatedROC3Clock + float value +float _pf_lazy_saturated_roc3_clock_1 = 0.0 +float _pf_lazy_saturated_roc3_close_history = 0.0 +gate = close > open +signal = gate and ta.roc(close, 3) > 0 +''') + compile_cpp(cpp, label="lazy_saturated_roc_name_collisions") + + +def test_lazy_saturated_roc_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() +signal = close > open and ta.roc(close, 3) > 0 +''') + compile_cpp(cpp, label="lazy_saturated_roc_udf_collision") + + def test_calc_on_order_fills_mixed_script_state_checkpoint_compiles(): """The rollback aggregate must remain copyable across every state family.""" skip_if_no_compile_env() diff --git a/tests/test_lazy_saturated_roc.py b/tests/test_lazy_saturated_roc.py new file mode 100644 index 0000000..eff433d --- /dev/null +++ b/tests/test_lazy_saturated_roc.py @@ -0,0 +1,198 @@ +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 new file mode 100644 index 0000000..3ce37f6 --- /dev/null +++ b/tests/test_lazy_saturated_roc_runtime.py @@ -0,0 +1,270 @@ +"""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"