diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 85994ca..8e90f59 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -469,6 +469,79 @@ 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 (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 + 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 +875,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 +952,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 +973,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 +988,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 +1015,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 +1064,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 +1073,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 +1086,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 +1140,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 +1155,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 500a5a6..8d6e777 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.", @@ -191,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.", } @@ -386,6 +404,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 +653,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 +948,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 +976,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], @@ -1004,14 +1040,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/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"); 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/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 b880cf9..a9e2a83 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 # --------------------------------------------------------------------------- @@ -167,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(): @@ -608,12 +674,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 = (