Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 73 additions & 24 deletions pineforge_codegen/codegen/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
175 changes: 175 additions & 0 deletions tests/test_security_tf_input_derived.py
Original file line number Diff line number Diff line change
@@ -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
Loading