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
21 changes: 20 additions & 1 deletion pineforge_codegen/codegen/visit_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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<double>()"
qty_pct = self._visit_expr(p.get("qty_percent")) if p.get("qty_percent") is not None else "na<double>()"
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")
Expand Down
21 changes: 17 additions & 4 deletions tests/test_codegen_new.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for the new CodeGen that reads from AnalyzerContext."""

import re

import pytest

from pineforge_codegen import transpile
Expand Down Expand Up @@ -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<double>(), na<double>(), false)' in cpp
assert re.search(
r'strategy_close\(std::string\("Long"\), std::string\("manual exit"\), '
r'na<double>\(\), na<double>\(\), 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<double>(), 50, true)' in cpp
assert re.search(
r'strategy_close\(std::string\("Long"\), "", na<double>\(\), 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():
Expand Down
81 changes: 81 additions & 0 deletions tests/test_strategy_close_callsite_tokens.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading