From c8d8d0d6bdb5085598e8fb17f3b96057aa5dc9e7 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 21 Jul 2026 08:57:48 +0800 Subject: [PATCH] fix: emit strategy close callsite tokens --- pineforge_codegen/codegen/visit_call.py | 21 ++++- tests/test_codegen_new.py | 21 ++++- tests/test_strategy_close_callsite_tokens.py | 81 ++++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/test_strategy_close_callsite_tokens.py diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 2800db8..1f7763b 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -2198,6 +2198,24 @@ def _visit_fixnan(self, node: FuncCall) -> str: x = self._visit_expr(node.args[0]) return f"(is_na({x}) ? {member} : ({member} = {x}))" + @staticmethod + def _strategy_close_callsite_token(node: FuncCall) -> str: + """Return the stable runtime token for an authored close statement. + + A generated strategy is compiled from one Pine source unit, so the + parser's 1-based line and column identify the syntactic callsite. The + same inner UDF/loop node retains that location across every emitted or + runtime execution. Zero is reserved for source-less synthetic calls + so existing compatibility behavior remains available. + """ + loc = node.loc + if loc is None or loc.line <= 0 or loc.col <= 0: + return "0ULL" + token = ((int(loc.line) & 0xFFFFFFFF) << 32) | ( + int(loc.col) & 0xFFFFFFFF + ) + return f"{token}ULL" + def _visit_strategy_call(self, func_name: str, node: FuncCall) -> str: if func_name in ("convert_to_account", "convert_to_symbol"): p = self._resolve_func_args(node, f"strategy.{func_name}") @@ -2243,7 +2261,8 @@ def _visit_strategy_call(self, func_name: str, node: FuncCall) -> str: qty = self._visit_expr(p.get("qty")) if p.get("qty") is not None else "na()" qty_pct = self._visit_expr(p.get("qty_percent")) if p.get("qty_percent") is not None else "na()" immediately = self._visit_expr(p.get("immediately")) if p.get("immediately") is not None else "false" - return f"strategy_close({close_id}, {comment}, {qty}, {qty_pct}, {immediately})" + callsite = self._strategy_close_callsite_token(node) + return f"strategy_close({close_id}, {comment}, {qty}, {qty_pct}, {immediately}, {callsite})" if func_name == "close_all": p = self._resolve_func_args(node, "strategy.close_all") diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index 3ea5565..3fec36d 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -1,5 +1,7 @@ """Tests for the new CodeGen that reads from AnalyzerContext.""" +import re + import pytest from pineforge_codegen import transpile @@ -371,21 +373,32 @@ def test_strategy_close(): def test_strategy_close_forwards_comment(): src = '//@version=6\nstrategy("T")\nstrategy.close("Long", comment="manual exit")\n' cpp = _generate(src) - assert 'strategy_close(std::string("Long"), std::string("manual exit"), na(), na(), false)' in cpp + assert re.search( + r'strategy_close\(std::string\("Long"\), std::string\("manual exit"\), ' + r'na\(\), na\(\), false, [1-9][0-9]*ULL\)', + cpp, + ) def test_strategy_close_forwards_qty_percent_and_immediately(): src = '//@version=6\nstrategy("T")\nstrategy.close("Long", qty_percent=50, immediately=true)\n' cpp = _generate(src) - assert 'strategy_close(std::string("Long"), "", na(), 50, true)' in cpp + assert re.search( + r'strategy_close\(std::string\("Long"\), "", na\(\), 50, true, ' + r'[1-9][0-9]*ULL\)', + cpp, + ) def test_strategy_close_positional_immediately_uses_official_order(): src = ('//@version=6\nstrategy("T")\n' 'strategy.close("Long", "manual", 2, 50, "", true)\n') cpp = transpile(src) - assert ('strategy_close(std::string("Long"), std::string("manual"), ' - '2, 50, true)') in cpp + assert re.search( + r'strategy_close\(std::string\("Long"\), std::string\("manual"\), ' + r'2, 50, true, [1-9][0-9]*ULL\)', + cpp, + ) def test_strategy_close_all_forwards_named_comment_and_immediately(): diff --git a/tests/test_strategy_close_callsite_tokens.py b/tests/test_strategy_close_callsite_tokens.py new file mode 100644 index 0000000..83c31cf --- /dev/null +++ b/tests/test_strategy_close_callsite_tokens.py @@ -0,0 +1,81 @@ +"""Callsite-token coverage for authored ``strategy.close`` statements.""" + +import re + +from pineforge_codegen import transpile +from pineforge_codegen.analyzer import Analyzer +from pineforge_codegen.codegen import CodeGen +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.parser import Parser + + +_CLOSE_TOKEN = re.compile(r"strategy_close\([^;\n]*, ([0-9]+ULL)\)") + + +def _close_tokens(cpp: str) -> list[str]: + return _CLOSE_TOKEN.findall(cpp) + + +def test_direct_close_sites_have_distinct_deterministic_location_tokens(): + source = """//@version=6 +strategy("T") +if bar_index == 1 + strategy.close("A") + strategy.close("B") +""" + + first = _close_tokens(transpile(source)) + second = _close_tokens(transpile(source)) + # FuncCall.loc points at each opening parenthesis: lines 4/5, column 19. + expected = [f"{(line << 32) | 19}ULL" for line in (4, 5)] + + assert first == expected + assert second == expected + assert len(set(first)) == 2 + assert "0ULL" not in first + + +def test_close_inside_loop_emits_one_nonzero_inner_site_token(): + source = """//@version=6 +strategy("T") +for i = 0 to 2 + strategy.close("L" + str.tostring(i)) +""" + + tokens = _close_tokens(transpile(source)) + + assert tokens == [f"{(4 << 32) | 19}ULL"] + + +def test_close_inside_shared_udf_clones_keeps_one_inner_site_token(): + source = """//@version=6 +strategy("T") +close_one(string id) => + avg = ta.sma(close, 2) + strategy.close(id) + avg +if bar_index == 2 + close_one("A") + close_one("B") +""" + + tokens = _close_tokens(transpile(source)) + + # Stateful-callable isolation emits two UDF bodies. Both must carry the + # authored inner close statement's location, not the outer invocation. + assert len(tokens) == 2 + assert set(tokens) == {f"{(5 << 32) | 19}ULL"} + + +def test_source_less_synthetic_close_uses_compatibility_token_zero(): + source = """//@version=6 +strategy("T") +strategy.close("Long") +""" + ast = Parser(Lexer(source).tokenize(), source=source).parse() + close_call = ast.body[-1].expr + close_call.loc = None + + cpp = CodeGen(Analyzer(ast).analyze()).generate() + + assert _close_tokens(cpp) == ["0ULL"]