From 0f9fe7d5c1d56fe78871addf8803bb8ffda128b1 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 18 Jul 2026 00:19:38 +0800 Subject: [PATCH] fix: correct final-bar support diagnostics --- CLAUDE.md | 2 +- pineforge_codegen/codegen/security.py | 28 +++++------ pineforge_codegen/support_checker.py | 43 +++++++++------- .../err/divergent_last_bar_index.pine | 7 --- .../ok/divergent_last_bar_index.pine | 7 +++ tests/test_codegen_new.py | 50 +++++++++++++++++-- tests/test_support_checker.py | 49 ++++++++++-------- 7 files changed, 123 insertions(+), 63 deletions(-) delete mode 100644 tests/gate-corpus/err/divergent_last_bar_index.pine create mode 100644 tests/gate-corpus/ok/divergent_last_bar_index.pine diff --git a/CLAUDE.md b/CLAUDE.md index 76c0fa1..6e94099 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,7 @@ strategy. The taxonomy: | -------------------------------- | -------------------------------------------------------------------- | | `HARD_REJECT_FUNC` | Calls with no PineForge semantics at all (e.g. `request.financial`). | | `HARD_REJECT_NAMESPACE` | Whole-namespace rejects — currently EMPTY (the old `ticker.*` blanket reject became per-function `HARD_REJECT_FUNC` entries: `ticker.{renko,kagi,linebreak,pointfigure,new,modify}`; `ticker.inherit`/`ticker.standard` pass through, `ticker.heikinashi` allowed for the chart's own symbol). | -| `DIVERGENT_VARS` (warning) / `DIVERGENT_VARS_ERROR` (reject) | Built-in variables whose value diverges from TV (e.g. `bar_index` warns); the ERROR subset (`last_bar_index` — would silently mis-alias to the CURRENT bar index) is rejected because the backtest would be silently wrong. | +| `DIVERGENT_VARS` (warning) / `DIVERGENT_VARS_ERROR` (reject) | Built-in variables whose value can diverge from TV. `bar_index` and `last_bar_index` warn because they depend on the fed data window; `last_bar_index` lowers to the window's true final index. The ERROR subset is reserved for silent mis-aliases and is currently empty. | | `BARSTATE_APPROX_VARS` (warning) | Barstate flags PineForge approximates in batch mode. | | `STRATEGY_UNSUPPORTED_PARAMS` | Per-strategy.* call kwargs that codegen drops silently. | | `NOT_YET_FUNC` | Implementable but currently no codegen — reject loudly. | diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index bd98cae..2205dee 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -480,11 +480,11 @@ def _literal_int_for_security_index(self, node) -> int | None: return None return None - # In PineForge batch backtests these barstate flags are compile-time - # constants (see support_checker / codegen.visit_expr barstate emission): - # every bar is historical, none realtime/last. The other flags - # (isfirst/isnew/isconfirmed) depend on runtime tick state, so they are - # deliberately absent and leave a fold "unknown". + # Narrow approximations used only while resolving a request.security() + # history index to a literal. This is not chart-scope barstate lowering: + # visit_expr emits runtime state for islast/islastconfirmedhistory, whereas + # this security-index folder currently treats them as false. Other flags + # (isfirst/isnew/isconfirmed) remain unknown and are deliberately absent. _SECURITY_CONST_BARSTATE = { "isrealtime": False, "islast": False, @@ -500,10 +500,10 @@ def _fold_security_const_bool( ) -> bool | None: """Fold a boolean expression to a compile-time constant, or None. - Only reduces expressions whose non-literal leaves are the batch-constant - barstate flags above; anything runtime-dependent returns None. Used to - resolve a request.security history index expressed as - ``barstate.isrealtime ? 1 : 0`` and friends.""" + Only reduces expressions whose non-literal leaves have a narrow + request.security history-index approximation above; anything else + returns None. Used to resolve a request.security history index + expressed as ``barstate.isrealtime ? 1 : 0`` and friends.""" if resolving is None: resolving = set() if isinstance(node, BoolLiteral): @@ -555,11 +555,11 @@ def _resolve_security_index_literal( Extends ``_literal_int_for_security_index`` by also resolving the index through helper-parameter bindings and global aliases and constant-folding - a ternary whose condition is a batch-constant barstate flag (e.g. - ``idxHigher = barstate.isrealtime ? 1 : 0`` -> 0). A literal index short- - circuits on the first line, so behaviour is unchanged for every already- - literal index. Returns None when the index cannot be reduced to a - compile-time literal, so the caller keeps its existing rejection.""" + a ternary whose condition has a security-index barstate approximation + (e.g. ``idxHigher = barstate.isrealtime ? 1 : 0`` -> 0). A literal index + short-circuits on the first line, so behaviour is unchanged for every + already-literal index. Returns None when the index cannot be reduced to + a compile-time literal, so the caller keeps its existing rejection.""" direct = self._literal_int_for_security_index(node) if direct is not None: return direct diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 8be2a5a..b5c57f4 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -10,14 +10,11 @@ * HARD_REJECT_FUNC / HARD_REJECT_NAMESPACE - calls that have no PineForge semantics at all (e.g. ``request.financial``, ``ticker.*``). * DIVERGENT_VARS - built-in variables whose PineForge value diverges from - TradingView. Most are reported as WARNING (e.g. ``bar_index`` depends on the - data window, ``timenow`` is not wall-clock) — these often appear in visual or - logging code that does not affect trade outcomes. A subset - (DIVERGENT_VARS_ERROR: ``last_bar_index`` aliased to the *current* bar index, - ``time_close`` aliased to the bar *open* timestamp) are silent MIS-ALIASES: - they produce a plausible-looking but wrong value that flows straight into - trade logic, so a backtest would be silently wrong. Those are escalated to - ERROR (rejected) rather than merely warned. + TradingView. They are reported as WARNING (e.g. ``bar_index`` and + ``last_bar_index`` depend on the fed data window, ``timenow`` is not + wall-clock) — these often appear in visual or logging code that does not + affect trade outcomes. DIVERGENT_VARS_ERROR is reserved for silent + mis-aliases severe enough to reject; it is currently empty. * NOT_YET - calls the runtime could support but the transpiler does not yet emit (e.g. ``max_bars_back``, bare ``barssince``). * request.security - only ``symbol`` / ``timeframe`` / ``expression`` allowed, @@ -174,11 +171,11 @@ # logging or visual logic that does not affect trade outcomes. The checker still # flags divergence so users see the risk. # -# DIVERGENT_VARS_ERROR is a SUBSET that is escalated to ERROR (rejected): these -# are silent MIS-ALIASES, not merely data-window divergences. They return a -# plausible value that is the WRONG quantity (last_bar_index -> current bar -# index) and that value flows directly into trade logic, so the backtest would -# be silently wrong. A WARNING is not enough. +# DIVERGENT_VARS_ERROR is a SUBSET reserved for silent MIS-ALIASES that must be +# escalated to ERROR (rejected), rather than merely warned. It is currently +# empty: last_bar_index lowers to the true final index of the fed data window, +# but that index can still differ from TradingView when the window does not +# cover the same chart history. # # NOTE: the bare ``time_close`` variable is NOT divergent — codegen lowers it to # the engine's ``time_close()`` accessor, which returns the true bar-close @@ -188,20 +185,32 @@ # separate supported builtin handled in visit_call.) DIVERGENT_VARS: dict[str, str] = { "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.", - "last_bar_index": "last_bar_index is aliased to the CURRENT bar index in PineForge codegen (not the index of the last bar); backtest would be silently wrong — rejected.", + "last_bar_index": "last_bar_index is the final index of PineForge's fed data window; it can diverge from TradingView when the window does not cover the same chart history.", "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.", } # Subset of DIVERGENT_VARS escalated from WARNING to ERROR (see comment above). -DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index"}) +DIVERGENT_VARS_ERROR: frozenset[str] = frozenset() BARSTATE_APPROX_VARS: dict[str, str] = { - "barstate.islast": "barstate.islast is always false in PineForge batch backtests.", + "barstate.islast": ( + "In direct batch chart-scope evaluation, barstate.islast is true only " + "on the final bar of PineForge's fed chart-data window. Inside a " + "request.security() history index, PineForge currently selects the " + "false branch; requested-context last-bar state is not modeled." + ), "barstate.ishistory": "barstate.ishistory is always true in PineForge batch backtests.", "barstate.isrealtime": "barstate.isrealtime is always false in PineForge batch backtests.", "barstate.isnew": "barstate.isnew follows PineForge first-tick execution state.", "barstate.isconfirmed": "barstate.isconfirmed follows PineForge last-tick execution state.", - "barstate.islastconfirmedhistory": "barstate.islastconfirmedhistory is always false in PineForge batch backtests.", + "barstate.islastconfirmedhistory": ( + "In direct historical batch chart-scope evaluation, PineForge " + "approximates barstate.islastconfirmedhistory as true only on the " + "final bar of the fed chart-data window. Inside a request.security() " + "history index, PineForge currently selects the false branch; " + "TradingView's requested-context and realtime-boundary semantics are " + "not modeled." + ), } STRATEGY_UNSUPPORTED_PARAMS: dict[str, set[str]] = { diff --git a/tests/gate-corpus/err/divergent_last_bar_index.pine b/tests/gate-corpus/err/divergent_last_bar_index.pine deleted file mode 100644 index e742328..0000000 --- a/tests/gate-corpus/err/divergent_last_bar_index.pine +++ /dev/null @@ -1,7 +0,0 @@ -//@version=6 -strategy("T") -// last_bar_index is aliased to the CURRENT bar index in PineForge codegen, so a -// backtest reading it would be silently wrong -> hard reject (ERROR). -isLast = bar_index == last_bar_index -if isLast - strategy.close_all() diff --git a/tests/gate-corpus/ok/divergent_last_bar_index.pine b/tests/gate-corpus/ok/divergent_last_bar_index.pine new file mode 100644 index 0000000..737b6cb --- /dev/null +++ b/tests/gate-corpus/ok/divergent_last_bar_index.pine @@ -0,0 +1,7 @@ +//@version=6 +strategy("T") +// last_bar_index is the final index of PineForge's fed data window. It is +// accepted with a chart-history divergence warning rather than hard-rejected. +isLast = bar_index == last_bar_index +if isLast + strategy.close_all() diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index 9c02003..e1fedd5 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 pytest + from pineforge_codegen import transpile from pineforge_codegen.lexer import Lexer from pineforge_codegen.parser import Parser @@ -1431,16 +1433,56 @@ def test_isInSession_default_resolves_to_script_tf(): assert 'std::string("15")' not in cpp -def test_barstate_members_use_runtime_state(): +def test_barstate_tick_members_use_runtime_state(): cpp = _generate(""" -if barstate.isnew and barstate.isconfirmed and barstate.islast +if barstate.isnew and barstate.isconfirmed strategy.entry("L", strategy.long) -if barstate.islastconfirmedhistory - strategy.close("L") """) assert "is_first_tick_" in cpp assert "is_last_tick_" in cpp + + +@pytest.mark.parametrize( + "var_name", + ["barstate.islast", "barstate.islastconfirmedhistory"], +) +def test_chart_scope_final_bar_members_use_runtime_state(var_name: str): + cpp = _generate(f""" +if {var_name} + strategy.close("L") +""") assert "barstate_islast_" in cpp + assert "if (false)" not in cpp + + +@pytest.mark.parametrize( + "var_name", + ["barstate.islast", "barstate.islastconfirmedhistory"], +) +def test_security_history_index_final_bar_members_currently_fold_false( + var_name: str, +): + cpp = _generate(f""" +secured = request.security( + syminfo.tickerid, + "60", + close[{var_name} ? 1 : 0]) +plot(secured) +""") + # This intentionally pins the current caveat, not the desired chart-scope + # semantics: the security history-index folder selects the false (zero) + # branch, so the requested evaluator reads its current bar close. + assert "_req_sec_0 = bar.close;" in cpp + assert "_sec0_hist_close" not in cpp + + +def test_last_bar_index_uses_runtime_final_index_accessor(): + cpp = _generate(""" +is_last = bar_index == last_bar_index +if is_last + strategy.close_all() +""") + assert "pine_last_bar_index()" in cpp diff --git a/tests/test_support_checker.py b/tests/test_support_checker.py index e84ec76..940d5a0 100644 --- a/tests/test_support_checker.py +++ b/tests/test_support_checker.py @@ -64,7 +64,7 @@ def test_indicator_decl_rejected(): # --------------------------------------------------------------------------- -# Divergent built-in variables — most WARN; the mis-alias subset ERRORs +# Divergent built-in variables — WARN unless a true mis-alias enters the ERROR subset # --------------------------------------------------------------------------- _DIVERGENT_WARN_ONLY = sorted(set(DIVERGENT_VARS) - DIVERGENT_VARS_ERROR) @@ -79,21 +79,16 @@ def test_divergent_variables_warn(var_name: str): f"expected divergence warning for {var_name}, got {[d.message for d in warns]}" -@pytest.mark.parametrize("var_name", sorted(DIVERGENT_VARS_ERROR)) -def test_divergent_mis_alias_variables_error(var_name: str): - """last_bar_index is a silent mis-alias -> ERROR (rejected).""" - src = PRELUDE + f"x = {var_name}\n" - errs = _errors(src) - assert errs, f"{var_name} is a silent mis-alias and must ERROR, not warn" - assert any("diverges" in d.message for d in errs), \ - f"expected divergence error for {var_name}, got {[d.message for d in errs]}" - # and it must NOT also be a warning (single diagnostic, escalated) - assert not any("diverges" in d.message for d in _warnings(src)), \ - f"{var_name} should be ERROR-only, not also WARNING" - - -def test_last_bar_index_errors(): - _expect_error(PRELUDE + "x = last_bar_index\n", "last_bar_index") +def test_last_bar_index_warns_about_fed_window_chart_history(): + src = PRELUDE + "x = last_bar_index\n" + assert _errors(src) == [] + warns = [d for d in _warnings(src) if "last_bar_index" in d.message] + assert len(warns) == 1 + detail = f"{warns[0].message} {warns[0].hint or ''}".lower() + assert "final index" in detail + assert "fed data window" in detail + assert "chart history" in detail + assert "current bar index" not in detail def test_time_close_variable_accepted(): @@ -125,7 +120,8 @@ def test_divergent_error_subset_is_subset(): # time_close is no longer rejected — codegen lowers it to the faithful # engine time_close() accessor. assert "time_close" not in DIVERGENT_VARS_ERROR - assert {"last_bar_index"} == set(DIVERGENT_VARS_ERROR) + assert "last_bar_index" not in DIVERGENT_VARS_ERROR + assert not DIVERGENT_VARS_ERROR def test_time_close_function_call_not_flagged_as_divergent_var(): @@ -408,10 +404,23 @@ def test_unknown_syminfo_member_rejected(): _expect_error(src, "syminfo.not_a_real_field") -def test_barstate_approximation_warns(): - src = PRELUDE + "x = barstate.islast\n" +@pytest.mark.parametrize( + "var_name", + ["barstate.islast", "barstate.islastconfirmedhistory"], +) +def test_barstate_final_fed_bar_approximation_warns(var_name: str): + src = PRELUDE + f"x = {var_name}\n" assert _errors(src) == [] - assert any("barstate.islast" in d.message for d in _warnings(src)) + warns = [d for d in _warnings(src) if var_name in d.message] + assert len(warns) == 1 + detail = f"{warns[0].message} {warns[0].hint or ''}".lower() + assert "true only on the final bar" in detail + assert "fed chart-data window" in detail + assert "direct" in detail + assert "chart-scope" in detail + assert "inside a request.security() history index" in detail + assert "false branch" in detail + assert "requested-context" in detail def test_unsupported_strategy_entry_params_warn():