diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 58be635..89a7476 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -89,6 +89,7 @@ from ..symbols import PineType, method_receiver_cpp_token from .tables import ( BAR_SERIES_PUSH, + TA_CHART_PREV_CLOSE_ARG, DRAWING_TYPE_TO_CPP, PINE_TYPE_TO_CPP, RUNTIME_REGISTER_SECURITY_EVAL_FN, @@ -1982,6 +1983,14 @@ def _emit_precalculate_and_run(self, lines: list[str]) -> None: if self._ta_site_uses_precalc(site): compute_args = self._ta_compute_args_for_site(site) compute_args_bars = compute_args.replace("current_bar_.", "bars[i].") + # issue #178: the precalc pre-pass walks the bar array + # itself, so the previous CHART close is bars[i - 1] + # (the engine's prev_chart_close() tracker only advances + # at on_bar dispatch). + compute_args_bars = compute_args_bars.replace( + TA_CHART_PREV_CLOSE_ARG, + "(i > 0 ? bars[i - 1].close : na())", + ) lines.append(f" _precalc_{site.member_name}[i] = {site.member_name}.compute({compute_args_bars});") finally: self._precalc_loop_active = False diff --git a/pineforge_codegen/codegen/ta.py b/pineforge_codegen/codegen/ta.py index d43f714..63b3bc3 100644 --- a/pineforge_codegen/codegen/ta.py +++ b/pineforge_codegen/codegen/ta.py @@ -31,7 +31,12 @@ Ternary, TupleAssign, TupleLiteral, TypeDecl, TypeField, UnaryOp, VarDecl, WhileStmt, ) -from .tables import TA_IMPLICIT_APPEND, TA_IMPLICIT_COMPUTE_FULL +from .tables import ( + TA_CHART_PREV_CLOSE, + TA_CHART_PREV_CLOSE_ARG, + TA_IMPLICIT_APPEND, + TA_IMPLICIT_COMPUTE_FULL, +) if TYPE_CHECKING: from ..analyzer import TACallSite @@ -113,6 +118,10 @@ def _ta_compute_args_for_site(self, site: "TACallSite") -> str: if ta_name in TA_IMPLICIT_COMPUTE_FULL: implicit = TA_IMPLICIT_COMPUTE_FULL[ta_name] + # issue #178: chart-context atr / tr take the previous CHART + # bar's close as a 4th argument (see TA_CHART_PREV_CLOSE). + if ta_name in TA_CHART_PREV_CLOSE: + implicit = f"{implicit}, {TA_CHART_PREV_CLOSE_ARG}" if site.compute_args: explicit = ", ".join(self._visit_expr(a) for a in site.compute_args) if ta_name in self._TA_IMPLICIT_REPLACE: diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 645f07f..7419e30 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -222,6 +222,18 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: "max": [0], "min": [0], "rci": [0], } +# issue #178: ta.atr / ta.tr read their true range against the previous +# CHART bar's close (close[1]) even when the call site executes sparsely — +# the RMA advances on the executions only (TradingView pin 2026-09-06, +# lab tv i178-sparse-atr-sense, BINANCE:BTCUSDT 60: 398/398 executions equal +# the chart-close model, 0/398 the previous-execution model). The chart +# context therefore passes ``BacktestEngine::prev_chart_close()`` as the +# 4th compute() argument (the engine's always-on tracker); the per-object +# prev_close (3-arg form) remains for request.security contexts, whose +# ``bar.`` rewrite has no chart-close tracker. +TA_CHART_PREV_CLOSE = {"atr", "tr"} +TA_CHART_PREV_CLOSE_ARG = "prev_chart_close()" + # TA functions whose ``.compute()`` always receives bar OHLC implicitly. TA_IMPLICIT_COMPUTE_FULL = { "atr": "current_bar_.high, current_bar_.low, current_bar_.close", diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index fc6fdee..5708d68 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -168,7 +168,7 @@ def test_ta_tr_handle_na_false_routes_through_tr_class(): assert re.search(r"_ta_tr_\d+\(false\)", cpp), cpp # Compute is invoked with the bar OHLC implicitly threaded in. assert ( - "_ta_tr_1.compute(current_bar_.high, current_bar_.low, current_bar_.close)" + "_ta_tr_1.compute(current_bar_.high, current_bar_.low, current_bar_.close, prev_chart_close())" in cpp ) diff --git a/tests/test_sparse_atr_prev_close.py b/tests/test_sparse_atr_prev_close.py new file mode 100644 index 0000000..b5e0bbd --- /dev/null +++ b/tests/test_sparse_atr_prev_close.py @@ -0,0 +1,65 @@ +"""issue #178: ta.atr / ta.tr take the previous CHART bar's close. + +TradingView pin (2026-09-06, lab tv i178-sparse-atr-sense, BINANCE:BTCUSDT 60, +398/398 sparse executions): ``ta.atr(n)`` called inside a block that does not +execute every bar advances its RMA on the executions only, but its true range +always reads ``close[1]`` of the CHART, never the close of the site's previous +execution. The engine's per-object ``prev_close`` is the refuted model, so the +chart-context compute() passes ``BacktestEngine::prev_chart_close()`` as a 4th +argument; the precalc pre-pass (which walks ``bars[i]`` itself) passes +``bars[i - 1].close``; a request.security context keeps the 3-arg form. +""" + +from __future__ import annotations + +import re + +import pineforge_codegen as pc + +PRELUDE = '//@version=6\nstrategy("t", overlay=true)\n' + + +def _gen(src: str) -> str: + return pc.transpile(PRELUDE + src, check_support=False) + + +def test_sparse_atr_chart_context_passes_prev_chart_close(): + cpp = _gen( + "reach = close[1] > open[1] and close < open\n" + "var float a = na\n" + "if reach\n" + " a := ta.atr(3)\n" + "plot(a)\n" + ) + assert ( + "_ta_atr_1.compute(current_bar_.high, current_bar_.low, current_bar_.close, prev_chart_close())" + in cpp + ), cpp + assert ( + "_ta_atr_1.recompute(current_bar_.high, current_bar_.low, current_bar_.close, prev_chart_close())" + in cpp + ), cpp + + +def test_every_bar_tr_passes_prev_chart_close_and_precalc_uses_bars_i_minus_1(): + cpp = _gen("t = ta.tr(true)\nplot(t)\n") + assert ( + "_ta_tr_1.compute(current_bar_.high, current_bar_.low, current_bar_.close, prev_chart_close())" + in cpp + ), cpp + # The precalc pre-pass, when emitted, walks the bar array itself. + for m in re.finditer(r"_precalc__ta_tr_1\[i\] = _ta_tr_1\.compute\(([^;]*)\);", cpp): + assert m.group(1) == "bars[i].high, bars[i].low, bars[i].close, (i > 0 ? bars[i - 1].close : na())", m.group(0) + assert "prev_chart_close()" not in "".join( + l for l in cpp.splitlines() if "_precalc_" in l + ), cpp + + +def test_security_context_atr_keeps_three_arg_form(): + cpp = _gen( + 'a = request.security(syminfo.tickerid, "D", ta.atr(14))\n' + "plot(a)\n" + ) + sec_lines = [l for l in cpp.splitlines() if "compute(bar.high, bar.low, bar.close" in l] + assert sec_lines, cpp + assert all("prev_chart_close()" not in l for l in sec_lines), sec_lines diff --git a/tests/test_transpile_tr_handle_na.py b/tests/test_transpile_tr_handle_na.py index aba76f5..63bf232 100644 --- a/tests/test_transpile_tr_handle_na.py +++ b/tests/test_transpile_tr_handle_na.py @@ -92,7 +92,7 @@ def test_ta_tr_compute_uses_implicit_bar_ohlc(): # implicit bar OHLC (high, low, close) — it never sees ``handle_na``. cpp = _generate(_wrap("x = ta.tr()")) assert ( - "_ta_tr_1.compute(current_bar_.high, current_bar_.low, current_bar_.close)" + "_ta_tr_1.compute(current_bar_.high, current_bar_.low, current_bar_.close, prev_chart_close())" in cpp ), cpp # Defensive: the bool literal must NOT appear inside the compute call.