From c2f39c71dcc44748db957dcba777cbaa7a5a8462 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 11 Jul 2026 23:22:41 +0800 Subject: [PATCH] Resolve input-derived security timeframes before evaluator setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request.security / request.security_lower_tf timeframe expressions are emitted into configure_security_evaluators(), which runs once before the first on_bar(). A timeframe expression whose input-derived variable sat inside a BinOp, UnaryOp, FuncCall, or ternary CONDITION was rendered as the raw class member — still its C++ default at that point — so the conditional took the wrong branch and registered the security at the wrong timeframe. Directly-assigned input variables already resolved; the leaf-inside-subtree case did not. The same defect made the ctx_*_in override condition read the member, silently ignoring non-default overrides. The timeframe renderer now substitutes input-derived leaves anywhere in the expression tree with their input getter calls (known strings fold to literals; timeframe.period folds to the script timeframe; unchanged subtrees return the same object, keeping unaffected output byte-identical). Genuinely per-bar series-derived timeframe leaves are intentionally left as members — a documented residual pending an engine-side input-init prologue. Exemplar: an MTF confluence strategy registered its direction gates one timeframe level too fast (15/60/1 instead of 60/240/5), firing 266 phantom entries; fixed it converges to an exact 307/307 TV match (weak 69.5 -> excellent 100). Differential transpile: 0 of 23 corpus and 140 of 143 private security strategies byte-identical; the other two changed strategies are behavior-neutral at default inputs. Co-Authored-By: Claude Fable 5 --- pineforge_codegen/codegen/security.py | 97 +++++++++---- tests/test_security_tf_input_derived.py | 175 ++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 tests/test_security_tf_input_derived.py diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index f06287f..5801dba 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -140,42 +140,91 @@ def _security_tf_runtime_expr(self, node, resolving: set[str] | None = None) -> : inputTf`` must be expanded to their source expression with direct ``get_input_*`` reads. Emitting the member name would register with its default-constructed value (usually an empty string). + + Delegates to :meth:`_substitute_tf_input_reads`, which rewrites the + input-derived *leaves* of the expression tree — including leaves buried + inside a ternary CONDITION or any BinOp / UnaryOp / FuncCall — and then + renders the substituted tree through the normal expression visitor. """ if node is None: return None - resolving = resolving or set() - if isinstance(node, StringLiteral): - return self._visit_expr(node) + substituted = self._substitute_tf_input_reads(node, resolving or set()) + return self._visit_expr(substituted) + + def _substitute_tf_input_reads(self, node, resolving: set[str]): + """Return ``node`` with input-derived leaves rewritten to expressions + that are valid at security-registration time (before ``on_bar()`` + assigns members from their inputs): + + * an input-backed var -> its ``input.*()`` source call (``get_input_*``); + * a ``timeframe.period`` alias var -> ``timeframe.period`` (``script_tf_``); + * a known compile-time string var -> that string literal; + * a global alias -> its defining expression, expanded recursively. + + The walk descends through Ternary / BinOp / UnaryOp / FuncCall / + Subscript, so an input-backed identifier nested inside e.g. a ternary + condition (``mode == "15" ? "240" : "60"``) resolves to its input read + instead of the uninitialised member. A subtree containing no + input-derived leaf is returned unchanged (same object) so unaffected + timeframe expressions render byte-identically to before the fix. + MemberAccess and literals are left verbatim: ``timeframe.period`` is + already lowered to ``script_tf_`` by the expression visitor. + """ + if not isinstance(node, ASTNode): + return node if isinstance(node, Identifier): name = node.name if name in self._timeframe_period_vars: - return "script_tf_" + return MemberAccess(object=Identifier(name="timeframe"), member="period") if name in self._input_backed_vars and name in self._input_var_to_call: - return self._visit_expr(self._input_var_to_call[name]) + return self._input_var_to_call[name] if (name in self._known_vars and name not in self._input_backed_vars and isinstance(self._known_vars[name], str)): - return self._visit_expr(StringLiteral(value=self._known_vars[name])) + return StringLiteral(value=self._known_vars[name]) global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {} if name in global_expr_map and name not in resolving: - resolving.add(name) - out = self._security_tf_runtime_expr(global_expr_map[name], resolving) - resolving.remove(name) - return out - return self._visit_expr(node) - if ( - isinstance(node, MemberAccess) - and isinstance(node.object, Identifier) - and node.object.name == "timeframe" - and node.member == "period" - ): - return "script_tf_" + return self._substitute_tf_input_reads( + global_expr_map[name], resolving | {name}) + return node if isinstance(node, Ternary): - cond = self._security_tf_runtime_expr(node.condition, resolving) - tv = self._security_tf_runtime_expr(node.true_val, resolving) - fv = self._security_tf_runtime_expr(node.false_val, resolving) - if cond is not None and tv is not None and fv is not None: - return f"(({cond}) ? ({tv}) : ({fv}))" - return self._visit_expr(node) + cond = self._substitute_tf_input_reads(node.condition, resolving) + tv = self._substitute_tf_input_reads(node.true_val, resolving) + fv = self._substitute_tf_input_reads(node.false_val, resolving) + if cond is node.condition and tv is node.true_val and fv is node.false_val: + return node + return Ternary(condition=cond, true_val=tv, false_val=fv) + if isinstance(node, BinOp): + left = self._substitute_tf_input_reads(node.left, resolving) + right = self._substitute_tf_input_reads(node.right, resolving) + if left is node.left and right is node.right: + return node + return BinOp(left=left, op=node.op, right=right) + if isinstance(node, UnaryOp): + operand = self._substitute_tf_input_reads(node.operand, resolving) + if operand is node.operand: + return node + return UnaryOp(op=node.op, operand=operand) + if isinstance(node, FuncCall): + new_args = [self._substitute_tf_input_reads(a, resolving) for a in node.args] + new_kwargs = { + k: (self._substitute_tf_input_reads(v, resolving) + if isinstance(v, ASTNode) else v) + for k, v in node.kwargs.items() + } + unchanged = ( + all(a is b for a, b in zip(new_args, node.args)) + and all(new_kwargs[k] is node.kwargs[k] for k in node.kwargs) + ) + if unchanged: + return node + return FuncCall(callee=node.callee, args=new_args, kwargs=new_kwargs) + if isinstance(node, Subscript): + obj = self._substitute_tf_input_reads(node.object, resolving) + idx = self._substitute_tf_input_reads(node.index, resolving) + if obj is node.object and idx is node.index: + return node + return Subscript(object=obj, index=idx) + return node def _resolve_param_tf_from_callsites(self, func_name: str, param_name: str): """For a ``request.security`` whose tf is function parameter ``param_name`` diff --git a/tests/test_security_tf_input_derived.py b/tests/test_security_tf_input_derived.py new file mode 100644 index 0000000..a53d35c --- /dev/null +++ b/tests/test_security_tf_input_derived.py @@ -0,0 +1,175 @@ +"""Regression: ``request.security`` / ``request.security_lower_tf`` timeframe +arguments that depend on an ``input.*()``-derived script variable must be +emitted in ``configure_security_evaluators()`` over the INPUT SOURCE READS +(``get_input_string(...)`` etc.), never over the uninitialised class member. + +``configure_security_evaluators()`` runs once at setup, BEFORE the first +``on_bar()`` assigns members from their inputs. A member holding an input +value (e.g. ``exec_tf_mode``) is therefore still its C++ default ("") at +registration time, so a conditional timeframe such as +``exec_tf_mode == "15" ? "240" : "60"`` takes the wrong branch and registers +the security at the wrong timeframe. The fix resolves input-derived leaves — +anywhere in the tf expression, including inside a ternary *condition* — to the +input getter that is valid at registration time. + +See oscar-lab MTF confluence root-cause (data/ campaign, KI series). +""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile + + +PRELUDE = '//@version=6\nstrategy("t")\n' + + +def _register_lines(src: str) -> list[str]: + """Return the trimmed ``register_security*`` lines from the generated + ``configure_security_evaluators()`` body (order preserved).""" + return [ + ln.strip() + for ln in transpile(src).splitlines() + if "register_security_eval(" in ln + or "register_security_lower_tf_eval(" in ln + ] + + +# --------------------------------------------------------------------------- +# R1 (GREEN guard) — a tf that is a variable DIRECTLY assigned an input.*() +# call already resolves to the input getter (the var is in _input_var_to_call). +# The fix must keep this working. +# --------------------------------------------------------------------------- + +def test_r1_direct_input_var_tf_uses_input_getter(): + src = ( + PRELUDE + + 'tf = input.string("60", "TF")\n' + + "x = request.security(syminfo.tickerid, tf, close)\nplot(x)\n" + ) + line = _register_lines(src)[0] + assert 'get_input_string("TF"' in line, line + # The bare uninitialised member must not be the timeframe argument. + assert "0, tf," not in line, line + + +# --------------------------------------------------------------------------- +# R2 (RED on HEAD) — conditional timeframe over an input-derived var. The +# ternary CONDITION reads the member on HEAD; it must read the input getter. +# --------------------------------------------------------------------------- + +def test_r2_conditional_tf_over_input_uses_input_getter(): + src = ( + PRELUDE + + 'mode = input.string("15", "Exec", options=["5", "15"])\n' + + 'tf = mode == "15" ? "240" : "60"\n' + + "x = request.security(syminfo.tickerid, tf, close)\nplot(x)\n" + ) + line = _register_lines(src)[0] + # Registration-time-valid input read, not the default-"" member. + assert 'get_input_string("Exec"' in line, line + assert "(mode == std::string" not in line, line + + +def test_r2_inline_conditional_tf_over_input(): + # The tf expression is written inline at the call site (no intermediate + # var) — same defect, exercises the _resolve_security_tf fallthrough. + src = ( + PRELUDE + + 'mode = input.string("15", "Exec", options=["5", "15"])\n' + + 'x = request.security(syminfo.tickerid, mode == "15" ? "240" : "60", close)\nplot(x)\n' + ) + line = _register_lines(src)[0] + assert 'get_input_string("Exec"' in line, line + assert "(mode == std::string" not in line, line + + +# --------------------------------------------------------------------------- +# R3 (RED on HEAD) — request.security_lower_tf variant of the same defect. +# --------------------------------------------------------------------------- + +def test_r3_lower_tf_conditional_over_input_uses_input_getter(): + src = ( + PRELUDE + + 'mode = input.string("15", "Exec", options=["5", "15"])\n' + + 'sub = mode == "15" ? "5" : "1"\n' + + "a = request.security_lower_tf(syminfo.tickerid, sub, close)\n" + + "plot(array.size(a))\n" + ) + line = _register_lines(src)[0] + assert line.startswith("register_security_lower_tf_eval("), line + assert 'get_input_string("Exec"' in line, line + assert "(mode == std::string" not in line, line + + +# --------------------------------------------------------------------------- +# Oscar shape — str.length()-gated override ternary whose auto branch nests a +# second input-derived conditional. Both legs must resolve to input getters. +# --------------------------------------------------------------------------- + +def test_oscar_shape_override_and_auto_both_resolve_inputs(): + src = ( + PRELUDE + + 'exec_tf_mode = input.string("15", "Execution mode", options=["5", "15"])\n' + + 'ctx_mtf_in = input.string("", "Override MTF")\n' + + 'auto_mtf = exec_tf_mode == "15" ? "60" : "15"\n' + + "tf_mtf = str.length(ctx_mtf_in) > 0 ? ctx_mtf_in : auto_mtf\n" + + "x = request.security(syminfo.tickerid, tf_mtf, close)\nplot(x)\n" + ) + line = _register_lines(src)[0] + # Override leg reads the override input; auto leg reads the exec-mode input. + assert 'get_input_string("Override MTF"' in line, line + assert 'get_input_string("Execution mode"' in line, line + # No uninitialised member survives anywhere in the tf expression. + assert "exec_tf_mode ==" not in line, line + assert "ctx_mtf_in.length" not in line, line + + +# --------------------------------------------------------------------------- +# GREEN guards — behaviour that must NOT change. +# --------------------------------------------------------------------------- + +def test_green_literal_tf_unchanged(): + src = PRELUDE + 'x = request.security(syminfo.tickerid, "240", close)\nplot(x)\n' + assert _register_lines(src) == [ + 'register_security_eval(0, "240", input_tf_, false, false);' + ] + + +def test_green_timeframe_period_tf_unchanged(): + # A tf sourced from timeframe.period registers against script_tf_. + src = ( + PRELUDE + + "x = request.security(syminfo.tickerid, timeframe.period, close)\nplot(x)\n" + ) + line = _register_lines(src)[0] + assert "script_tf_" in line, line + assert "get_input_string" not in line, line + + +def test_green_series_dependent_tf_characterized(): + # A tf that depends on genuine per-bar state (close/open) is OUT OF SCOPE: + # not input-derived, so the fix leaves it verbatim (reads current_bar_). + src = ( + PRELUDE + + 'tf = close > open ? "60" : "15"\n' + + "x = request.security(syminfo.tickerid, tf, close)\nplot(x)\n" + ) + line = _register_lines(src)[0] + assert "current_bar_.close" in line, line + assert "get_input_string" not in line, line + + +def test_green_non_tf_security_args_unaffected(): + # The expression / gaps / lookahead args are untouched by the tf fix. + src = ( + PRELUDE + + 'mode = input.string("15", "Exec", options=["5", "15"])\n' + + 'tf = mode == "15" ? "240" : "60"\n' + + "x = request.security(syminfo.tickerid, tf, close, " + "barmerge.gaps_on, barmerge.lookahead_on)\nplot(x)\n" + ) + line = _register_lines(src)[0] + # gaps_on -> true (4th register arg), lookahead_on -> true (3rd). + assert line.endswith("input_tf_, true, true);"), line