From 030c9c8650958558769b7957a3da39ebb5e2d37a Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 20 Jul 2026 13:52:12 +0800 Subject: [PATCH] fix: honor security history offset lifecycles --- pineforge_codegen/analyzer/base.py | 9 + pineforge_codegen/analyzer/contracts.py | 4 + pineforge_codegen/codegen/security.py | 580 +++++++++++++++--- .../test_security_ta_input_history_offset.py | 558 +++++++++++++++++ 4 files changed, 1078 insertions(+), 73 deletions(-) create mode 100644 tests/test_security_ta_input_history_offset.py diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 319625b..d6cfc73 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -192,6 +192,11 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._block_var_owner: dict[str, int] = {} self._block_var_renames: dict[int, dict[str, str]] = {} self._block_var_seq = 0 + # Identifier node identity -> the lexical Symbol.scope resolved while + # that node's source scope is live. Codegen uses this narrow + # provenance to distinguish true global aliases from same-named local + # shadows after the analyzer's scope stack has unwound. + self._identifier_binding_scopes: dict[int, str | None] = {} self._ta_counter = 0 self._fixnan_counter = 0 # All fixnan member names minted so far (base + clones), for O(1) @@ -520,6 +525,7 @@ def analyze(self) -> AnalyzerContext: filename=self._filename, global_var_decls=self._global_var_decls, global_expr_map=pure_global_expr_map, + identifier_binding_scopes=dict(self._identifier_binding_scopes), var_member_init_exprs=self._var_member_init_exprs, var_member_metadata_by_node=self._var_member_metadata_by_node, var_member_type_specs_by_node=self._var_member_type_specs_by_node, @@ -4762,6 +4768,9 @@ def _visit_Identifier(self, node: Identifier) -> PineType: return PineType.VOID sym = self._symbols.resolve(node.name) + self._identifier_binding_scopes[id(node)] = ( + getattr(sym, "scope", None) if sym is not None else None + ) if sym is not None: return sym.pine_type diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index e6212ba..146004f 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -221,6 +221,10 @@ class AnalyzerContext: filename: str = "" global_var_decls: list = field(default_factory=list) # [(name, PineType)] non-var global scope vars global_expr_map: dict = field(default_factory=dict) # name -> defining AST expr (global, non-var) + # Exact lexical binding scope for each visited Identifier AST node. Raw + # name lookup is not enough once a UDF parameter, block local, or loop + # iterator shadows a same-spelled global binding. + identifier_binding_scopes: dict = field(default_factory=dict) # id(Identifier) -> scope name | None var_member_init_exprs: dict = field(default_factory=dict) # var-member name -> init AST expr # id(VarDecl) -> (node, emitted member name, PineType, rendered initializer, # is function/method scoped) diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index a49f2af..06e81d8 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -591,19 +591,33 @@ def _fold_security_const_bool( return None return (left and right) if node.op == "and" else (left or right) if isinstance(node, Identifier): - binding = self._security_lookup_helper_binding_context( - node.name, helper_binding_stack - ) + binding = None + if not self._security_identifier_is_global_binding(node): + binding = self._security_lookup_helper_binding_context( + node.name, helper_binding_stack + ) if binding is not None: bound, bound_stack = binding + if isinstance(bound, str): + return None return self._fold_security_const_bool( bound, bound_stack, resolving ) + param_key = f"param:{id(node)}" + param_binding = self._security_index_param_callsite_binding(node) + if param_binding is not None and param_key not in resolving: + return self._fold_security_const_bool( + param_binding, (), resolving | {param_key} + ) global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} - if node.name in global_expr_map and node.name not in resolving: + if ( + self._security_identifier_is_global_binding(node) + and node.name in global_expr_map + and node.name not in resolving + ): resolving.add(node.name) out = self._fold_security_const_bool( - global_expr_map[node.name], helper_binding_stack, resolving + global_expr_map[node.name], (), resolving ) resolving.remove(node.name) return out @@ -630,19 +644,33 @@ def _resolve_security_index_literal( if resolving is None: resolving = set() if isinstance(node, Identifier): - binding = self._security_lookup_helper_binding_context( - node.name, helper_binding_stack - ) + binding = None + if not self._security_identifier_is_global_binding(node): + binding = self._security_lookup_helper_binding_context( + node.name, helper_binding_stack + ) if binding is not None: bound, bound_stack = binding + if isinstance(bound, str): + return None return self._resolve_security_index_literal( bound, bound_stack, resolving ) + param_key = f"param:{id(node)}" + param_binding = self._security_index_param_callsite_binding(node) + if param_binding is not None and param_key not in resolving: + return self._resolve_security_index_literal( + param_binding, (), resolving | {param_key} + ) global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} - if node.name in global_expr_map and node.name not in resolving: + if ( + self._security_identifier_is_global_binding(node) + and node.name in global_expr_map + and node.name not in resolving + ): resolving.add(node.name) out = self._resolve_security_index_literal( - global_expr_map[node.name], helper_binding_stack, resolving + global_expr_map[node.name], (), resolving ) resolving.remove(node.name) return out @@ -657,6 +685,173 @@ def _resolve_security_index_literal( ) return None + def _security_identifier_is_global_binding(self, node: Identifier) -> bool: + """Whether this exact identifier AST resolved to a global binding. + + ``global_expr_map`` is keyed only by spelling. Consulting it without + node provenance lets a UDF parameter, block local, or loop iterator + capture a same-named global input after analysis scopes have unwound. + """ + scopes = getattr(self.ctx, "identifier_binding_scopes", {}) or {} + return scopes.get(id(node)) == "global" + + def _security_index_param_callsite_binding(self, node: Identifier): + """Resolve one UDF parameter index from its call sites, conservatively. + + A request.security evaluator is a class method, so a parameter of the + UDF that *contains* the request call is not otherwise in scope there. + Reuse the established timeframe-callsite model for the narrow offset + case: one call site is exact; several are accepted only for the same + literal or the same proven-global identifier. Mixed bindings fail + closed instead of sharing one evaluator across different offsets. + """ + scopes = getattr(self.ctx, "identifier_binding_scopes", {}) or {} + scope = scopes.get(id(node)) + if not isinstance(scope, str) or not scope.startswith("func_"): + return None + func_name = scope[5:] + func_info = self._func_info_map.get(func_name) + if ( + func_info is None + or func_info.node is None + or node.name not in func_info.node.params + ): + return None + # A parameter is only equal to its call-site argument until the UDF + # reassigns it. Specializing a later ``ta.*[param]`` from the authored + # call argument would otherwise ignore legal ``param := ...`` writes + # and silently select the wrong requested-context history bar. + for child in self._walk_ast(func_info.node): + if ( + isinstance(child, Assignment) + and isinstance(child.target, Identifier) + and child.target.name == node.name + ): + return None + if isinstance(child, VarDecl) and child.name == node.name: + return None + if isinstance(child, TupleAssign) and node.name in child.names: + return None + if isinstance(child, ForStmt) and child.var == node.name: + return None + if isinstance(child, ForInStmt) and ( + child.var == node.name or node.name in (child.vars or []) + ): + return None + param_index = list(func_info.node.params).index(node.name) + bound_args = [] + for call in self._walk_ast(self.ctx.ast): + if not ( + isinstance(call, FuncCall) + and isinstance(call.callee, Identifier) + and call.callee.name == func_name + ): + continue + arg = call.args[param_index] if param_index < len(call.args) else None + if node.name in call.kwargs: + arg = call.kwargs[node.name] + if arg is None: + return None + bound_args.append(arg) + if not bound_args: + return None + + first = bound_args[0] + + def equivalent(left, right) -> bool: + if isinstance(left, NumberLiteral) and isinstance(right, NumberLiteral): + return left.value == right.value + if isinstance(left, Identifier) and isinstance(right, Identifier): + return ( + left.name == right.name + and self._security_identifier_is_global_binding(left) + and self._security_identifier_is_global_binding(right) + ) + return left is right + + if not all(equivalent(first, other) for other in bound_args[1:]): + return None + return first + + def _resolve_security_immutable_input_int( + self, + node, + helper_binding_stack: tuple[dict[str, ASTNode], ...] | None = None, + resolving: set[int] | None = None, + ) -> tuple[FuncCall, tuple[dict[str, ASTNode], ...]] | None: + """Resolve an immutable security-history offset to its ``input.int``. + + Pine v6 permits a bar-invariant input integer as a history offset. A + request.security TA offset needs special admission because its backing + history advances in the requested context, not in chart context. Keep + this proof deliberately narrower than arbitrary ``series int`` support: + + * the leaf must be exactly ``input.int``; + * helper-parameter and top-level immutable alias chains are followed; + * ``var``/``varip`` or any globally reassigned alias is rejected; and + * arithmetic/ternary expressions remain unsupported for now. + + Return the proven input call plus the lexical stack in which that call + was authored. Rendering the original identifier under a helper's + stack would let a same-named helper parameter capture a global alias. + Crossing ``global_expr_map`` therefore resets the stack to global. + + The generated ``Series`` safely returns ``na`` for insufficient depth; + negative runtime overrides are also lowered to ``na`` by the caller. + """ + if resolving is None: + resolving = set() + + if isinstance(node, FuncCall): + func_name, namespace = self._resolve_callee(node.callee) + if namespace == "input" and func_name == "int": + return node, tuple(helper_binding_stack or ()) + return None + + if not isinstance(node, Identifier) or id(node) in resolving: + return None + + binding = None + if not self._security_identifier_is_global_binding(node): + binding = self._security_lookup_helper_binding_context( + node.name, helper_binding_stack + ) + if binding is not None: + bound, bound_stack = binding + if isinstance(bound, str): + return None + return self._resolve_security_immutable_input_int( + bound, + bound_stack, + resolving | {id(node)}, + ) + + param_binding = self._security_index_param_callsite_binding(node) + if param_binding is not None: + return self._resolve_security_immutable_input_int( + param_binding, + (), + resolving | {id(node)}, + ) + + # Mutable globals have requested-context state of their own and cannot + # be replaced by their authored initializer without changing Pine + # semantics. Reject them before following global_expr_map. + if node.name in getattr(self, "_global_mutable_infos", {}): + return None + + if not self._security_identifier_is_global_binding(node): + return None + + global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} + if node.name not in global_expr_map: + return None + return self._resolve_security_immutable_input_int( + global_expr_map[node.name], + (), + resolving | {id(node)}, + ) + def _compose_security_helper_history_subscript( self, bound, @@ -721,9 +916,11 @@ def walk(n, bindings) -> None: return if isinstance(n, Identifier): - binding = self._security_lookup_helper_binding_context( - n.name, bindings - ) + binding = None + if not self._security_identifier_is_global_binding(n): + binding = self._security_lookup_helper_binding_context( + n.name, bindings + ) if binding is not None: bound, bound_stack = binding if not isinstance(bound, str): @@ -741,16 +938,22 @@ def walk(n, bindings) -> None: 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: + if ( + self._security_identifier_is_global_binding(n) + and n.name in global_expr_map + and n.name not in resolving + ): resolving.add(n.name) - walk(global_expr_map[n.name], bindings) + walk(global_expr_map[n.name], ()) resolving.remove(n.name) return if isinstance(n, Subscript) and isinstance(n.object, Identifier): - binding = self._security_lookup_helper_binding_context( - n.object.name, bindings - ) + binding = None + if not self._security_identifier_is_global_binding(n.object): + binding = self._security_lookup_helper_binding_context( + n.object.name, bindings + ) if binding is not None: bound, bound_stack = binding if not isinstance(bound, str): @@ -779,7 +982,11 @@ def walk(n, bindings) -> None: 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: + if ( + self._security_identifier_is_global_binding(n.object) + and n.object.name in global_expr_map + and n.object.name not in resolving + ): resolving.add(n.object.name) walk( Subscript( @@ -937,50 +1144,172 @@ def _collect_security_ta_hist_indices(self, node) -> set[int]: per-site ``Series`` filled (gated on ``is_complete``) in ``_eval_security_N``. Mirrors ``_collect_security_ohlc_hist_fields`` for OHLC offsets. Offset 0 reuses the current committed value (``_secval_*``) - and needs no Series, so only index >= 1 registers here.""" + and needs no Series, so only index >= 1 registers here. A dynamic + index is registered conservatively; the expression emitter separately + admits only an immutable ``input.int`` chain and rejects everything + else. This declaration prepass must expand helper/global bindings just + like ``_build_security_expr`` or a valid helper-wrapped offset could + emit a history read without storage, pushes, or reset.""" out: set[int] = set() global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} + resolving: set[str] = set() - def resolve_ta_site(obj, resolving: set[str] | None = None): + def resolve_ta_site(obj, bindings, seen: set[str] | None = None): """_get_ta_site only matches the literal ta.* FuncCall node by - identity; fall back through global_expr_map for an indirect - binding (``v = ta.ema(close, 55)`` then ``...v[1]...``), mirroring - the same fallback in _build_security_expr's Subscript branch.""" + identity; fall back through helper/global bindings for an indirect + binding (``v = ta.ema(close, 55)`` then ``...v[1]...``).""" site = self._get_ta_site(obj) if site is not None: return site if isinstance(obj, Identifier): - if resolving is None: - resolving = set() - if obj.name in global_expr_map and obj.name not in resolving: - resolving.add(obj.name) - return resolve_ta_site(global_expr_map[obj.name], resolving) + if seen is None: + seen = set() + if obj.name in seen: + return None + binding = None + if not self._security_identifier_is_global_binding(obj): + binding = self._security_lookup_helper_binding_context( + obj.name, bindings + ) + if binding is not None: + bound, bound_stack = binding + if not isinstance(bound, str): + return resolve_ta_site( + bound, bound_stack, seen | {obj.name} + ) + if ( + self._security_identifier_is_global_binding(obj) + and obj.name in global_expr_map + ): + return resolve_ta_site( + global_expr_map[obj.name], + (), + seen | {obj.name}, + ) return None - 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): + binding = None + if not self._security_identifier_is_global_binding(n): + binding = self._security_lookup_helper_binding_context( + n.name, bindings + ) + if binding is not None: + bound, bound_stack = binding + if not isinstance(bound, str): + key = f"bind:{id(bound)}" + if key not in resolving: + resolving.add(key) + walk(bound, bound_stack) + 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 + if ( + self._security_identifier_is_global_binding(n) + and n.name in global_expr_map + and n.name not in resolving + ): + resolving.add(n.name) + walk(global_expr_map[n.name], ()) + resolving.remove(n.name) + return + if isinstance(n, Subscript): - site = resolve_ta_site(n.object) + site = resolve_ta_site(n.object, bindings) if site is not None: - idx_lit = self._resolve_security_index_literal(n.index) - if idx_lit is not None and idx_lit >= 1: + idx_lit = self._resolve_security_index_literal(n.index, bindings) + if idx_lit is None or idx_lit >= 1: site_idx = self._ta_index_by_site_id.get(id(site)) if site_idx is not None: out.add(site_idx) + + 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 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) + walk(x, bindings) return for _k, v in getattr(n, "__dict__", {}).items(): if isinstance(v, ASTNode): - walk(v) + walk(v, bindings) elif isinstance(v, (list, tuple)): for x in v: if isinstance(x, ASTNode): - walk(x) + walk(x, bindings) - walk(node) + walk(node, ()) return out def _security_ta_hist_series_cpp(self, member_name: str) -> str: @@ -1769,9 +2098,11 @@ def _expr_depends_on_security_mutables( resolving = set() if isinstance(expr_node, Identifier): - binding = self._security_lookup_helper_binding_context( - expr_node.name, helper_binding_stack - ) + binding = None + if not self._security_identifier_is_global_binding(expr_node): + binding = self._security_lookup_helper_binding_context( + expr_node.name, helper_binding_stack + ) if binding is not None: bound, bound_stack = binding return self._expr_depends_on_security_mutables( @@ -1981,10 +2312,12 @@ def resolve( if current is None or depth > 32: return None if isinstance(current, Identifier): - binding = self._security_lookup_helper_binding_context( - current.name, - stack, - ) + binding = None + if not self._security_identifier_is_global_binding(current): + binding = self._security_lookup_helper_binding_context( + current.name, + stack, + ) if binding is None: return current bound, bound_stack = binding @@ -2094,9 +2427,11 @@ def _collect_security_ta_binding_stacks( resolving = set() if isinstance(expr_node, Identifier): - binding = self._security_lookup_helper_binding_context( - expr_node.name, helper_binding_stack - ) + binding = None + if not self._security_identifier_is_global_binding(expr_node): + binding = self._security_lookup_helper_binding_context( + expr_node.name, helper_binding_stack + ) if binding is not None: bound, bound_stack = binding if isinstance(bound, str): @@ -2138,12 +2473,16 @@ def _collect_security_ta_binding_stacks( return collected global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} - if expr_node.name in global_expr_map and expr_node.name not in resolving: + if ( + self._security_identifier_is_global_binding(expr_node) + and expr_node.name in global_expr_map + and expr_node.name not in resolving + ): resolving.add(expr_node.name) self._collect_security_ta_binding_stacks( global_expr_map[expr_node.name], resolving, - helper_binding_stack, + (), collected, inline_ta_indices, inline_helper, @@ -2827,9 +3166,11 @@ def _build_security_expr( helper_binding_stack = () if isinstance(expr_node, Identifier): - binding = self._security_lookup_helper_binding_context( - expr_node.name, helper_binding_stack - ) + binding = None + if not self._security_identifier_is_global_binding(expr_node): + binding = self._security_lookup_helper_binding_context( + expr_node.name, helper_binding_stack + ) if binding is not None: bound, bound_stack = binding if isinstance(bound, str): @@ -2864,7 +3205,11 @@ def _build_security_expr( return state_name global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} - if expr_node.name in global_expr_map and expr_node.name not in resolving: + if ( + self._security_identifier_is_global_binding(expr_node) + and expr_node.name in global_expr_map + and expr_node.name not in resolving + ): resolving.add(expr_node.name) resolved = self._build_security_expr( sec_id, @@ -2873,7 +3218,7 @@ def _build_security_expr( ta_results, resolving, security_mutable_names, - helper_binding_stack, + (), emitted_lines, ) resolving.remove(expr_node.name) @@ -2889,25 +3234,27 @@ def _build_security_expr( return resolved if isinstance(expr_node, Subscript): - index_cpp = self._build_security_expr( - sec_id, - expr_node.index, - ta_range, - ta_results, - resolving, - security_mutable_names, - helper_binding_stack, - emitted_lines, - ) if isinstance(expr_node.object, Identifier): - binding = self._security_lookup_helper_binding_context( - expr_node.object.name, helper_binding_stack - ) + binding = None + if not self._security_identifier_is_global_binding(expr_node.object): + binding = self._security_lookup_helper_binding_context( + expr_node.object.name, helper_binding_stack + ) if binding is not None: bound, bound_stack = binding if isinstance(bound, str): series_name = self._security_series_binding_target(bound) if series_name is not None: + index_cpp = self._build_security_expr( + sec_id, + expr_node.index, + ta_range, + ta_results, + resolving, + security_mutable_names, + helper_binding_stack, + emitted_lines, + ) return f'_security_helper_series_["{series_name}"][{index_cpp}]' return bound # Function parameters retain Pine's series identity. Apply @@ -2960,6 +3307,16 @@ def _build_security_expr( hist = self._security_ohlc_hist_series_cpp(sec_id, field) cpp_t = self._security_bar_hist_type(field) current = self._security_bar_field_expr(field) + index_cpp = self._build_security_expr( + sec_id, + expr_node.index, + ta_range, + ta_results, + resolving, + security_mutable_names, + helper_binding_stack, + emitted_lines, + ) return ( f"([&]() -> {cpp_t} {{ " f"int _hidx = (int)({index_cpp}); " @@ -2979,8 +3336,11 @@ def _build_security_expr( # helper binding, whichever the resolved expression turns out # to be) instead of duplicating that dispatch here. global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} - if (expr_node.object.name in global_expr_map - and expr_node.object.name not in resolving): + if ( + self._security_identifier_is_global_binding(expr_node.object) + and expr_node.object.name in global_expr_map + and expr_node.object.name not in resolving + ): resolving.add(expr_node.object.name) resolved = self._build_security_expr( sec_id, @@ -3001,6 +3361,16 @@ def _build_security_expr( meta = self._security_expr_hist_by_node.get((sec_id, id(expr_node))) hist = meta["name"] if meta else f"_sec{sec_id}_expr_hist_missing" cpp_t = meta["type"] if meta else "double" + index_cpp = self._build_security_expr( + sec_id, + expr_node.index, + ta_range, + ta_results, + resolving, + security_mutable_names, + helper_binding_stack, + emitted_lines, + ) inner = self._build_security_expr( sec_id, expr_node.object, @@ -3032,14 +3402,72 @@ def _build_security_expr( # tick), so without a magnifier it advanced every chart bar and # produced the chart-tf TA instead of the confirmed HTF value. idx = self._ta_index_by_site_id.get(id(ta_site)) - sig = self._security_binding_stack_signature(helper_binding_stack) + # A top-level TA site reached through a global alias owns the + # global variant even when the subscript index was authored + # in a helper. Keep the helper stack for resolving that + # index, but do not use it to select the TA object's state. + ta_binding_stack = ( + () + if getattr(ta_site, "owner_func", None) is None + else helper_binding_stack + ) + sig = self._security_binding_stack_signature(ta_binding_stack) idx_lit = self._resolve_security_index_literal( expr_node.index, helper_binding_stack ) if idx_lit is None: + resolved_input = self._resolve_security_immutable_input_int( + expr_node.index, helper_binding_stack + ) + if resolved_input is None: + self._codegen_error( + expr_node, + "request.security() TA history index must be a literal integer (e.g. ta.ema(close, 55)[1])", + ) + input_node, input_stack = resolved_input + index_cpp = self._build_security_expr( + sec_id, + input_node, + ta_range, + ta_results, + resolving, + security_mutable_names, + input_stack, + emitted_lines, + ) + result_key = (idx, sig) + if result_key not in ta_results: + self._codegen_error( + expr_node, + "request.security multi-statement helper TA history is not supported", + hint="Use a single-expression helper for TA history offsets.", + ) + current = self._build_security_expr( + sec_id, + expr_node.object, + ta_range, + ta_results, + resolving, + security_mutable_names, + ta_binding_stack, + emitted_lines, + ) + member_name = self._security_ta_variant_names.get( + (sec_id, idx, sig), + f"_sec{sec_id}_{ta_site.member_name}", + ) + hist = self._security_ta_hist_series_cpp(member_name) + return ( + "([&]() -> double { " + f"int _hidx = (int)({index_cpp}); " + "if (is_na(_hidx) || _hidx < 0) return na(); " + f"return (_hidx == 0) ? {current} : {hist}[_hidx - 1]; " + "}())" + ) + if idx_lit < 0: self._codegen_error( expr_node, - "request.security() TA history index must be a literal integer (e.g. ta.ema(close, 55)[1])", + "request.security() TA history index must be non-negative", ) if idx_lit == 0: # Current completed-HTF-bar value: reuse the bare-TA emission. @@ -3050,9 +3478,15 @@ def _build_security_expr( ta_results, resolving, security_mutable_names, - helper_binding_stack, + ta_binding_stack, emitted_lines, ) + if (idx, sig) not in ta_results: + self._codegen_error( + expr_node, + "request.security multi-statement helper TA history is not supported", + hint="Use a single-expression helper for TA history offsets.", + ) member_name = self._security_ta_variant_names.get( (sec_id, idx, sig), f"_sec{sec_id}_{ta_site.member_name}", diff --git a/tests/test_security_ta_input_history_offset.py b/tests/test_security_ta_input_history_offset.py new file mode 100644 index 0000000..beafedd --- /dev/null +++ b/tests/test_security_ta_input_history_offset.py @@ -0,0 +1,558 @@ +"""Requested-context TA history with immutable ``input.int`` offsets. + +The TA value and its history must both live on the request.security clock. A +runtime input offset is legal Pine v6, but it still needs the same declaration, +completion-gated push, and reset lifecycle as a literal positive offset. +""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests._compile import compile_cpp +from tests.test_security_helper_local_var import _compile_and_run + + +SOURCE = """//@version=6 +strategy("security dynamic TA offset") +k = input.int(2, "Offset") +x = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[k], lookahead=barmerge.lookahead_off) +if not na(x) and close > x + strategy.entry("L", strategy.long) +""" + + +def _eval_body(cpp: str) -> str: + start = cpp.index("void _eval_security_0(") + end = cpp.index("void evaluate_security(", start) + return cpp[start:end] + + +def test_input_int_ta_offset_uses_complete_htf_history_and_reset(): + cpp = transpile(SOURCE) + body = _eval_body(cpp) + + # A: admit the override-aware integer offset and lower 0/current vs past. + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in body + # get_input_int can represent Pine's int-na sentinel (INT_MIN) through a + # host override, so guard it explicitly before ``_hidx - 1`` can overflow. + assert "if (is_na(_hidx) || _hidx < 0) return na();" in body + assert "return (_hidx == 0) ? _secval_0 : _sec0__ta_ema_1_hist[_hidx - 1];" in body + + # Existing invariant: read prior history before one completed HTF value is + # committed. A chart-bar/tick clock would silently shift every offset. + assign = body.index("_req_sec_0 = ([&]() -> double") + guard = body.index("if (is_complete) {", assign) + push = body.index("_sec0__ta_ema_1_hist.push(_secval_0);", guard) + assert assign < guard < push + assert "is_first_tick_" not in body + assert "_hist_call" not in body + + # B: direct dynamic-site registration owns declared storage and reset. The + # bare constructor intentionally retains the engine's finite default depth + # (500); a source max_bars_back directive is covered separately below. + assert "Series _sec0__ta_ema_1_hist;" in cpp + assert "_sec0__ta_ema_1_hist.clear();" in cpp + + +def test_input_int_ta_offset_cpp_compiles_against_engine_headers(): + compile_cpp(transpile(SOURCE), label="security_ta_input_history_offset") + + +def test_dynamic_offsets_match_literal_offsets_on_requested_context_clock(): + src = """//@version=6 +strategy("dynamic versus literal requested-context offsets") +k0 = input.int(0, "K0") +k1 = input.int(1, "K1") +k2 = input.int(2, "K2") +d0 = request.security(syminfo.tickerid, "2", ta.ema(close, 5)[k0]) +l0 = request.security(syminfo.tickerid, "2", ta.ema(close, 5)[0]) +d1 = request.security(syminfo.tickerid, "2", ta.ema(close, 5)[k1]) +l1 = request.security(syminfo.tickerid, "2", ta.ema(close, 5)[1]) +d2 = request.security(syminfo.tickerid, "2", ta.ema(close, 5)[k2]) +l2 = request.security(syminfo.tickerid, "2", ta.ema(close, 5)[2]) +outD0 = d0 +outL0 = l0 +outD1 = d1 +outL1 = l1 +outD2 = d2 +outL2 = l2 +plot(outD0) +""" + driver = r""" +#include +#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{4.0, 4.0, 4.0, 4.0, 1.0, 120000}, + Bar{8.0, 8.0, 8.0, 8.0, 1.0, 180000}, + Bar{16.0, 16.0, 16.0, 16.0, 1.0, 240000}, + Bar{32.0, 32.0, 32.0, 32.0, 1.0, 300000}, + Bar{64.0, 64.0, 64.0, 64.0, 1.0, 360000}, + Bar{128.0, 128.0, 128.0, 128.0, 1.0, 420000}, + }; + strategy.run(bars, 8, "1", "1"); + std::cout << std::setprecision(17) + << strategy.outD0 << " " << strategy.outL0 << " " + << strategy.outD1 << " " << strategy.outL1 << " " + << strategy.outD2 << " " << strategy.outL2 << "\n"; + return 0; +} +""" + values = tuple( + float(value) + for value in _compile_and_run(transpile(src) + driver).split() + ) + assert values[0] == values[1] + assert values[2] == values[3] + assert values[4] == values[5] + + +def test_input_int_ta_offset_honors_explicit_max_bars_back_over_500(): + cpp = transpile(SOURCE.replace( + 'strategy("security dynamic TA offset")', + 'strategy("security dynamic TA offset", max_bars_back=2048)', + )) + assert "Series _sec0__ta_ema_1_hist{2048};" in cpp + + +def test_input_int_ta_offset_follows_immutable_alias_chain(): + cpp = transpile(SOURCE.replace( + 'x = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[k],', + 'offsetAlias = k\n' + 'x = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[offsetAlias],', + )) + body = _eval_body(cpp) + assert 'get_input_int("Offset", 2)' in body + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in body + + +def test_input_int_ta_offset_inside_helper_gets_full_history_lifecycle(): + # A+B+C compose: runtime admission, dynamic registration, and helper reach. + src = """//@version=6 +strategy("helper dynamic TA offset") +requestedEma(int idx) => + ta.ema(close, 5)[idx] +k = input.int(2, "Offset") +x = request.security(syminfo.tickerid, "60", requestedEma(k)) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert "Series _sec0__ta_ema_1_hist;" in cpp + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + assert "_sec0__ta_ema_1_hist.clear();" in cpp + + +def test_helper_literal_ta_offset_gets_full_history_lifecycle_and_compiles(): + # C is independently useful even with A/B off: the old declaration prepass + # did not enter helper bodies, so this already-supported literal form + # transpiled to an undeclared ``_hist`` member and failed native compile. + src = """//@version=6 +strategy("helper literal TA offset") +requestedEma() => + ta.ema(close, 5)[1] +x = request.security(syminfo.tickerid, "60", requestedEma()) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert "Series _sec0__ta_ema_1_hist;" in cpp + assert "_req_sec_0 = _sec0__ta_ema_1_hist[0];" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + assert "_sec0__ta_ema_1_hist.clear();" in cpp + compile_cpp(cpp, label="security_helper_literal_ta_history_offset") + + +def test_input_int_ta_offset_through_global_expression_alias_gets_storage(): + src = """//@version=6 +strategy("global expression dynamic TA offset") +k = input.int(2, "Offset") +shifted = ta.ema(close, 5)[k] +x = request.security(syminfo.tickerid, "60", shifted) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert "Series _sec0__ta_ema_1_hist;" in cpp + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + + +def test_helper_literal_zero_does_not_allocate_unused_history(): + src = """//@version=6 +strategy("helper zero TA offset") +requestedEma(int idx) => + ta.ema(close, 5)[idx] +x = request.security(syminfo.tickerid, "60", requestedEma(0)) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert "_req_sec_0 = _secval_0;" in body + assert "_sec0__ta_ema_1_hist" not in cpp + + +def test_global_alias_is_not_captured_by_same_named_helper_parameter(): + src = """//@version=6 +strategy("global alias lexical provenance") +k = input.int(2, "Offset") +offsetAlias = k +requestedEma(int k) => + ta.ema(close, 5)[offsetAlias] +x = request.security(syminfo.tickerid, "60", requestedEma(0)) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in body + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + + +def test_global_ta_expression_is_not_captured_by_helper_parameter(): + src = """//@version=6 +strategy("global TA expression lexical provenance") +k = input.int(2, "Offset") +shifted = ta.ema(close, 5)[k] +requested(int k) => + shifted +x = request.security(syminfo.tickerid, "60", requested(0)) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in body + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + compile_cpp(cpp, label="security_global_ta_expr_lexical_provenance") + + +def test_nested_helper_global_input_is_not_captured_by_outer_parameter(): + src = """//@version=6 +strategy("nested helper lexical provenance") +k = input.int(2, "Offset") +inner() => + ta.ema(close, 5)[k] +outer(int k) => + inner() +x = request.security(syminfo.tickerid, "60", outer(0)) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in body + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + compile_cpp(cpp, label="security_nested_helper_global_input_provenance") + + +def test_nested_helper_global_ta_series_is_not_captured_by_outer_parameter(): + src = """//@version=6 +strategy("nested helper global TA series provenance") +v = ta.ema(close, 5) +inner() => + v[1] +outer(float v) => + inner() +x = request.security(syminfo.tickerid, "60", outer(close)) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert "_req_sec_0 = _sec0__ta_ema_1_hist[0];" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + assert "_req_sec_0 = _sec_close_hist_0[0];" not in body + compile_cpp(cpp, label="security_nested_helper_global_ta_provenance") + + +def test_global_ta_alias_keeps_helper_local_index_lifecycle(): + src = """//@version=6 +strategy("global TA alias helper-local offset") +v = ta.ema(close, 5) +requested(int idx) => + v[idx] +chartControl = requested(1) +k = input.int(2, "Offset") +x = request.security(syminfo.tickerid, "60", requested(k)) +plot(x + chartControl) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in body + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + compile_cpp(cpp, label="security_global_ta_alias_helper_local_offset") + + +def test_nested_helper_global_scalar_is_not_captured_by_outer_parameter(): + src = """//@version=6 +strategy("nested helper global scalar provenance") +g = 7 +inner() => + g +outer(float g) => + inner() +x = request.security(syminfo.tickerid, "60", outer(close)) +plot(x) +""" + body = _eval_body(transpile(src)) + assert "_req_sec_0 = 7;" in body + assert "_req_sec_0 = bar.close;" not in body + + +def test_containing_udf_parameter_cannot_capture_same_named_global_input(): + src = """//@version=6 +strategy("containing helper parameter shadow") +k = input.int(2, "Global offset") +requested(int k) => + request.security(syminfo.tickerid, "60", ta.ema(close, 5)[k]) +x = requested(bar_index) +plot(x) +""" + with pytest.raises( + CompileError, + match=r"TA history index must be a literal integer", + ): + transpile(src) + + +def test_containing_udf_parameter_resolves_unique_input_callsite(): + src = """//@version=6 +strategy("containing helper input offset") +requested(int idx) => + request.security(syminfo.tickerid, "60", ta.ema(close, 5)[idx]) +k = input.int(2, "Offset") +x = requested(k) +plot(x) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in body + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in body + assert "_sec0__ta_ema_1_hist.push(_secval_0);" in body + compile_cpp(cpp, label="security_containing_udf_input_history_offset") + + +def test_containing_udf_parameter_resolves_uniform_input_callsites(): + src = """//@version=6 +strategy("uniform containing helper input offset") +requested(int idx) => + request.security(syminfo.tickerid, "60", ta.ema(close, 5)[idx]) +k = input.int(2, "Offset") +x = requested(k) +y = requested(k) +plot(x + y) +""" + cpp = transpile(src) + assert cpp.count('get_input_int("Offset", 2)') >= 2 + assert "_sec0__ta_ema_1_hist[_hidx - 1]" in cpp + compile_cpp(cpp, label="security_uniform_udf_input_history_offset") + + +def test_containing_udf_parameter_resolves_keyword_input_callsite(): + src = """//@version=6 +strategy("keyword containing helper input offset") +requested(int idx) => + request.security(syminfo.tickerid, "60", ta.ema(close, 5)[idx]) +k = input.int(2, "Offset") +x = requested(idx=k) +plot(x) +""" + cpp = transpile(src) + assert 'int _hidx = (int)(get_input_int("Offset", 2));' in _eval_body(cpp) + compile_cpp(cpp, label="security_keyword_udf_input_history_offset") + + +def test_containing_udf_mixed_callsites_fail_closed(): + src = """//@version=6 +strategy("mixed containing helper input offset") +requested(int idx) => + request.security(syminfo.tickerid, "60", ta.ema(close, 5)[idx]) +k = input.int(2, "Offset") +x = requested(k) +y = requested(1) +plot(x + y) +""" + with pytest.raises( + CompileError, + match=r"TA history index must be a literal integer", + ): + transpile(src) + + +@pytest.mark.parametrize( + "reassignment", + ["idx := 1", "idx := int(close)"], + ids=["literal-reassignment", "series-reassignment"], +) +def test_containing_udf_parameter_reassignment_fails_closed( + reassignment: str, +): + src = f'''//@version=6 +strategy("reassigned containing helper offset") +requested(int idx) => + {reassignment} + request.security(syminfo.tickerid, "60", ta.ema(close, 5)[idx]) +k = input.int(2, "Offset") +x = requested(k) +plot(x) +''' + with pytest.raises( + CompileError, + match=r"TA history index must be a literal integer", + ): + transpile(src) + + +def test_containing_udf_parameter_reassignment_after_request_fails_closed(): + # Conservative on purpose: the parameter has a function-local lifecycle, + # so call-site substitution is disabled once any write exists, even after + # the request expression in authored statement order. + src = """//@version=6 +strategy("post-use reassigned containing helper offset") +requested(int idx) => + out = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[idx]) + idx := 1 + out +k = input.int(2, "Offset") +x = requested(k) +plot(x) +""" + with pytest.raises( + CompileError, + match=r"TA history index must be a literal integer", + ): + transpile(src) + + +@pytest.mark.parametrize( + "shadow", + [ + "int idx = 1", + "[idx, other] = [1, 2]", + ], + ids=["block-local", "tuple-local"], +) +def test_containing_udf_parameter_local_shadow_fails_closed(shadow: str): + src = f'''//@version=6 +strategy("shadowed containing helper offset") +requested(int idx) => + float out = na + if close > open + {shadow} + out := request.security(syminfo.tickerid, "60", ta.ema(close, 5)[idx]) + out +k = input.int(2, "Offset") +x = requested(k) +plot(x) +''' + with pytest.raises( + CompileError, + match=r"TA history index must be a literal integer", + ): + transpile(src) + + +@pytest.mark.parametrize( + "body", + [ + '''if close > open + k = bar_index + x = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[k])''', + '''for k = 0 to 1 + x = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[k])''', + ], + ids=["block-local", "loop-iterator"], +) +def test_local_shadow_cannot_capture_same_named_global_input(body: str): + src = f'''//@version=6 +strategy("local offset shadow") +k = input.int(2, "Global offset") +{body} +''' + with pytest.raises( + CompileError, + match=r"TA history index must be a literal integer", + ): + transpile(src) + + +@pytest.mark.parametrize("index", ["idx", "1"], ids=["dynamic", "literal"]) +def test_multistatement_helper_ta_history_remains_loudly_fail_closed(index: str): + src = f'''//@version=6 +strategy("linear helper TA history") +requestedEma(int idx) => + unrelated = 1 + ta.ema(close, 5)[{index}] +k = input.int(2, "Offset") +x = request.security(syminfo.tickerid, "60", requestedEma(k)) +plot(x) +''' + with pytest.raises( + CompileError, + match=r"multi-statement helper TA history is not supported", + ): + transpile(src) + + +@pytest.mark.parametrize( + "declaration,index", + [ + ("", "bar_index"), + ('k = input.float(2.0, "Offset")', "k"), + ('var int k = input.int(2, "Offset")', "k"), + ('k = input.int(2, "Offset")\nk := 3', "k"), + ('k = input.int(2, "Offset")', "k + 1"), + ], +) +def test_nonliteral_ta_offset_remains_fail_closed(declaration: str, index: str): + src = f'''//@version=6 +strategy("unsupported dynamic TA offset") +{declaration} +x = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[{index}]) +plot(x) +''' + with pytest.raises( + CompileError, + match=r"TA history index must be a literal integer", + ): + transpile(src) + + +def test_negative_literal_ta_offset_is_rejected_loudly(): + # D: fail closed before ``idx_lit - 1`` emits an undeclared negative read. + src = """//@version=6 +strategy("negative TA offset") +x = request.security(syminfo.tickerid, "60", ta.ema(close, 5)[-1]) +plot(x) +""" + with pytest.raises(CompileError, match="must be non-negative"): + transpile(src) + + +def test_excellent_control_keeps_literal_security_ema_path(): + # Source-faithful core of Concordance Execution Mandate [JOAT], whose + # idxHigher folds to 0 in the requested-context barstate approximation. + src = """//@version=6 +strategy("Concordance control") +f_secure_ema(string tf, int length, int idxHigher, int idxCurrent) => + request.security(syminfo.tickerid, tf, ta.ema(close, length)[idxHigher])[idxCurrent] +idxHigher = barstate.isrealtime ? 1 : 0 +idxCurrent = barstate.isrealtime ? 0 : 1 +primaryTf = input.timeframe("240", "Primary Bias Timeframe") +crossFastLen = input.int(21, "Crossframe Fast EMA", minval=2, maxval=100) +primaryFast = f_secure_ema(primaryTf, crossFastLen, idxHigher, idxCurrent) +plot(primaryFast) +""" + cpp = transpile(src) + body = _eval_body(cpp) + assert "_req_sec_0 = _secval_0;" in body + assert "_sec0__ta_ema_1_hist" not in cpp + assert "int _hidx" not in body