From 6b7a383abc356b703a3a7d960b9b9e23aa3deb3c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 18 Jun 2026 01:17:20 +0800 Subject: [PATCH 1/5] feat(support): escalate last_bar_index/time_close to ERROR (silently-wrong mis-aliases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit last_bar_index is aliased to the CURRENT bar index and time_close (bare var) to the bar OPEN timestamp in PineForge codegen. Both return a plausible but wrong value that flows into trade logic, so a backtest is silently wrong. Add DIVERGENT_VARS_ERROR = {last_bar_index, time_close} and emit Level.ERROR for that subset at both emit sites (bare name + member chain); bar_index and timenow stay WARNING. Sharpen the hints ("...is aliased to ; backtest would be silently wrong — rejected") and the module docstring. Disambiguate the bare divergent VARIABLE from a same-named FUNCTION call: the generic child-walk would otherwise flag the callee of the session-aware time_close(...) function. Track callee node ids and skip them. Tests: split the divergent parametrize into warn-only vs error subsets; add last_bar_index/time_close ERROR cases, bar_index/timenow still-WARNING cases, and time_close(...) function-call not-flagged regression. Gate corpus: add err/divergent_last_bar_index.pine + err/divergent_time_close.pine. Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/support_checker.py | 56 ++++++++++++---- .../err/divergent_last_bar_index.pine | 7 ++ .../gate-corpus/err/divergent_time_close.pine | 9 +++ tests/test_support_checker.py | 64 ++++++++++++++++++- 4 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 tests/gate-corpus/err/divergent_last_bar_index.pine create mode 100644 tests/gate-corpus/err/divergent_time_close.pine diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 500a5a6..6aad50f 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -10,9 +10,14 @@ * 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 (e.g. ``bar_index`` depends on data window, ``last_bar_index`` - is wrongly aliased in codegen). Reported as WARNING — these often appear - in visual or logging code that does not affect trade outcomes. + 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. * 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, @@ -131,16 +136,25 @@ } # Built-in variables whose PineForge value diverges from TradingView semantics. -# Demoted to WARNING — many real strategies use bar_index / time_close in -# logging or visual logic that does not affect trade outcomes. The checker -# still flags divergence so users see the risk. +# Most are reported as WARNING — many real strategies use bar_index / timenow in +# 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; time_close -> bar OPEN timestamp) and that value flows directly into +# trade logic, so the backtest would be silently wrong. A WARNING is not enough. 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 incorrectly aliased to the current bar index in PineForge codegen.", + "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.", "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.", - "time_close": "time_close is aliased to the bar open timestamp in PineForge; it does not represent the bar close time.", + "time_close": "time_close is aliased to the bar OPEN timestamp in PineForge; it does not represent the bar close time; backtest would be silently wrong — rejected.", } +# Subset of DIVERGENT_VARS escalated from WARNING to ERROR (see comment above). +DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index", "time_close"}) + BARSTATE_APPROX_VARS: dict[str, str] = { "barstate.islast": "barstate.islast is always false in PineForge batch backtests.", "barstate.ishistory": "barstate.ishistory is always true in PineForge batch backtests.", @@ -386,6 +400,12 @@ def __init__(self, ast: Program, filename: str = "") -> None: # request.security (barmerge.* gaps/lookahead values). While > 0 the # UNSUPPORTED_CONST_NAMESPACES rejection is suppressed. self._const_arg_ctx_depth: int = 0 + # id()s of Identifier/MemberAccess nodes that are the *callee* of a + # FuncCall. A divergent built-in NAME used as a call target (e.g. the + # session-aware ``time_close("D")`` function, which is distinct from the + # bare ``time_close`` variable) must NOT be flagged as a divergent + # variable read. Populated as _visit_FuncCall descends into children. + self._callee_node_ids: set[int] = set() # -- Public API -- @@ -629,6 +649,12 @@ def _visit_TupleAssign(self, node: TupleAssign) -> None: def _visit_FuncCall(self, node: FuncCall) -> None: ns, name = _qualified_name(node.callee) + # Mark the callee so the generic child-walk does not treat a divergent + # built-in *function* name (e.g. ``time_close("D")``) as a divergent + # *variable* read. The call's own semantics are validated here. + if node.callee is not None: + self._callee_node_ids.add(id(node.callee)) + if ns is None and name is None: self._visit_children(node) return @@ -918,8 +944,9 @@ def _visit_Identifier(self, node: Identifier) -> None: "code into the strategy script).", ) return - if node.name in DIVERGENT_VARS: - self._warn( + if node.name in DIVERGENT_VARS and id(node) not in self._callee_node_ids: + emit = self._err if node.name in DIVERGENT_VARS_ERROR else self._warn + emit( node, f"{node.name} diverges from TradingView semantics in PineForge.", hint=DIVERGENT_VARS[node.name], @@ -945,8 +972,13 @@ def _visit_Ternary(self, node: Ternary) -> None: def _visit_MemberAccess(self, node: MemberAccess) -> None: chain = _resolve_member_chain(node) - if chain is not None and chain in DIVERGENT_VARS: - self._warn( + if ( + chain is not None + and chain in DIVERGENT_VARS + and id(node) not in self._callee_node_ids + ): + emit = self._err if chain in DIVERGENT_VARS_ERROR else self._warn + emit( node, f"{chain} diverges from TradingView semantics in PineForge.", hint=DIVERGENT_VARS[chain], diff --git a/tests/gate-corpus/err/divergent_last_bar_index.pine b/tests/gate-corpus/err/divergent_last_bar_index.pine new file mode 100644 index 0000000..e742328 --- /dev/null +++ b/tests/gate-corpus/err/divergent_last_bar_index.pine @@ -0,0 +1,7 @@ +//@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/err/divergent_time_close.pine b/tests/gate-corpus/err/divergent_time_close.pine new file mode 100644 index 0000000..a425840 --- /dev/null +++ b/tests/gate-corpus/err/divergent_time_close.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("T") +// The bare ``time_close`` variable is aliased to the bar OPEN timestamp in +// PineForge codegen, so a backtest comparing against it would be silently +// wrong -> hard reject (ERROR). (The session-aware time_close(...) FUNCTION is +// a separate, supported builtin and is not rejected.) +expired = time >= time_close +if expired + strategy.close_all() diff --git a/tests/test_support_checker.py b/tests/test_support_checker.py index b880cf9..ebcf003 100644 --- a/tests/test_support_checker.py +++ b/tests/test_support_checker.py @@ -16,6 +16,7 @@ HARD_REJECT_FUNC, HARD_REJECT_NAMESPACE, DIVERGENT_VARS, + DIVERGENT_VARS_ERROR, NOT_YET_FUNC, SECURITY_ALLOWED_PARAMS, ) @@ -63,10 +64,13 @@ def test_indicator_decl_rejected(): # --------------------------------------------------------------------------- -# Divergent built-in variables — warn, don't reject +# Divergent built-in variables — most WARN; the mis-alias subset ERRORs # --------------------------------------------------------------------------- -@pytest.mark.parametrize("var_name", sorted(DIVERGENT_VARS)) +_DIVERGENT_WARN_ONLY = sorted(set(DIVERGENT_VARS) - DIVERGENT_VARS_ERROR) + + +@pytest.mark.parametrize("var_name", _DIVERGENT_WARN_ONLY) def test_divergent_variables_warn(var_name: str): src = PRELUDE + f"x = {var_name}\n" assert _errors(src) == [], f"{var_name} should warn, not error" @@ -75,6 +79,62 @@ 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 / time_close are silent mis-aliases -> 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_time_close_errors(): + _expect_error(PRELUDE + "x = time_close\n", "time_close") + + +def test_bar_index_still_warns(): + src = PRELUDE + "x = bar_index\n" + assert _errors(src) == [], "bar_index must remain a WARNING, not ERROR" + assert any("diverges" in d.message for d in _warnings(src)) + + +def test_timenow_still_warns(): + src = PRELUDE + "x = timenow\n" + assert _errors(src) == [], "timenow must remain a WARNING, not ERROR" + assert any("diverges" in d.message for d in _warnings(src)) + + +def test_divergent_error_subset_is_subset(): + assert DIVERGENT_VARS_ERROR <= set(DIVERGENT_VARS) + assert "bar_index" not in DIVERGENT_VARS_ERROR + assert "timenow" not in DIVERGENT_VARS_ERROR + assert {"last_bar_index", "time_close"} == set(DIVERGENT_VARS_ERROR) + + +def test_time_close_function_call_not_flagged_as_divergent_var(): + """The session-aware ``time_close(...)`` FUNCTION is distinct from the + bare ``time_close`` variable and must not trip the divergent-var ERROR.""" + src = PRELUDE + 'tc = time_close("D")\n' + assert _errors(src) == [], ( + "time_close(...) is a supported function; only the bare variable " + "should be rejected" + ) + assert not any("diverges" in d.message for d in _warnings(src)) + + +def test_time_close_session_function_call_not_flagged(): + src = PRELUDE + 'int tc = time_close(timeframe.period, "0930-1600", "UTC")\n' + assert _errors(src) == [] + + # --------------------------------------------------------------------------- # Hard-reject functions / namespaces # --------------------------------------------------------------------------- From f2e78d7e9cb921598e9b1b3290c6e1038f70e894 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 18 Jun 2026 01:18:49 +0800 Subject: [PATCH 2/5] feat(support): warn on syminfo silent-gap fields outside conditionals too The 26 _SYMINFO_SILENT_GAP_FIELDS warned only inside an if/ternary condition, so a field read directly in a plain expression (e.g. `x = syminfo.pricescale * 2`) slipped out as na with NO signal. Broaden the gate to fire on EVERY read of those fields; keep it a WARNING (never ERROR). The conditional phrasing ("condition will always be false") is kept where it applies; plain reads get "any expression using it will be na". Tests: add plain-expression parametrized coverage for all na-accept fields and the exact arithmetic shape from the report; flip the old test_syminfo_sector_non_conditional_no_warn (which encoded the bug) to assert the plain read now warns and still does not error. Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/support_checker.py | 19 ++++-- tests/test_support_checker.py | 12 ++-- ...test_support_checker_syminfo_gap_fields.py | 59 +++++++++++++++++-- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 6aad50f..584b446 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -1036,14 +1036,21 @@ def _visit_MemberAccess(self, node: MemberAccess) -> None: if isinstance(node.object, Identifier) and node.object.name == "syminfo": if node.member not in SUPPORTED_SYMINFO: self._err(node, f"syminfo.{node.member} is not implemented in PineForge runtime.") - elif ( - self._in_conditional_depth > 0 - and node.member in self._SYMINFO_SILENT_GAP_FIELDS - ): + elif node.member in self._SYMINFO_SILENT_GAP_FIELDS: + # These fields silently return na in current PineForge. Warn on + # EVERY read — not just inside an if/ternary condition — because + # a field used directly in a plain expression (e.g. ``x = + # syminfo.pricescale * 2``) slips out as na with no signal too. + # The conditional phrasing is kept where it applies. + extra = ( + " condition will always be false." + if self._in_conditional_depth > 0 + else " any expression using it will be na." + ) self._warn( node, - f"syminfo.{node.member} returns na in current PineForge; " - "condition will always be false. " + f"syminfo.{node.member} returns na in current PineForge;" + f"{extra} " "Will be backfilled by pineforge-data product.", ) self._visit_children(node) diff --git a/tests/test_support_checker.py b/tests/test_support_checker.py index ebcf003..0f558d9 100644 --- a/tests/test_support_checker.py +++ b/tests/test_support_checker.py @@ -668,12 +668,16 @@ def test_syminfo_isin_conditional_warns(): assert any("isin" in d.message and "returns na" in d.message for d in warns) -def test_syminfo_sector_non_conditional_no_warn(): - """Using syminfo.sector outside a conditional should NOT produce silent-gap warning.""" +def test_syminfo_sector_non_conditional_warns(): + """A silent-gap field used OUTSIDE a conditional must ALSO warn now — the + field still slips out as na, so the read deserves the same signal as a + conditional use (previously it was silently dropped).""" src = PRELUDE + 'x = syminfo.sector\n' warns = _warnings(src) - assert not any("returns na" in d.message for d in warns), \ - f"Unexpected silent-gap warning outside conditional: {[d.message for d in warns]}" + assert any("sector" in d.message and "returns na" in d.message for d in warns), \ + f"Expected silent-gap warning for plain syminfo.sector, got: {[d.message for d in warns]}" + # Stays a WARNING, never escalated to ERROR. + assert _errors(src) == [] # --------------------------------------------------------------------------- diff --git a/tests/test_support_checker_syminfo_gap_fields.py b/tests/test_support_checker_syminfo_gap_fields.py index 0cb725a..92b1aca 100644 --- a/tests/test_support_checker_syminfo_gap_fields.py +++ b/tests/test_support_checker_syminfo_gap_fields.py @@ -1,10 +1,14 @@ -"""Conditional-use warnings for ALL na-accept syminfo fields (audit item A7). +"""Silent-gap warnings for ALL na-accept syminfo fields (audit item A7). ``_SYMINFO_SILENT_GAP_FIELDS`` used to cover only 6 fields; the metadata- backed fields (employees, shareholders, shares_outstanding_*, recommendations_*, target_price_*) and the na-literal fields (root, -pricescale, minmove) silently returned na with NO warning when used in a -condition. The set is now derived from SYMINFO_MEMBER_MAP so it cannot drift. +pricescale, minmove) silently returned na with NO warning. The set is now +derived from SYMINFO_MEMBER_MAP so it cannot drift. + +The warning used to fire ONLY inside an if/ternary condition, so a field read +directly in a plain expression (``x = syminfo.pricescale * 2``) slipped out as +na with no signal. The gate now fires for EVERY read — conditional AND plain. """ import pytest @@ -20,7 +24,7 @@ def _diags(src: str): return SupportChecker(ast).check() -def _warnings_for(field: str): +def _conditional_warnings_for(field: str): src = ( '//@version=6\n' 'strategy("T")\n' @@ -32,6 +36,23 @@ def _warnings_for(field: str): ] +def _plain_warnings_for(field: str): + """Field read in a plain (non-conditional) expression.""" + src = ( + '//@version=6\n' + 'strategy("T")\n' + f'x = syminfo.{field}\n' + ) + return [ + d for d in _diags(src) + if d.level == Level.WARNING and f"syminfo.{field}" in d.message + ] + + +# Back-compat alias for the original helper name. +_warnings_for = _conditional_warnings_for + + NA_ACCEPT_FIELDS = [ # original 6 "sector", "industry", "isin", @@ -52,11 +73,39 @@ def _warnings_for(field: str): @pytest.mark.parametrize("field", NA_ACCEPT_FIELDS) def test_conditional_use_warns(field): - assert _warnings_for(field), ( + assert _conditional_warnings_for(field), ( f"syminfo.{field} in a condition must emit a silent-gap warning" ) +@pytest.mark.parametrize("field", NA_ACCEPT_FIELDS) +def test_plain_expression_use_warns(field): + """A silent-gap field read OUTSIDE any conditional must warn too — the + bug was that such reads slipped out as na with no signal.""" + warns = _plain_warnings_for(field) + assert warns, ( + f"syminfo.{field} in a plain expression must emit a silent-gap warning" + ) + # Stays a WARNING (not escalated to ERROR). + assert all(d.level == Level.WARNING for d in warns) + + +def test_plain_arithmetic_use_warns(): + """The exact shape from the bug report: field used directly in a number.""" + src = ( + '//@version=6\n' + 'strategy("T")\n' + 'x = syminfo.pricescale * 2.0\n' + ) + warns = [ + d for d in _diags(src) + if d.level == Level.WARNING and "syminfo.pricescale" in d.message + ] + assert warns, "syminfo.pricescale * 2.0 must warn (read flows out as na)" + errs = [d for d in _diags(src) if d.level == Level.ERROR] + assert errs == [], "silent-gap fields warn, they do not error" + + @pytest.mark.parametrize("field", ["mintick", "tickerid", "currency", "timezone"]) def test_real_fields_do_not_warn(field): src = ( From b8698c4ef689dc20a218dbb600f199c5fc14ec72 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 18 Jun 2026 01:25:20 +0800 Subject: [PATCH 3/5] feat(codegen): wire max_bars_back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's Series history store is a fixed-capacity ring buffer with an explicit Series(int max_len = 500) ctor (include/pineforge/series.hpp); reads past the retained depth return na. max_bars_back was therefore in NOT_YET_FUNC ("silently dropped") even though the engine has the exact hook. Wire it: CodeGen._compute_max_bars_back_cap scans the AST for both directive forms — the strategy(..., max_bars_back=N) kwarg and the bare max_bars_back(var, N) function — and takes the MAX literal N. Every Series member declaration now emits that capacity as a ctor arg (Series name{N};). Applying the max to all series is a safe superset of Pine's per-var semantics: it never retains LESS than Pine, so any history access that succeeds in Pine succeeds here. Absent the directive the cap is None and declarations keep the bare form, so directive-free output is byte-identical to before (golden + determinism tests unchanged). support_checker: drop max_bars_back from NOT_YET_FUNC (no longer rejected). visit_call: the bare max_bars_back(...) call is a directive, not a value, so it emits a no-op (the buffer sizing is done in the prepass) instead of tripping the unknown-function guard. Tests: flip test_max_bars_back_rejected -> _accepted; add official-surface coverage for kwarg/function/max/default-unchanged sizing. Gate corpus: add ok/validation__max-bars-back-function-call-deep-history-01.pine to anchor the function form (the 3 existing fixtures only used the kwarg). Engine support verified directly against series.hpp. Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/codegen/base.py | 88 ++++++++++++++++--- pineforge_codegen/codegen/visit_call.py | 5 ++ pineforge_codegen/support_checker.py | 6 +- ...rs-back-function-call-deep-history-01.pine | 23 +++++ tests/test_official_surface.py | 40 +++++++++ tests/test_support_checker.py | 10 ++- 6 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 tests/gate-corpus/ok/validation__max-bars-back-function-call-deep-history-01.pine diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 85994ca..d22d1e4 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -469,6 +469,70 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._register_global_aggregate_member_types() self._uses_matrix = self._detect_matrix_usage() + # max_bars_back: the per-variable history depth the engine's Series + # ring buffer should retain. Pine exposes this two ways — the + # ``strategy(..., max_bars_back=N)`` kwarg (global) and the + # ``max_bars_back(var, N)`` function (per-var). The engine's + # ``Series(int max_len)`` ctor (default 500, include/pineforge/ + # series.hpp) is the wiring point: reads past the retained depth return + # na, so honoring the directive means constructing each Series with a + # capacity >= the requested depth. We take the MAX requested N and apply + # it to every Series declaration — a safe superset of Pine's per-var + # semantics (it never retains LESS than Pine, so any history access that + # succeeds in Pine succeeds here). ``None`` => no directive => keep the + # engine default 500 (emit a bare ``Series`` with no ctor arg, so + # directive-free output is byte-identical to before). + self._max_bars_back_cap: int | None = self._compute_max_bars_back_cap() + + @staticmethod + def _int_literal_value(node: ASTNode | None) -> int | None: + """Return the integer value of a (possibly unary-minus) NumberLiteral, + or None if ``node`` is not an integer literal expression.""" + if isinstance(node, UnaryOp) and node.op == "-": + inner = CodeGen._int_literal_value(node.operand) + return -inner if inner is not None else None + if isinstance(node, NumberLiteral) and isinstance(node.value, int): + return node.value + if isinstance(node, NumberLiteral) and isinstance(node.value, float): + # Pine accepts ``max_bars_back=5e2`` style; accept integral floats. + return int(node.value) if node.value.is_integer() else None + return None + + def _compute_max_bars_back_cap(self) -> int | None: + """Scan the AST for max_bars_back directives (strategy() kwarg AND the + bare function call) and return the largest positive integer requested, + or None if none is present / none is a usable literal.""" + ast = getattr(self.ctx, "ast", None) + if ast is None: + return None + caps: list[int] = [] + for node in self._walk_ast(ast): + if isinstance(node, StrategyDecl): + val = self._int_literal_value(node.kwargs.get("max_bars_back")) + if val is not None and val > 0: + caps.append(val) + elif ( + isinstance(node, FuncCall) + and isinstance(node.callee, Identifier) + and node.callee.name == "max_bars_back" + ): + # max_bars_back(var, num) — second positional arg, or the + # ``num=`` kwarg, is the depth. + num_node = None + if len(node.args) >= 2: + num_node = node.args[1] + elif "num" in node.kwargs: + num_node = node.kwargs["num"] + val = self._int_literal_value(num_node) + if val is not None and val > 0: + caps.append(val) + return max(caps) if caps else None + + def _series_decl_suffix(self) -> str: + """C++ constructor-arg suffix for Series member declarations. Empty + (engine default 500) unless a max_bars_back directive raised the cap.""" + return f"{{{self._max_bars_back_cap}}}" if self._max_bars_back_cap else "" + def _register_global_aggregate_member_types(self) -> None: """Infer matrix/array/map class members for global non-var declarations from RHS AST. @@ -802,6 +866,10 @@ def generate(self) -> str: lines: list[str] = [] + # Series ctor-arg suffix from any max_bars_back directive (empty when + # absent, so directive-free output is byte-identical to before). + _mbb = self._series_decl_suffix() + # 1. Includes self._emit_includes(lines) @@ -875,7 +943,7 @@ def generate(self) -> str: self._security_ohlc_hist_fields_by_sec.get(sec_id, ()) ): lines.append( - f" Series {self._security_ohlc_hist_series_cpp(sec_id, field)};" + f" Series {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};" ) continue if returns_tuple and tuple_size and tuple_size > 0 and isinstance(expr_node, TupleLiteral): @@ -896,7 +964,7 @@ def generate(self) -> str: lines.append(f" double _req_sec_{sec_id} = na();") for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())): lines.append( - f" Series {self._security_ohlc_hist_series_cpp(sec_id, field)};" + f" Series {self._security_ohlc_hist_series_cpp(sec_id, field)}{_mbb};" ) if self._security_calls: @@ -911,7 +979,7 @@ def generate(self) -> str: state_name = self._security_state_name(info["sec_id"], name) cpp_type = self._security_cpp_type_for_mutable(name, ginfo) if getattr(ginfo, "is_series", False): - lines.append(f" Series<{cpp_type}> {state_name};") + lines.append(f" Series<{cpp_type}> {state_name}{_mbb};") else: default = self._default_for_type(cpp_type) lines.append(f" {cpp_type} {state_name} = {default};") @@ -938,7 +1006,7 @@ def generate(self) -> str: # 4. Series members for bar field history for field_name in sorted(self.ctx.series_bar_fields): - lines.append(f" Series _s_{field_name};") + lines.append(f" Series _s_{field_name}{_mbb};") # 5. var/varip members (deduplicate by name) seen_var_members: set[str] = set() @@ -987,7 +1055,7 @@ def generate(self) -> str: if cpp_type == "int" and self._is_int64_builtin_init(name): cpp_type = "int64_t" if name in self.ctx.series_vars: - lines.append(f" Series<{cpp_type}> {safe};") + lines.append(f" Series<{cpp_type}> {safe}{_mbb};") else: lines.append(f" {cpp_type} {safe};") @@ -996,7 +1064,7 @@ def generate(self) -> str: if name not in self._var_names: safe = self._safe_name(name) cpp_type = self._series_type_for(name) - lines.append(f" Series<{cpp_type}> {safe};") + lines.append(f" Series<{cpp_type}> {safe}{_mbb};") # 7. Fixnan members for site in self.ctx.fixnan_sites: @@ -1009,9 +1077,9 @@ def generate(self) -> str: # Determine type: int for count vars, double for float vars if member in ("closedtrades", "opentrades", "wintrades", "losstrades", "eventrades"): - lines.append(f" Series {svar};") + lines.append(f" Series {svar}{_mbb};") else: - lines.append(f" Series {svar};") + lines.append(f" Series {svar}{_mbb};") # 8b. Global-scope non-var declarations as class members # (so user-defined functions can reference them) @@ -1063,7 +1131,7 @@ def generate(self) -> str: if self._safe_name(vname) == orig_safe: cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double") if vname in self.ctx.series_vars: - lines.append(f" Series<{cpp_type}> {cloned_safe};") + lines.append(f" Series<{cpp_type}> {cloned_safe}{_mbb};") elif vname in self._matrix_specs: lines.append(f" {self._type_spec_to_cpp(self._matrix_specs[vname])} {cloned_safe};") elif vname in self._array_vars: @@ -1078,7 +1146,7 @@ def generate(self) -> str: # Non-var series var if orig_safe in [self._safe_name(n) for n in self.ctx.series_vars]: cpp_type = self._series_type_for(orig_safe) - lines.append(f" Series<{cpp_type}> {cloned_safe};") + lines.append(f" Series<{cpp_type}> {cloned_safe}{_mbb};") else: lines.append(f" double {cloned_safe} = 0.0;") diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index dc4c10a..d45f33e 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -444,6 +444,11 @@ def _visit_func_call(self, node: FuncCall) -> str: return "0" if func_name in SKIP_FUNC_NAMES and namespace is None: return "0" + # max_bars_back(var, num): a history-depth DIRECTIVE, not a value. + # Its effect is captured in CodeGen._compute_max_bars_back_cap (which + # sizes every Series ring buffer), so the call itself emits nothing. + if func_name == "max_bars_back" and namespace is None: + return "0" # request.* calls if namespace == "request": diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 584b446..8d6e777 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -205,8 +205,12 @@ }) # Implementable but currently silent in codegen -> reject loudly. +# +# max_bars_back was here ("silently dropped") but is now WIRED: codegen sizes +# every Series ring buffer to the requested depth via the engine's +# ``Series(int max_len)`` ctor (include/pineforge/series.hpp). It is no +# longer rejected — see CodeGen._compute_max_bars_back_cap. NOT_YET_FUNC: dict[str, str] = { - "max_bars_back": "max_bars_back is silently dropped by the codegen.", "timeframe.from_seconds": "timeframe.from_seconds is not yet implemented; codegen would emit 'false' and silently produce wrong TF strings.", } diff --git a/tests/gate-corpus/ok/validation__max-bars-back-function-call-deep-history-01.pine b/tests/gate-corpus/ok/validation__max-bars-back-function-call-deep-history-01.pine new file mode 100644 index 0000000..faa77dc --- /dev/null +++ b/tests/gate-corpus/ok/validation__max-bars-back-function-call-deep-history-01.pine @@ -0,0 +1,23 @@ +//@version=6 +// Purpose: anchor the now-WIRED max_bars_back(var, num) FUNCTION-call form in +// the conformance gate. The directive sizes the engine's Series ring buffer +// (Series(int max_len), include/pineforge/series.hpp) so the deep-history +// reference close[300] retains real data instead of slipping out as na. +strategy("Max Bars Back Function Call", overlay=true, max_bars_back=200) + +// Function-call form: raise the per-var retention well past the default 500 +// AND past the kwarg above, so codegen must take the MAX (1000). +max_bars_back(close, 1000) + +deepRef = close[300] +sma = ta.sma(close, 50) + +longCond = not na(deepRef) and close > sma and close > deepRef +shortCond = not na(deepRef) and close < sma and close < deepRef + +if longCond + strategy.entry("L", strategy.long) +if shortCond + strategy.close("L") + +plot(sma) diff --git a/tests/test_official_surface.py b/tests/test_official_surface.py index 513d885..2829387 100644 --- a/tests/test_official_surface.py +++ b/tests/test_official_surface.py @@ -396,6 +396,46 @@ def test_intentionally_rejected_cases_raise_compile_error(body): transpile(_pine(body)) +# --------------------------------------------------------------------------- +# max_bars_back: now a SUPPORTED directive (was NOT_YET / silently dropped). +# The engine's Series(int max_len) ring buffer (include/pineforge/series.hpp) +# is the wiring point: codegen sizes every Series to the requested depth. +# --------------------------------------------------------------------------- + +def test_max_bars_back_strategy_kwarg_sizes_series(): + """strategy(..., max_bars_back=N) sizes the Series ring buffers to N.""" + cpp = transpile( + '//@version=6\nstrategy("T", max_bars_back=1234)\nx = close[400]\nplot(x)\n' + ) + assert "Series _s_close{1234};" in cpp, ( + "max_bars_back kwarg must size the bar-field Series ring buffer" + ) + + +def test_max_bars_back_function_call_sizes_series(): + """The bare max_bars_back(var, N) function is accepted and sizes Series.""" + cpp = transpile( + '//@version=6\nstrategy("T")\nmax_bars_back(close, 2048)\nx = close[400]\nplot(x)\n' + ) + assert "Series _s_close{2048};" in cpp + + +def test_max_bars_back_takes_max_across_directives(): + cpp = transpile( + '//@version=6\nstrategy("T", max_bars_back=300)\n' + 'max_bars_back(close, 5000)\nx = close[400]\nplot(x)\n' + ) + assert "Series _s_close{5000};" in cpp + + +def test_no_max_bars_back_leaves_series_at_engine_default(): + """Absent the directive, Series declarations keep the bare form (engine + default 500) so directive-free output is byte-identical to before.""" + cpp = transpile('//@version=6\nstrategy("T")\nx = close[10]\nplot(x)\n') + assert "Series _s_close;" in cpp + assert "_s_close{" not in cpp + + # --------------------------------------------------------------------------- # Hard-reject table sanity # --------------------------------------------------------------------------- diff --git a/tests/test_support_checker.py b/tests/test_support_checker.py index 0f558d9..a9e2a83 100644 --- a/tests/test_support_checker.py +++ b/tests/test_support_checker.py @@ -227,9 +227,15 @@ def test_str_format_time_supported(): assert _errors(src) == [] -def test_max_bars_back_rejected(): +def test_max_bars_back_accepted(): + """max_bars_back is WIRED now (sizes the Series ring buffer via the + engine's Series(int max_len) ctor) — it is no longer rejected.""" src = PRELUDE + "max_bars_back(close, 500)\n" - _expect_error(src, "max_bars_back") + assert _errors(src) == [], "max_bars_back is wired and must not be rejected" + + +def test_max_bars_back_not_in_not_yet(): + assert "max_bars_back" not in NOT_YET_FUNC def test_bare_barssince_rejected(): From 7968b8fdb8f02bc68a9c39ee0477079bf622482b Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 18 Jun 2026 01:37:10 +0800 Subject: [PATCH 4/5] docs(codegen): correct max_bars_back Series-cap coverage comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The max_bars_back wiring comment claimed the {N} cap is applied to "every Series declaration". That is inaccurate: the security-helper map (_security_helper_series_, the std::unordered_map> ~line 971) does NOT pick up the cap — its entries are default-constructed on first operator[] access and therefore always use the engine default 500. Reword to say the cap applies to the directly-declared Series members, and document the security-helper map as a known limitation. Comment-only; no change to generated C++ output. Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/codegen/base.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index d22d1e4..8e90f59 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -477,11 +477,20 @@ def __init__(self, ctx: AnalyzerContext) -> None: # series.hpp) is the wiring point: reads past the retained depth return # na, so honoring the directive means constructing each Series with a # capacity >= the requested depth. We take the MAX requested N and apply - # it to every Series declaration — a safe superset of Pine's per-var - # semantics (it never retains LESS than Pine, so any history access that - # succeeds in Pine succeeds here). ``None`` => no directive => keep the - # engine default 500 (emit a bare ``Series`` with no ctor arg, so + # it (via ``_series_decl_suffix`` -> ``{N}``) to the directly-declared + # ``Series`` members — a safe superset of Pine's per-var semantics + # (it never retains LESS than Pine, so any history access that succeeds + # in Pine succeeds here). ``None`` => no directive => keep the engine + # default 500 (emit a bare ``Series`` with no ctor arg, so # directive-free output is byte-identical to before). + # + # KNOWN LIMITATION: the lazily-constructed security-helper map series + # (``_security_helper_series_``, the ``std::unordered_map>`` ~line 971) do NOT pick up the cap. Their entries are + # default-constructed on first ``operator[]`` access, so they always use + # the engine default 500 regardless of the requested ``N``. A + # max_bars_back directive larger than 500 is therefore not honored for + # history reads off security-helper series. self._max_bars_back_cap: int | None = self._compute_max_bars_back_cap() @staticmethod From 38295a602df32cdc958bbe18f40747ef7bcec98e Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 18 Jun 2026 01:38:46 +0800 Subject: [PATCH 5/5] feat(gate): worker verifies archive sha256 when manifest provides it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped Pyodide transpile worker fetched manifest.json -> archive -> unpackArchive + runPython with no integrity check. Add a defensive, verify-if-present sha256 check: after fetching the archive bytes and BEFORE unpackArchive, if manifest.sha256 is present, compute the SHA-256 of the bytes via the worker's WebCrypto (crypto.subtle.digest("SHA-256", buf), hex-encoded) and compare. On mismatch, post an init-error ("codegen archive sha256 mismatch — expected got ") and return without unpacking or running. When manifest.sha256 is absent the check is skipped, so older manifests that predate the field keep working (forward/backward compatible) — the app writes the field in a later wave. The hex encoding matches Node's createHash("sha256").digest("hex") (and thus the gate's release.json sha256), so a manifest sha256 derived from release.json will verify. Edits scripts/worker-template.mjs (the __GLUE__-templated source); the generated npm/transpile.worker.mjs is a gitignored build artifact regenerated by build-npm-package.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/worker-template.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/worker-template.mjs b/scripts/worker-template.mjs index 87de323..7a46cdc 100644 --- a/scripts/worker-template.mjs +++ b/scripts/worker-template.mjs @@ -12,6 +12,14 @@ const GLUE = `__GLUE__`; const post = (m) => self.postMessage(m); let transpileJson = null; +// Hex-encode the SHA-256 of an ArrayBuffer using the worker's WebCrypto. +async function sha256Hex(buf) { + const digest = await crypto.subtle.digest("SHA-256", buf); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + async function init() { try { const pyodide = await loadPyodide({ indexURL: "/pyodide/" }); @@ -21,6 +29,19 @@ async function init() { const archiveRes = await fetch(`/pyodide/${manifest.archive}`); if (!archiveRes.ok) throw new Error(`fetch /pyodide/${manifest.archive}: ${archiveRes.status}`); const buf = await archiveRes.arrayBuffer(); + // Defensive integrity check: verify the archive bytes against the manifest's + // sha256 BEFORE unpacking/running. Verify-if-present — older manifests that + // predate the sha256 field are accepted unchanged (forward/backward compat). + if (manifest.sha256) { + const actual = await sha256Hex(buf); + if (actual !== manifest.sha256) { + post({ + type: "init-error", + error: `codegen archive sha256 mismatch — expected ${manifest.sha256} got ${actual}`, + }); + return; + } + } pyodide.unpackArchive(buf, "gztar", { extractDir: "/codegen" }); pyodide.runPython(GLUE); const fn = pyodide.globals.get("transpile_json");