From 81ceca2867f826ce49cf41acb22fe66ff88a379a Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 5 Jul 2026 04:24:37 +0800 Subject: [PATCH 1/2] Return committed helper for rolling math reducers in request.security A rolling math reducer (math.sum) inside a request.security() expression lowered to `0.0 /* unsupported: math.sum */`, so the whole security silently returned 0. _build_security_expr routed the math.* node to the scalar-math inline lowering (_build_security_math_call) before the TA-site lookup, discarding the math::Sum helper the security machinery had already precomputed into a committed _secval_*. Return that committed _secval_ when a math.* call inside a security expression is a registered rolling TA site. Scalar math (abs/round/min/max/...) is not a TA site and still lowers inline. Adds tests/test_security_math_reducer.py locking both directions. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013tuvZmenDcKuPu8LVqfqag --- pineforge_codegen/codegen/security.py | 13 +++++++ tests/test_security_math_reducer.py | 53 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/test_security_math_reducer.py diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 33e82f1..114e5cf 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -2173,6 +2173,19 @@ def _build_security_expr( and isinstance(expr_node.callee.object, Identifier) and expr_node.callee.object.name == "math" ): + # A rolling/stateful math reducer (e.g. math.sum -> math::Sum) is + # precomputed into a committed _secval_* by the security TA + # machinery, exactly like a ta.* call. Return that committed value + # instead of falling through to _build_security_math_call, whose + # inline lowering only covers scalar math and emits a broken + # "unsupported: math." 0.0 for a reducer. Scalar math + # (abs/round/min/max/...) is not a TA site, so this is a no-op. + math_site = self._get_ta_site(expr_node) + if math_site is not None: + math_idx = self._ta_index_by_site_id.get(id(math_site)) + math_sig = self._security_binding_stack_signature(helper_binding_stack) + if math_idx is not None and (math_idx, math_sig) in ta_results: + return ta_results[(math_idx, math_sig)] return self._build_security_math_call( sec_id, expr_node.callee.member, diff --git a/tests/test_security_math_reducer.py b/tests/test_security_math_reducer.py new file mode 100644 index 0000000..83ce729 --- /dev/null +++ b/tests/test_security_math_reducer.py @@ -0,0 +1,53 @@ +"""Regression: a rolling/stateful math reducer (e.g. ``math.sum``) inside +``request.security`` must resolve to its committed ``_secval_*`` helper, not the +scalar-math "unsupported" ``0.0`` fallback. + +``request.security(tickerid, "D", math.sum(high[1] - low[1], 10) / 10, ...)`` +precomputes ``math.sum`` as a ``math::Sum`` TA site into ``_secval_N`` (exactly +like a ``ta.*`` reducer). But ``_build_security_expr`` routed the ``math.*`` node +to the scalar-math lowering (``_build_security_math_call``, which can only emit +inline scalar ops) BEFORE the TA-site lookup — so it emitted +``0.0 /* unsupported: math.sum */`` and the whole security silently returned 0. +The dispatch must return the committed ``_secval_`` for a math call that is a +precomputed TA site. Scalar math (abs/round/min/max/...) is not a TA site and +must still lower inline. +""" + +from __future__ import annotations + +import re + +from pineforge_codegen import transpile + + +def test_math_sum_in_security_resolves_to_committed_secval(): + src = """//@version=6 +strategy("t", overlay=true) +adr = request.security(syminfo.tickerid, "D", math.sum(high[1] - low[1], 10) / 10, lookahead=barmerge.lookahead_off) +if not na(adr) and adr > 0 + strategy.entry("L", strategy.long) +plot(close) +""" + cpp = transpile(src) + # The reducer must be precomputed as a rolling math::Sum helper... + assert "math::Sum" in cpp + # ...and it must NOT degrade to the scalar-math unsupported fallback. + assert "/* unsupported: math.sum */" not in cpp + # The request.security committed value must reference the _secval_ helper + # (here: (_secval_N / 10)), not a literal 0.0. + assert re.search(r"_req_sec_\d+\s*=\s*\(_secval_\d+ / 10\)", cpp), cpp + + +def test_scalar_math_in_security_still_lowers_inline(): + # math.abs is not a TA site: it must keep lowering inline, unaffected by the + # reducer fix (guards against over-broadly rerouting all math.* calls). + src = """//@version=6 +strategy("t", overlay=true) +d = request.security(syminfo.tickerid, "D", math.abs(close - open), lookahead=barmerge.lookahead_off) +if not na(d) and d > 0 + strategy.entry("L", strategy.long) +plot(close) +""" + cpp = transpile(src) + assert "std::abs" in cpp + assert "/* unsupported: math.abs */" not in cpp From 3803e38f910e0c4f0b397963bab7532df9fcb390 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 5 Jul 2026 12:08:29 +0800 Subject: [PATCH 2/2] Return na for strategy.position_avg_price when flat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pine returns na for strategy.position_avg_price when the position is flat (position_size == 0). The codegen mapped it directly to the engine field position_entry_price_, which is 0.0 when flat, silently defeating the common `na(strategy.position_avg_price)` flat/in-position guard idiom (0.0 is not na) — exit/SL/TP logic then ran against avg=0 (e.g. a 0-priced stop firing immediately). Emit the field guarded by the flat test, matching the engine's own avg-price accessor which already returns na when FLAT: (signed_position_size() == 0.0 ? na() : position_entry_price_). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013tuvZmenDcKuPu8LVqfqag --- pineforge_codegen/codegen/visit_expr.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 4f6bce6..8f6434c 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -373,7 +373,12 @@ def _visit_member_access(self, node: MemberAccess) -> str: if node.member == "position_size": return "signed_position_size()" if node.member == "position_avg_price": - return "position_entry_price_" + # Pine returns `na` when the position is flat + # (position_size == 0); the engine field is 0.0 when flat. + # Guard so the common `na(strategy.position_avg_price)` + # flat/in-position idiom is not silently defeated. + return ("(signed_position_size() == 0.0 ? na() " + ": position_entry_price_)") if node.member == "position_entry_name": return "position_entry_name()" # Trade counts