From b73d2d64bef0b3809f068e38dd8ef83ca6bceea3 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 16 Jul 2026 09:46:01 +0800 Subject: [PATCH] Support helper-local var state in request.security --- pineforge_codegen/analyzer/base.py | 11 +- pineforge_codegen/analyzer/call_handlers.py | 20 ++ pineforge_codegen/codegen/base.py | 1 + pineforge_codegen/codegen/security.py | 374 +++++++++++++++++-- tests/test_security_helper_local_var.py | 379 ++++++++++++++++++++ 5 files changed, 748 insertions(+), 37 deletions(-) create mode 100644 tests/test_security_helper_local_var.py diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index ff2e855..ac4b906 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -157,6 +157,8 @@ def __init__(self, ast: Program, filename: str = "") -> None: # Track user-defined function tuple returns self._func_returns_tuple: dict[str, bool] = {} self._func_tuple_element_count: dict[str, int] = {} + self._func_tuple_element_types: dict[str, tuple[PineType, ...]] = {} + self._tuple_element_types_by_node: dict[int, tuple[PineType, ...]] = {} # Track user-defined functions whose body returns a UDT instance — # maps func_name -> UDT type name. Detected from the body's final # expression (``=> Sample.new(...)`` or last stmt ``Sample.new(...)``). @@ -1484,6 +1486,7 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: # Detect if function returns a tuple (last stmt is TupleLiteral) self._func_returns_tuple[node.name] = False self._func_tuple_element_count[node.name] = 0 + self._func_tuple_element_types[node.name] = () if node.body: last_stmt = node.body[-1] tuple_node = None @@ -1494,6 +1497,9 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: if tuple_node is not None: self._func_returns_tuple[node.name] = True self._func_tuple_element_count[node.name] = len(tuple_node.elements) + self._func_tuple_element_types[node.name] = ( + self._tuple_element_types_by_node.get(id(tuple_node), ()) + ) # Detect if the function returns a UDT instance via ``T.new(...)`` — # used by codegen to emit the C++ return type as the struct name and @@ -2329,6 +2335,7 @@ def _visit_ColorLiteral(self, node: ColorLiteral) -> PineType: return PineType.COLOR def _visit_TupleLiteral(self, node: TupleLiteral) -> PineType: - for elem in node.elements: - self._visit(elem) + self._tuple_element_types_by_node[id(node)] = tuple( + self._visit(elem) for elem in node.elements + ) return PineType.FLOAT diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 7987c77..22d6794 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -323,6 +323,26 @@ def _handle_request_call(self, func_name: str, node: FuncCall) -> PineType: and isinstance(expr_node.callee.object, Identifier)): expr_ns = expr_node.callee.object.name expr_func = expr_node.callee.member + elif isinstance(expr_node.callee, Identifier): + expr_func = expr_node.callee.name + if self._func_returns_tuple.get(expr_func, False): + tuple_size = self._func_tuple_element_count.get(expr_func, 0) + tuple_types = self._func_tuple_element_types.get(expr_func, ()) + if ( + tuple_size != 2 + or len(tuple_types) != 2 + or any( + item not in (PineType.INT, PineType.FLOAT) + for item in tuple_types + ) + ): + self._error( + "request.security tuple-return helpers currently support " + "exactly two numeric elements", + expr_node.loc, + ) + else: + returns_tuple = True if expr_ns == "ta": if expr_func == "vwap": merged_v = list(expr_node.args) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 4350262..449df8b 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -2185,6 +2185,7 @@ def generate(self) -> str: for item in self._security_calls: sec_id = item["sec_id"] expr_node = item["expr_node"] + self._validate_security_persistent_var_control_flow(expr_node) returns_tuple = item.get("returns_tuple", False) tuple_size = item.get("tuple_size", 0) if item.get("is_lower_tf_array"): diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 279b642..bd98cae 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -590,35 +590,202 @@ def _resolve_security_index_literal( ) return None - def _collect_security_ohlc_hist_fields(self, node) -> set[str]: - """Which security bar fields need HTF history (subscript index >= 1).""" + def _compose_security_helper_history_subscript( + self, + bound, + local_index, + source_node, + ) -> Subscript: + """Apply helper-local history to a supported bound bar series. + + The security runtime currently has native requested-context history + storage only for direct OHLC/time bar fields. Keep that support fence + explicit instead of emitting a C++ subscript on a scalar expression + for bindings such as ``hl2`` or ``ta.ema(...)``. + """ + if isinstance(bound, Subscript): + if not ( + isinstance(bound.object, Identifier) + and bound.object.name in SECURITY_BAR_FIELDS + ): + self._codegen_error( + source_node, + "request.security helper-parameter history currently requires " + "a direct OHLC/time bar-series binding", + ) + bound_idx = self._literal_int_for_security_index(bound.index) + local_idx = self._literal_int_for_security_index(local_index) + if bound_idx is not None and local_idx is not None: + combined_index = NumberLiteral(value=bound_idx + local_idx) + else: + combined_index = BinOp( + left=bound.index, + op="+", + right=local_index, + ) + return Subscript(object=bound.object, index=combined_index) + if isinstance(bound, Identifier) and bound.name in SECURITY_BAR_FIELDS: + return Subscript(object=bound, index=local_index) + self._codegen_error( + source_node, + "request.security helper-parameter history currently requires " + "a direct OHLC/time bar-series binding", + ) + + def _collect_security_ohlc_hist_fields( + self, + node, + helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None, + resolving: set[str] | None = None, + ) -> set[str]: + """Which requested-context bar fields need completed-bar history. + + This is the declaration prepass for ``_build_security_expr``. It must + follow the same helper-argument bindings and offset composition as the + emitter; otherwise ``f(close)`` plus ``src[1]`` can emit a history read + without declaring, pushing, or clearing its backing Series. + """ out: set[str] = set() + if resolving is None: + resolving = set() - def walk(n): - if n is None: + def walk(n, bindings) -> None: + if n is None or isinstance(n, str): return + + if isinstance(n, Identifier): + bound = self._security_lookup_helper_binding(n.name, bindings) + if bound is not None: + if not isinstance(bound, str): + key = f"bind:{id(bound)}" + if key not in resolving: + resolving.add(key) + walk(bound, bindings) + resolving.remove(key) + return + mutable_info = self._global_mutable_infos.get(n.name) + if mutable_info is not None and n.name not in resolving: + resolving.add(n.name) + for stmt in getattr(mutable_info, "source_stmts", []) or []: + walk(stmt, bindings) + resolving.remove(n.name) + return + global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} + if n.name in global_expr_map and n.name not in resolving: + resolving.add(n.name) + walk(global_expr_map[n.name], bindings) + resolving.remove(n.name) + return + if isinstance(n, Subscript) and isinstance(n.object, Identifier): + bound = self._security_lookup_helper_binding(n.object.name, bindings) + if bound is not None: + if not isinstance(bound, str): + walk( + self._compose_security_helper_history_subscript( + bound, + n.index, + n, + ), + bindings, + ) + return if n.object.name in SECURITY_BAR_FIELDS: - idx = self._literal_int_for_security_index(n.index) - # high[0]/time[0] uses current HTF `bar`; k>=1 reads prior - # completed HTF bars from Series history (filled before push - # in _eval_security_*). Dynamic indices may be >=1 at - # runtime, so they need the same backing Series. + idx = self._resolve_security_index_literal(n.index, bindings) + # field[0] uses the current requested bar; k>=1 reads the + # completed-bar Series. Dynamic indices need that Series too. if idx is None or idx >= 1: out.add(n.object.name) + return + global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} + if n.object.name in global_expr_map and n.object.name not in resolving: + resolving.add(n.object.name) + walk( + Subscript( + object=global_expr_map[n.object.name], + index=n.index, + ), + bindings, + ) + resolving.remove(n.object.name) + return + + if isinstance(n, FuncCall) and isinstance(n.callee, Identifier): + func_name = n.callee.name + if func_name in self._func_names: + call_key = f"func:{func_name}" + if call_key in resolving: + return + resolving.add(call_key) + plan = self._security_helper_call_plan(n, bindings) + if plan["mode"] == "expr": + walk(plan["expr"], plan["binding_stack"]) + else: + local_series_names = set(plan.get("local_series_names", ())) + active: dict[str, object] = {} + + def walk_stmt(stmt, current: dict[str, object]) -> None: + local_stack = plan["binding_stack"] + (current,) + if isinstance(stmt, VarDecl): + if stmt.value is not None: + walk(stmt.value, local_stack) + if stmt.name in local_series_names or stmt.is_var: + current[stmt.name] = self._security_series_binding( + f"{plan['func_info'].name}:{stmt.name}" + ) + elif stmt.value is not None: + current[stmt.name] = stmt.value + return + if isinstance(stmt, Assignment): + if stmt.value is not None: + walk(stmt.value, local_stack) + target = self._get_target_name(stmt.target) + existing = current.get(target) if target is not None else None + if ( + target in local_series_names + or ( + isinstance(existing, str) + and self._security_series_binding_target(existing) + is not None + ) + ): + current[target] = self._security_series_binding( + f"{plan['func_info'].name}:{target}" + ) + elif target is not None and stmt.value is not None: + current[target] = stmt.value + return + if isinstance(stmt, IfStmt): + walk(stmt.condition, local_stack) + body_bindings = dict(current) + for child in stmt.body: + walk_stmt(child, body_bindings) + else_bindings = dict(current) + for child in stmt.else_body: + walk_stmt(child, else_bindings) + + for stmt in plan["body"]: + walk_stmt(stmt, active) + walk( + plan["expr"], + plan["binding_stack"] + (active,), + ) + resolving.remove(call_key) + return + if isinstance(n, (list, tuple)): - for x in n: - walk(x) + for child in n: + walk(child, bindings) return - for _k, v in getattr(n, "__dict__", {}).items(): - if isinstance(v, ASTNode): - walk(v) - elif isinstance(v, (list, tuple)): - for x in v: - if isinstance(x, ASTNode): - walk(x) - - walk(node) + for value in getattr(n, "__dict__", {}).values(): + if isinstance(value, ASTNode): + walk(value, bindings) + elif isinstance(value, (list, tuple)): + for child in value: + if isinstance(child, ASTNode): + walk(child, bindings) + + walk(node, helper_binding_stack or ()) return out def _collect_security_ohlc_hist_fields_for_call(self, item: dict) -> set[str]: @@ -918,7 +1085,8 @@ def emit_stmt(stmt: ASTNode, active_bindings: dict[str, str], indent: int) -> No runtime_stack_local = plan["binding_stack"] + (active_bindings,) if isinstance(stmt, VarDecl): - if stmt.name in local_series_names: + is_persistent_var = stmt.is_var + if stmt.name in local_series_names or is_persistent_var: binding = active_bindings.get(stmt.name) if binding is None: binding = self._security_series_binding( @@ -937,13 +1105,52 @@ def emit_stmt(stmt: ASTNode, active_bindings: dict[str, str], indent: int) -> No lines, ) active_bindings[stmt.name] = binding - lines.append(f'{pad}if (_security_helper_series_["{series_name}"].size() == 0) {{') - lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});') - lines.append(f'{pad}}} else if (security_series_slot_is_new({sec_id})) {{') - lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});') - lines.append(f'{pad}}} else {{') - lines.append(f'{pad} _security_helper_series_["{series_name}"].update({expr_cpp});') - lines.append(f'{pad}}}') + if is_persistent_var: + cpp_type = self._type_for_decl(stmt) + if cpp_type not in {"double", "int", "bool"}: + self._codegen_error( + stmt, + "request.security helper-local var state currently supports only int, float, and bool values", + hint="Hoist collection, string, UDT, or drawing state outside request.security().", + ) + # A Pine ``var`` initializer runs once per helper call + # site in the requested context. On a new requested + # bar the prior committed value is carried forward; on + # a lookahead/realtime recomputation of the current + # requested bar it rolls back before the helper body is + # evaluated again. ``Series[1]`` is that rollback + # value after the first bar. The companion seed entry + # preserves the once-only initializer for first-bar + # recomputations without adding another generated + # member/state family. + seed_name = f"{series_name}@var_seed" + lines.append(f'{pad}if (_security_helper_series_["{series_name}"].size() == 0) {{') + lines.append(f'{pad} _security_helper_series_["{seed_name}"].push({expr_cpp});') + lines.append( + f'{pad} _security_helper_series_["{series_name}"].push(' + f'_security_helper_series_["{seed_name}"][0]);' + ) + lines.append(f'{pad}}} else if (security_series_slot_is_new({sec_id})) {{') + lines.append( + f'{pad} _security_helper_series_["{series_name}"].push(' + f'_security_helper_series_["{series_name}"][0]);' + ) + lines.append(f'{pad}}} else {{') + lines.append( + f'{pad} _security_helper_series_["{series_name}"].update(' + f'_security_helper_series_["{series_name}"].size() > 1 ' + f'? _security_helper_series_["{series_name}"][1] ' + f': _security_helper_series_["{seed_name}"][0]);' + ) + lines.append(f'{pad}}}') + else: + lines.append(f'{pad}if (_security_helper_series_["{series_name}"].size() == 0) {{') + lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});') + lines.append(f'{pad}}} else if (security_series_slot_is_new({sec_id})) {{') + lines.append(f'{pad} _security_helper_series_["{series_name}"].push({expr_cpp});') + lines.append(f'{pad}}} else {{') + lines.append(f'{pad} _security_helper_series_["{series_name}"].update({expr_cpp});') + lines.append(f'{pad}}}') return local_name = active_bindings.get(stmt.name) @@ -1202,11 +1409,11 @@ def _security_helper_call_plan( "request.security multi-statement helpers may only use local declarations, assignments, and if-branches before the final expression", ) if isinstance(stmt, VarDecl): - if stmt.is_var or stmt.is_varip: + if stmt.is_varip: self._codegen_error( node, - "request.security does not support multi-statement helpers with helper-local var state", - hint="Rewrite helper-local state as plain temporaries or hoist it outside request.security().", + "request.security does not support helper-local varip state", + hint="Use var for requested-context bar state; varip tick state is unavailable in batch backtests.", ) if isinstance(stmt, Assignment): target_name = self._get_target_name(stmt.target) @@ -1226,6 +1433,92 @@ def _security_helper_call_plan( "local_series_names": local_series_names, } + def _validate_security_persistent_var_control_flow(self, expr_node) -> None: + """Reject helper ``var`` state whose rollback would be conditional. + + Persistent helper state is restored at its declaration before the body + mutates it. That is safe only when the declaration executes on every + requested-bar evaluation. Until rollback is hoisted to evaluator entry, + reject declarations reached through ``if``/``?:``/short-circuit paths, + including state owned by a nested helper called from such a path. + """ + call_stack: set[str] = set() + + def visit_expr(node, conditional: bool) -> None: + if node is None: + return + if isinstance(node, FuncCall) and isinstance(node.callee, Identifier): + name = node.callee.name + if name in self._func_names: + if name in call_stack: + return + fi = self._func_info_map.get(name) + if fi is not None and fi.node is not None: + call_stack.add(name) + for stmt in fi.node.body: + visit_stmt(stmt, conditional) + call_stack.remove(name) + for arg in node.args: + visit_expr(arg, conditional) + for value in node.kwargs.values(): + if isinstance(value, ASTNode): + visit_expr(value, conditional) + return + if isinstance(node, Ternary): + visit_expr(node.condition, conditional) + visit_expr(node.true_val, True) + visit_expr(node.false_val, True) + return + if isinstance(node, BinOp) and node.op in ("and", "or"): + visit_expr(node.left, conditional) + visit_expr(node.right, True) + return + for value in getattr(node, "__dict__", {}).values(): + if isinstance(value, ASTNode): + visit_expr(value, conditional) + elif isinstance(value, (list, tuple)): + for child in value: + if isinstance(child, ASTNode): + visit_expr(child, conditional) + + def visit_stmt(stmt, conditional: bool) -> None: + if isinstance(stmt, VarDecl): + if stmt.is_var and conditional: + self._codegen_error( + stmt, + "request.security helper-local var declarations inside " + "conditional control flow are not supported", + hint="Declare persistent requested-context state at the helper's top level.", + ) + # A persistent initializer runs only when its requested-context + # slot is first created. Any nested persistent helper reached + # from that initializer would therefore need its rollback and + # mutation to remain inside the same once-only guard. The + # current linear emitter hoists nested helper statements ahead + # of that guard, so treat the initializer as conditional and + # fail closed until it can preserve that execution boundary. + visit_expr(stmt.value, conditional or stmt.is_var) + return + if isinstance(stmt, IfStmt): + visit_expr(stmt.condition, conditional) + for child in stmt.body: + visit_stmt(child, True) + for child in stmt.else_body: + visit_stmt(child, True) + return + if isinstance(stmt, ExprStmt): + visit_expr(stmt.expr, conditional) + return + for value in getattr(stmt, "__dict__", {}).values(): + if isinstance(value, ASTNode): + visit_expr(value, conditional) + elif isinstance(value, (list, tuple)): + for child in value: + if isinstance(child, ASTNode): + visit_expr(child, conditional) + + visit_expr(expr_node, False) + def _security_next_inline_name(self, sec_id: int, func_name: str, base_name: str) -> str: self._security_inline_counter += 1 return ( @@ -2207,9 +2500,18 @@ def _build_security_expr( if series_name is not None: return f'_security_helper_series_["{series_name}"][{index_cpp}]' return bound - obj_cpp = self._build_security_expr( - sec_id, + # Function parameters retain Pine's series identity. Apply + # history to the supported bound bar series and compose + # nested offsets before lowering. Unsupported scalar/ + # expression bindings fail closed in the shared helper. + composed = self._compose_security_helper_history_subscript( bound, + expr_node.index, + expr_node, + ) + return self._build_security_expr( + sec_id, + composed, ta_range, ta_results, resolving, @@ -2217,10 +2519,12 @@ def _build_security_expr( helper_binding_stack, emitted_lines, ) - return f"{obj_cpp}[{index_cpp}]" if expr_node.object.name in SECURITY_BAR_FIELDS: field = expr_node.object.name - idx_lit = self._literal_int_for_security_index(expr_node.index) + idx_lit = self._resolve_security_index_literal( + expr_node.index, + helper_binding_stack, + ) if idx_lit is not None: if idx_lit == 0: return self._security_bar_field_expr(field) diff --git a/tests/test_security_helper_local_var.py b/tests/test_security_helper_local_var.py new file mode 100644 index 0000000..b239223 --- /dev/null +++ b/tests/test_security_helper_local_var.py @@ -0,0 +1,379 @@ +"""Micro-contract for helper-local ``var`` inside ``request.security``. + +The N=2 matrix crosses the two independent semantics that previously hid one +another: persistent declaration (plain/``var``) and history use (none/``[1]``). +All four cells use a two-minute requested context over one-minute input so the +same requested bar is evaluated once and then recomputed. This makes a +missing rollback visible instead of testing only the easier new-bar path. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests import _compile as compile_env + + +_N2_MATRIX = """//@version=6 +strategy("security helper var N2") + +f00(float src) => + float state = src + state := state + 1.0 + state + +f10(float src) => + var float state = src + state += 1.0 + state + +f01(float src) => + float state = src + state := nz(state[1], 0.0) + 1.0 + state + +f11(float src) => + var float state = src + state := nz(state[1], 0.0) + 1.0 + state + +out00 = request.security(syminfo.tickerid, "2", f00(close), lookahead=barmerge.lookahead_on) +out10 = request.security(syminfo.tickerid, "2", f10(close), lookahead=barmerge.lookahead_on) +out01 = request.security(syminfo.tickerid, "2", f01(close), lookahead=barmerge.lookahead_on) +out11 = request.security(syminfo.tickerid, "2", f11(close), lookahead=barmerge.lookahead_on) +""" + + +_CONCORDANCE_SHAPE = """//@version=6 +strategy("Concordance helper-local var probe") +int almaLen = 9 +int sdLen = 9 +float almaFactor = 1.5 + +f_alma_sig(float src) => + float alma = ta.alma(src, almaLen, 0.85, 4.0) + float dev = ta.stdev(src, sdLen) + float upper = alma + almaFactor * dev + float lower = alma - almaFactor * dev + float prevUpper = nz(upper[1], upper) + float prevLower = nz(lower[1], lower) + upper := upper < prevUpper or src[1] > prevUpper ? upper : prevUpper + lower := lower > prevLower or src[1] < prevLower ? lower : prevLower + var int dir = 0 + dir := src > upper ? 1 : src < lower ? -1 : nz(dir[1], 0) + [dir, alma] + +[sig15, alma15] = request.security(syminfo.tickerid, "15", f_alma_sig(close[1]), lookahead=barmerge.lookahead_off) +[sig60, alma60] = request.security(syminfo.tickerid, "60", f_alma_sig(close[1]), lookahead=barmerge.lookahead_off) +[sig240, alma240] = request.security(syminfo.tickerid, "240", f_alma_sig(close[1]), lookahead=barmerge.lookahead_off) +[sigD, almaD] = request.security(syminfo.tickerid, "D", f_alma_sig(close[1]), lookahead=barmerge.lookahead_off) +plot(sig15 + sig60 + sig240 + sigD + alma15 + alma60 + alma240 + almaD) +""" + + +def _eval_body(cpp: str, sec_id: int, next_sec_id: int | None) -> str: + start = cpp.index(f"void _eval_security_{sec_id}(") + marker = ( + f"void _eval_security_{next_sec_id}(" + if next_sec_id is not None + else "void evaluate_security(" + ) + return cpp[start:cpp.index(marker, start)] + + +def _series_key(body: str) -> str: + match = re.search(r'_security_helper_series_\["([^"@]+_state)"\]\.size\(\)', body) + assert match is not None, body + return match.group(1) + + +def test_n2_matrix_emits_distinct_plain_var_history_and_rollback_cells(): + cpp = transpile(_N2_MATRIX) + cell00 = _eval_body(cpp, 0, 1) + cell10 = _eval_body(cpp, 1, 2) + cell01 = _eval_body(cpp, 2, 3) + cell11 = _eval_body(cpp, 3, None) + + # 00: an ordinary non-history temporary remains a scalar local. + assert "_security_helper_series_" not in cell00 + + # 10: var forces requested-context persistence even without an explicit + # history read. Its initializer is captured once, new bars carry state, + # and same-bar recomputation rolls back to the prior bar (or first seed). + key10 = _series_key(cell10) + seed10 = f"{key10}@var_seed" + assert f'_security_helper_series_["{seed10}"].push(bar.close);' in cell10 + assert ( + f'_security_helper_series_["{key10}"].push(' + f'_security_helper_series_["{key10}"][0]);' + ) in cell10 + assert ( + f'_security_helper_series_["{key10}"].size() > 1 ' + f'? _security_helper_series_["{key10}"][1] ' + f': _security_helper_series_["{seed10}"][0]' + ) in cell10 + + # 01: a plain history-bearing local keeps its established per-slot + # declaration recomputation behavior and never gains var seed state. + key01 = _series_key(cell01) + assert "@var_seed" not in cell01 + assert f'_security_helper_series_["{key01}"].push(bar.close);' in cell01 + assert f'_security_helper_series_["{key01}"].update(bar.close);' in cell01 + assert f'_security_helper_series_["{key01}"][1]' in cell01 + + # 11: the target shape composes persistence, rollback, and [1] recurrence. + key11 = _series_key(cell11) + assert f"{key11}@var_seed" in cell11 + assert f'_security_helper_series_["{key11}"][1]' in cell11 + + +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 + if compile_env._ENGINE_INC is None: + return None + root = compile_env._ENGINE_INC.parent + candidates: list[Path] = [] + for pattern in ("build*/lib/libpineforge.a", "build*/lib/libpineforge.dylib"): + candidates.extend(sorted(root.glob(pattern))) + return candidates[0].resolve() if candidates else None + + +def _compile_and_run(cpp_source: str) -> str: + compile_env.skip_if_no_compile_env() + engine_lib = _find_engine_library() + if engine_lib is None: + pytest.skip("built libpineforge not found; set PINEFORGE_ENGINE_LIB") + + 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-security-var-") as tmp: + cpp_path = Path(tmp) / "matrix.cpp" + exe_path = Path(tmp) / "matrix" + cpp_path.write_text(cpp_source) + cmd = [ + compiler, + "-std=c++17", + "-O0", + "-I", str(engine_inc), + "-I", str(eigen_inc), + ] + if compile_env._GENERATED_INC is not None: + cmd += ["-I", str(compile_env._GENERATED_INC)] + cmd += [str(cpp_path), str(engine_lib), "-pthread", "-o", str(exe_path)] + built = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if built.returncode != 0: + raise AssertionError( + "security helper-var runtime probe failed to link\n" + + "\n".join((built.stderr or built.stdout).splitlines()[:80]) + ) + ran = subprocess.run([str(exe_path)], capture_output=True, text=True, timeout=30) + if ran.returncode != 0: + raise AssertionError( + f"security helper-var runtime probe exited {ran.returncode}\n" + f"stdout:\n{ran.stdout}\nstderr:\n{ran.stderr}" + ) + return ran.stdout + + +def test_n2_matrix_runtime_recomputes_without_double_mutating_var_state(): + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bars[] = { + Bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}, + Bar{2.0, 2.0, 2.0, 2.0, 1.0, 60000}, + Bar{3.0, 3.0, 3.0, 3.0, 1.0, 120000}, + Bar{4.0, 4.0, 4.0, 4.0, 1.0, 180000}, + }; + strategy.run(bars, 4, "1", "1"); + std::cout << strategy.out00 << " " << strategy.out10 << " " + << strategy.out01 << " " << strategy.out11 << "\n"; + return 0; +} +""" + values = tuple(float(value) for value in _compile_and_run(transpile(_N2_MATRIX) + driver).split()) + # f10's initializer captures close=1 only once. Each two-minute requested + # bar then increments once: 1 -> 2 -> 3. Without same-slot rollback the + # four child evaluations would incorrectly produce 5. + assert values == (5.0, 3.0, 2.0, 2.0) + + +def test_concordance_tuple_helper_shape_composes_offsets_and_compiles(): + cpp = transpile(_CONCORDANCE_SHAPE) + for sec_id in range(4): + assert re.search( + rf"std::tuple _req_sec_{sec_id}\s*=", + cpp, + ) + assert f'_sec{sec_id}_f_alma_sig_' in cpp + # f(close[1]) plus src[1] must become close[2], represented by the HTF + # history series at offset 1. It must never subscript the scalar read. + assert re.search(r'_sec0_hist_close\[1\]', cpp) + assert not re.search(r'_sec\d+_hist_close\[\d+\]\[', cpp) + compile_env.compile_cpp(cpp, label="concordance-security-helper-var") + + +def test_helper_parameter_history_declares_direct_and_composed_bar_series(): + src = """//@version=6 +strategy("security helper parameter history") +f(float src) => + src[1] +direct = request.security(syminfo.tickerid, "2", f(close)) +composed = request.security(syminfo.tickerid, "2", f(close[0])) +plot(direct + composed) +""" + cpp = transpile(src) + assert "Series _sec0_hist_close" in cpp + assert "Series _sec1_hist_close" in cpp + assert "_req_sec_0 = _sec0_hist_close[0];" in cpp + assert "_req_sec_1 = _sec1_hist_close[0];" in cpp + assert "_sec0_hist_close.push(bar.close);" in cpp + assert "_sec1_hist_close.push(bar.close);" in cpp + compile_env.compile_cpp(cpp, label="security-helper-parameter-history") + + +def test_helper_parameter_history_rejects_non_bar_series_binding(): + src = """//@version=6 +strategy("security helper expression history reject") +f(float src) => + src[1] +out = request.security(syminfo.tickerid, "2", f(close + 1.0)) +""" + with pytest.raises(CompileError, match="direct OHLC/time bar-series binding"): + transpile(src) + + +def test_helper_parameter_history_index_binding_matches_declaration_prepass(): + src = """//@version=6 +strategy("security helper bound history index") +f(float src, int offset) => + src[offset] +current = request.security(syminfo.tickerid, "2", f(close, 0)) +previous = request.security(syminfo.tickerid, "2", f(close, 1)) +plot(current + previous) +""" + cpp = transpile(src) + assert "Series _sec0_hist_close" not in cpp + assert "_req_sec_0 = bar.close;" in cpp + assert "Series _sec1_hist_close" in cpp + assert "_req_sec_1 = _sec1_hist_close[0];" in cpp + compile_env.compile_cpp(cpp, label="security-helper-bound-history-index") + + +def test_helper_local_varip_remains_rejected(): + src = """//@version=6 +strategy("varip reject") +f(float src) => + varip float state = src + state += 1.0 + state +out = request.security(syminfo.tickerid, "2", f(close)) +""" + with pytest.raises(CompileError, match="varip"): + transpile(src) + + +def test_non_scalar_helper_local_var_state_is_not_silently_numericized(): + src = """//@version=6 +strategy("string var reject") +f() => + var string state = "seed" + state +out = request.security(syminfo.tickerid, "2", f()) +""" + with pytest.raises(CompileError, match="supports only int, float, and bool"): + transpile(src) + + +def test_conditional_helper_local_var_state_is_rejected(): + src = """//@version=6 +strategy("conditional helper var reject") +f(float src) => + float out = 0.0 + if src > 0.0 + var float state = 0.0 + state += 1.0 + out := state + out +value = request.security(syminfo.tickerid, "2", f(close)) +""" + with pytest.raises(CompileError, match="inside conditional control flow"): + transpile(src) + + +def test_nested_conditional_persistent_helper_call_is_rejected(): + src = """//@version=6 +strategy("conditional nested helper var reject") +stateful(float src) => + var float state = src + state += 1.0 + state +outer(float src) => + float out = 0.0 + if src > 0.0 + out := stateful(src) + out +value = request.security(syminfo.tickerid, "2", outer(close)) +""" + with pytest.raises(CompileError, match="inside conditional control flow"): + transpile(src) + + +def test_nested_persistent_helper_in_var_initializer_is_rejected(): + src = """//@version=6 +strategy("persistent initializer nested helper reject") +stateful() => + var float state = 0.0 + state += 1.0 + state +outer() => + var float captured = stateful() + captured +value = request.security(syminfo.tickerid, "2", outer()) +""" + with pytest.raises(CompileError, match="inside conditional control flow"): + transpile(src) + + +def test_security_tuple_helper_rejects_three_elements_before_codegen(): + src = """//@version=6 +strategy("three tuple reject") +f(float src) => + [src, src + 1.0, src + 2.0] +[a, b, c] = request.security(syminfo.tickerid, "2", f(close)) +""" + with pytest.raises( + CompileError, + match="exactly two numeric elements", + ): + transpile(src) + + +def test_security_tuple_helper_rejects_mixed_elements_before_codegen(): + src = """//@version=6 +strategy("mixed tuple reject") +f(float src) => + string tag = "value" + [src, tag] +[value, tag] = request.security(syminfo.tickerid, "2", f(close)) +""" + with pytest.raises( + CompileError, + match="exactly two numeric elements", + ): + transpile(src)