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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
28 changes: 14 additions & 14 deletions pineforge_codegen/codegen/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
43 changes: 26 additions & 17 deletions pineforge_codegen/support_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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]] = {
Expand Down
7 changes: 0 additions & 7 deletions tests/gate-corpus/err/divergent_last_bar_index.pine

This file was deleted.

7 changes: 7 additions & 0 deletions tests/gate-corpus/ok/divergent_last_bar_index.pine
Original file line number Diff line number Diff line change
@@ -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()
50 changes: 46 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 pytest

from pineforge_codegen import transpile
from pineforge_codegen.lexer import Lexer
from pineforge_codegen.parser import Parser
Expand Down Expand Up @@ -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



Expand Down
49 changes: 29 additions & 20 deletions tests/test_support_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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():
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
Loading