From 807b392b672fd2229c68f8ecb2dbf591a7bce47f Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 4 Oct 2026 16:24:16 +0800 Subject: [PATCH] Emit immutable compiled execution capability receipts Add the optional versioned generated-strategy capability ABI for the next release. Resolve positional strategy declarations in Pine order, retain unresolved literals and unpinned request sites, and collect request, feed, FX, intrabar and endpoint/realtime requirements in one AST walk. Cover canonical receipts and conservative runner admission with generated fixtures and compile tests. Document display-only refusal, legacy warning compatibility and receipt limitations. Add Unreleased notes while preserving all released sections and default batch computation. --- CHANGELOG.md | 23 +++ docs/PUBLIC_CONTRACT.md | 43 +++++ pineforge_codegen/codegen/capabilities.py | 186 ++++++++++++++++++ pineforge_codegen/codegen/emit_top.py | 2 + pineforge_codegen/codegen/helpers.py | 1 + pineforge_codegen/external_requests.py | 16 ++ tests/_legacy_cpp.py | 4 +- tests/test_strategy_capabilities.py | 224 ++++++++++++++++++++++ 8 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 pineforge_codegen/codegen/capabilities.py create mode 100644 tests/test_strategy_capabilities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e23f6..3559b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ Release notes for `pineforge-codegen`. From 1.0.0 on, versions of codegen and supported as exact pairs; on the 0.x line they are independent. See the [pairing rule](README.md#engine-pairing). +## Unreleased + +### Compiled execution capabilities + +- Emit the optional generated-strategy C ABI capability extension: + `strategy_capabilities_api_version()` and `strategy_capabilities_receipt()`, + versioned by the paired engine's `PF_CAPABILITIES_API_VERSION`. Its immutable + canonical JSON uses the checked-settings buffer protocol without changing the + base C ABI. Regenerate C++ and relink against the paired next-release engine. +- Record `strategy()` execution declarations, including positional arguments in + Pine signature order, and analyzed request/feed, FX and intrabar requirements. + Name nonliteral arguments and unpinned runtime-lowered request sites in + `unresolved`; retain every request kind, symbol, timeframe and lookahead. + Record endpoint/realtime builtin use even in plots, labels and tables. The + receipt proves declarations only, not arbitrary live-versus-batch equivalence. +- The paired close-only runner refuses intrabar/fill-policy declarations, + `process_orders_on_close`, every `request.*` site regardless of timeframe, + unsupported clock/feed/FX requirements, `varip`, unresolved declarations and + the six endpoint/realtime builtins before its ledger exists, naming the + requirement instead of silently changing the computation. Legacy libraries + without the extension warn and run, including their requests, without proving + eligibility. Default batch computation is unchanged. + ## Release note policy - Keep a section for each released version, including prereleases. Use the exact diff --git a/docs/PUBLIC_CONTRACT.md b/docs/PUBLIC_CONTRACT.md index 5d9ad35..a7bb77f 100644 --- a/docs/PUBLIC_CONTRACT.md +++ b/docs/PUBLIC_CONTRACT.md @@ -1,5 +1,48 @@ # Public contract for 1.0 +## Optional compiled execution capabilities + +Availability: **since the next release**, with the paired engine's capability +extension and receipt-based runner admission policy. + +The receipt proves declarations only, not general live-versus-batch equivalence. +Its `strategy()` positional arguments follow Pine signature order independently +of batch configuration extraction. Every unresolved argument is named in +`unresolved`. Runtime-lowered unpinned request sites retain their request kind, +symbol and timeframe in `requests` with `feed: "unpinned"`, and in `unresolved`. +The close-only runner retains its conservative refusal of every nonempty +request array and `process_orders_on_close=true`; confirmed-bar runtime fixes +and their equivalence tests do not automatically widen that admission policy. +Legacy libraries without a receipt warn and run, including their requests. +Realtime +builtins are conservatively named and refused: generated `barstate.isrealtime` +is `false`, and `timenow` reads the bar timestamp in both warmup and realtime. +`barstate.islast`, `barstate.islastconfirmedhistory`, `last_bar_index` and +`last_bar_time` are also named in `unresolved`: a stream's delivered endpoint +and last-bar flags differ from the completed batch's final bar. +All six builtin uses are refused including display-only use (plots, labels, +tables): the receipt records their use conservatively rather than certifying +that display-only code cannot influence strategy execution. + +Paired development headers defining `PF_CAPABILITIES_API_VERSION` add +`strategy_capabilities_api_version()` (version 1) and +`strategy_capabilities_receipt(handle, json, capacity, required, error, error_capacity)`. +The receipt uses the checked-settings status and buffer protocol: NULL/0 queries +return `PF_SETTINGS_BUFFER_TOO_SMALL`, `required` includes the NUL, and short +buffers return no partial JSON. Canonical JSON is emitted from `strategy()` +declarations and analyzed security/request sites, not guessed from a handle's +runtime behavior. It is immutable across settings changes and fresh/reused runs. + +Version 1 has `version`, `declarations`, `requests`, `requirements` and `unresolved` +keys. Declarations include calculation cadence, close execution, magnifier, +standard-OHLC fill mode, limit verification, currency and clock settings. Requests +name their function, symbol, timeframe, gaps, lookahead, Heikin-Ashi transform and +feed source. Requirements name historical-only data and intrabar persistence; +nonliteral contexts remain explicit rather than silently assuming defaults. +See the paired engine's `docs/strategy-capabilities.md` for the exact schema and +live-runner refusal policy. No batch dispatch, strategy calculation, matching, +margin or numeric behavior changes. Old paired headers emit no capability extension. + ## Optional generated settings extension From 1.1.0 on, C++ compiled against an engine providing diff --git a/pineforge_codegen/codegen/capabilities.py b/pineforge_codegen/codegen/capabilities.py new file mode 100644 index 0000000..497da15 --- /dev/null +++ b/pineforge_codegen/codegen/capabilities.py @@ -0,0 +1,186 @@ +"""Immutable execution metadata; it does not alter generated computation.""" + +import json +import math + +from ..ast_nodes import BoolLiteral, FuncCall, Identifier, MemberAccess, NumberLiteral, StrategyDecl, StringLiteral, UnaryOp, VarDecl +from ..external_requests import CAPABILITY_UNPINNED_ANNOTATION +from ..limits import iter_ast_nodes + + +STRATEGY_PARAMETERS = ( + "title", "shorttitle", "overlay", "format", "precision", "scale", "pyramiding", + "calc_on_order_fills", "calc_on_every_tick", "max_bars_back", + "backtest_fill_limits_assumption", "default_qty_type", "default_qty_value", + "initial_capital", "currency", "slippage", "commission_type", "commission_value", + "process_orders_on_close", "close_entries_rule", "margin_long", "margin_short", + "explicit_plot_zorder", "max_lines_count", "max_labels_count", "max_boxes_count", + "calc_bars_count", "risk_free_rate", "use_bar_magnifier", "fill_orders_on_standard_ohlc", + "max_polylines_count", "dynamic_requests", "behind_chart", "calc_on_every_history_tick", +) + + +DECLARATION_DEFAULTS = { + "calc_on_every_tick": False, + "calc_on_order_fills": False, + "process_orders_on_close": False, + "use_bar_magnifier": False, + "fill_orders_on_standard_ohlc": False, + "backtest_fill_limits_assumption": 0, + "currency": "currency.NONE", + "timeframe": "", + "timeframe_gaps": True, + "dynamic_requests": True, + "calc_on_every_history_tick": False, +} + + +def _expression(node): + if isinstance(node, (StringLiteral, NumberLiteral, BoolLiteral)): + return node.value + if isinstance(node, Identifier): + return node.name + if isinstance(node, MemberAccess): + return f"{_expression(node.object)}.{node.member}" + if isinstance(node, FuncCall): + return f"{_expression(node.callee)}({','.join(str(_expression(arg)) for arg in node.args)})" + return None + + +def _literal(node): + if isinstance(node, (StringLiteral, NumberLiteral, BoolLiteral)): + return node.value, True + if isinstance(node, UnaryOp) and node.op in ("+", "-"): + value, valid = _literal(node.operand) + if valid and type(value) in (int, float): + return value if node.op == "+" else -value, True + if isinstance(node, MemberAccess): + value = _expression(node) + if value and value.split(".")[0] in ("currency", "format", "scale", "strategy"): + return value, True + return _expression(node), False + + +def capabilities_document(emitter) -> str: + declarations = dict(DECLARATION_DEFAULTS) + unresolved = [] + requests = [] + lower_symbols = {} + intrabar = False + recorded = False + for node, _depth in iter_ast_nodes(emitter.ctx.ast): + if isinstance(node, StrategyDecl): + arguments = {STRATEGY_PARAMETERS[index] if index < len(STRATEGY_PARAMETERS) + else f"strategy() positional argument {index + 1}": argument + for index, argument in enumerate(node.args)} + unresolved.extend(arguments.keys() & node.kwargs.keys()) + arguments.update(node.kwargs) + for name, argument in arguments.items(): + value, valid = _literal(argument) + default = DECLARATION_DEFAULTS.get(name) + if name in DECLARATION_DEFAULTS: + valid &= type(value) is type(default) + if name == "backtest_fill_limits_assumption": + valid = (type(value) is int and value >= 0) or ( + type(value) is float and math.isfinite(value) and value >= 0 and value == int(value)) + if valid: + value = int(value) + declarations[name] = value + if not valid or name not in STRATEGY_PARAMETERS and name not in DECLARATION_DEFAULTS: + unresolved.append(name) + elif isinstance(node, VarDecl): + intrabar |= node.is_varip + elif isinstance(node, FuncCall): + name, namespace = emitter._resolve_callee(node.callee) + if namespace == "request" and name == "security_lower_tf": + expression = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression") + lower_symbols[id(expression)] = node.args[0] if node.args else node.kwargs.get("symbol") + parts = (node.annotations or {}).get("pf_recorded_key") + if parts is not None: + recorded = True + requests.append({ + "function": f"{namespace}.{name}", + "symbol": _expression(node.args[0]) if node.args else None, + "timeframe": "", + "lookahead": f"barmerge.lookahead_{parts.get('lookahead', 'off')}", + "gaps": f"barmerge.gaps_{parts.get('gaps', 'off')}", + "heikinashi": False, + "feed": "recorded", + }) + if isinstance(node, Identifier) and node.name == "timenow": + unresolved.append("timenow (bar timestamp approximation)") + if isinstance(node, Identifier) and node.name in ("last_bar_index", "last_bar_time"): + unresolved.append(f"{node.name} (stream endpoint is not batch-equivalent)") + if isinstance(node, MemberAccess) and _expression(node) == "barstate.isrealtime": + unresolved.append("barstate.isrealtime (compiled historical-only approximation)") + if isinstance(node, MemberAccess) and _expression(node) in ( + "barstate.islast", "barstate.islastconfirmedhistory"): + unresolved.append(f"{_expression(node)} (stream endpoint is not batch-equivalent)") + + auxiliary = False + for site in emitter._security_calls: + symbol_node = site.get("symbol_node") + if site.get("is_lower_tf_array"): + symbol_node = lower_symbols.get(id(site.get("expr_node")), symbol_node) + if isinstance(symbol_node, FuncCall) and site.get("heikinashi") and symbol_node.args: + symbol_node = symbol_node.args[0] + symbol = _expression(symbol_node) if symbol_node is not None else "syminfo.tickerid" + timeframe = _expression(site.get("tf_node")) + lookahead = _expression(site.get("lookahead_node")) or "barmerge.lookahead_off" + gaps = _expression(site.get("gaps_node")) or "barmerge.gaps_off" + if isinstance(symbol_node, Identifier) and symbol_node.name not in emitter._global_mutable_infos: + symbol = _expression((getattr(emitter.ctx, "global_expr_map", {}) or {}).get( + symbol_node.name, symbol_node)) + feed = "chart" if symbol in ("syminfo.tickerid", "syminfo.ticker", "") else "auxiliary" + auxiliary |= feed == "auxiliary" + if not isinstance(site.get("tf_node"), StringLiteral) and timeframe != "timeframe.period": + unresolved.append(f"request.security[{site['sec_id']}].timeframe") + if lookahead not in ("barmerge.lookahead_off", "barmerge.lookahead_on"): + unresolved.append(f"request.security[{site['sec_id']}].lookahead") + requests.append({ + "function": "request.security_lower_tf" if site.get("is_lower_tf_array") else "request.security", + "symbol": symbol, + "timeframe": timeframe, + "lookahead": lookahead, + "gaps": gaps, + "heikinashi": bool(site.get("heikinashi")), + "feed": feed, + }) + for index, site in enumerate((emitter.ctx.ast.annotations or {}).get(CAPABILITY_UNPINNED_ANNOTATION, ())): + requests.append({ + "function": site["function"], + "symbol": _expression(site["symbol_node"]), + "timeframe": _expression(site["tf_node"]) if site["function"] in ( + "request.security", "request.security_lower_tf") else "", + "lookahead": _expression(site["lookahead_node"]) or "barmerge.lookahead_off", + "gaps": _expression(site["gaps_node"]) or "barmerge.gaps_off", + "heikinashi": False, + "feed": "unpinned", + }) + unresolved.append(f"{site['function']}[unpinned:{index}]") + requirements = { + "auxiliary_security_feeds": auxiliary, + "native_security_feeds": False, + "fx_curve": declarations["currency"] != "currency.NONE", + "recorded_series": recorded, + "historical_probe_overrides": False, + "intrabar_persistence": intrabar, + } + return json.dumps({"version": 1, "declarations": declarations, "requests": requests, + "requirements": requirements, "unresolved": sorted(set(unresolved))}, + sort_keys=True, separators=(",", ":"), allow_nan=False) + + +def emit_capabilities_exports(emitter, lines: list[str]) -> None: + literal = json.dumps(capabilities_document(emitter)) + lines.extend([ + "#ifdef PF_SETTINGS_API_VERSION", + "#ifdef PF_CAPABILITIES_API_VERSION", + " uint32_t strategy_capabilities_api_version(void) { return PF_CAPABILITIES_API_VERSION; }", + " int strategy_capabilities_receipt(void* s, char* json, size_t capacity, size_t* required, char* error, size_t error_capacity) {", + " if (required) *required = 0;", + " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {", + ' ::pineforge::checked_settings::require(s != nullptr, "null strategy");', + f" ::pineforge::checked_settings::receipt({literal}, json, capacity, required);", + " });", " }", "#endif", "#endif", + ]) diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 04fec05..f376279 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -1998,6 +1998,8 @@ def _emit_extern_c(self, lines: list[str]) -> None: lines.append(" }") from .checked_settings import emit_settings_exports emit_settings_exports(lines) + from .capabilities import emit_capabilities_exports + emit_capabilities_exports(self, lines) if self._declares_bar_magnifier(): # TradingView runs a script that declares use_bar_magnifier = true # on its bar magnifier; the host reads this export to run it on diff --git a/pineforge_codegen/codegen/helpers.py b/pineforge_codegen/codegen/helpers.py index 5540def..6c6e045 100644 --- a/pineforge_codegen/codegen/helpers.py +++ b/pineforge_codegen/codegen/helpers.py @@ -282,6 +282,7 @@ def cpp_code_only(cpp: str) -> str: configure_security_evaluators snapshot_script_state restore_script_state commit_script_state set_strategy_override set_input set_magnifier_volume_weighted strategy_declares_bar_magnifier fill_report run + strategy_capabilities_api_version strategy_capabilities_receipt precalculate strategy_entry strategy_close strategy_close_all strategy_exit strategy_exit_cancel_bracket strategy_cancel strategy_cancel_all strategy_order diff --git a/pineforge_codegen/external_requests.py b/pineforge_codegen/external_requests.py index 062241b..9ea3b68 100644 --- a/pineforge_codegen/external_requests.py +++ b/pineforge_codegen/external_requests.py @@ -85,6 +85,19 @@ # RequestRef}``: the run stops there only when no data is installed for the # request that carries the same ref (``REQUEST_REF_ANNOTATION``). UNPINNED_ANNOTATION = "pf_request_unpinned" +CAPABILITY_UNPINNED_ANNOTATION = "pf_capability_unpinned_requests" + + +def _record_unpinned_capability(program, request): + notes = program.annotations = dict(program.annotations or {}) + sites = notes.setdefault(CAPABILITY_UNPINNED_ANNOTATION, []) + sites.append({ + "function": f"request.{request.callee.member}", + "symbol_node": request.args[0] if request.args else request.kwargs.get("symbol"), + "tf_node": request.args[1] if len(request.args) > 1 else request.kwargs.get("timeframe"), + "lookahead_node": request.kwargs.get("lookahead"), + "gaps_node": request.kwargs.get("gaps"), + }) REQUEST_REF_ANNOTATION = "pf_request_ref" # On a request whose symbol can select the chart's or another symbol's: it # read the chart before it read a feed, and keeps that lowering where no feed @@ -785,10 +798,12 @@ def lower_no_data_requests(program: Program) -> Program: backed.append(node) continue if lowering == ABSENT_LOWERING: + _record_unpinned_capability(program, node) node.annotations = {**node.annotations, UNPINNED_ANNOTATION: unpinned_message(node)} continue swaps[id(node)] = _na_of(node, funcs) if lowering == "unpinned": + _record_unpinned_capability(program, node) unpinned.append(node) if unpinned or backed: index = ScriptIndex(program) @@ -853,6 +868,7 @@ def unpin_requests(program: Program, reasons: dict[int, str]) -> None: "results.")) continue lowered = _na_of(request, funcs) + _record_unpinned_capability(program, request) marker = (request.annotations or {}).get(UNPINNED_ANNOTATION) if isinstance(marker, dict): lowered.annotations = {**(lowered.annotations or {}), diff --git a/tests/_legacy_cpp.py b/tests/_legacy_cpp.py index 491830c..8e39d03 100644 --- a/tests/_legacy_cpp.py +++ b/tests/_legacy_cpp.py @@ -1,4 +1,4 @@ -"""Compare frozen lowering bytes without the additive checked-settings ABI.""" +"""Compare frozen lowering bytes without additive settings/capabilities ABIs.""" import re @@ -6,6 +6,8 @@ def legacy_cpp(cpp: str) -> str: cpp = cpp.replace( '#if __has_include()\n' '#include \n#endif\n', '') + cpp = re.sub(r'^#ifdef PF_CAPABILITIES_API_VERSION\n.*?^#endif\n', '', + cpp, flags=re.MULTILINE | re.DOTALL) cpp = re.sub(r'^#ifdef PF_SETTINGS_API_VERSION\n.*?^#endif\n', '', cpp, flags=re.MULTILINE | re.DOTALL) cpp = re.sub(r'^#ifndef PF_SETTINGS_API_VERSION\n.*?^#endif\n', '', diff --git a/tests/test_strategy_capabilities.py b/tests/test_strategy_capabilities.py new file mode 100644 index 0000000..6d9ee7c --- /dev/null +++ b/tests/test_strategy_capabilities.py @@ -0,0 +1,224 @@ +"""Compiled execution requirements are receipts, not runtime guesses.""" +import json +import re + +import pytest + +from pineforge_codegen import transpile +from tests._compile import run_emitted_tu +from pineforge_codegen.codegen.capabilities import STRATEGY_PARAMETERS + + +def emitted_receipt(source): + cpp = transpile(source) + match = re.search(r'checked_settings::receipt\(("(?:[^"\\]|\\.)*"), json, capacity, required\);', cpp) + assert match, cpp[-6000:] + document = json.loads(match.group(1)) + receipt = json.loads(document) + assert json.dumps(receipt, sort_keys=True, separators=(',', ':')) == document + assert 'strategy_capabilities_api_version(void)' in cpp + assert '#ifdef PF_CAPABILITIES_API_VERSION' in cpp + return receipt + + +@pytest.mark.parametrize(('name', 'value', 'expected'), [ + ('calc_on_every_tick', 'true', True), + ('calc_on_order_fills', 'true', True), + ('process_orders_on_close', 'true', True), + ('use_bar_magnifier', 'true', True), + ('fill_orders_on_standard_ohlc', 'true', True), + ('backtest_fill_limits_assumption', '3', 3), + ('currency', 'currency.EUR', 'currency.EUR'), + ('timeframe', '"15"', '15'), + ('timeframe_gaps', 'false', False), + ('dynamic_requests', 'false', False), + ('calc_on_every_history_tick', 'true', True), +]) +def test_strategy_declarations(name, value, expected): + receipt = emitted_receipt(f'//@version=6\nstrategy("capabilities", {name}={value})\nstrategy.entry("entry", strategy.long)') + assert receipt['version'] == 1 + assert receipt['declarations'][name] == expected + assert receipt['requirements']['fx_curve'] is (name == 'currency') + assert not receipt['unresolved'] + + +@pytest.mark.parametrize(('function', 'timeframe', 'lookahead'), [ + ('security', '15', 'off'), ('security', 'D', 'on'), ('security_lower_tf', '1', 'off'), +]) +def test_security_contexts(function, timeframe, lookahead): + options = f', lookahead=barmerge.lookahead_{lookahead}' if function == 'security' else '' + expression = f'request.{function}(syminfo.tickerid, "{timeframe}", close{options})' + payload = 'array.get(value, 0)' if function == 'security_lower_tf' else 'value' + receipt = emitted_receipt(f'//@version=6\nstrategy("security receipt")\nvalue = {expression}\nif {payload} > close\n strategy.entry("entry", strategy.long)') + request = receipt['requests'][0] + assert request['function'] == f'request.{function}' + assert request['symbol'] == 'syminfo.tickerid' + assert request['timeframe'] == timeframe + assert request['lookahead'] == f'barmerge.lookahead_{lookahead}' + assert request['feed'] == 'chart' + assert not receipt['requirements']['auxiliary_security_feeds'] + + +def test_auxiliary_symbol_and_recorded_requests(): + source = '''//@version=6 +strategy("external receipt") +other = request.security("NASDAQ:MSFT", "D", close) +earnings = request.earnings("NASDAQ:MSFT") +if other > earnings + strategy.entry("entry", strategy.long) +''' + receipt = emitted_receipt(source) + assert receipt['requirements']['auxiliary_security_feeds'] + assert receipt['requirements']['recorded_series'] + assert any(request['symbol'] == 'NASDAQ:MSFT' for request in receipt['requests']) + assert any(request['feed'] == 'recorded' for request in receipt['requests']) + + +def test_dynamic_context_and_intrabar_persistence_are_explicit(): + receipt = emitted_receipt('''//@version=6 +strategy("dynamic receipt") +tf = input.timeframe("15", "Requested timeframe") +varip int count = 0 +count += 1 +value = request.security(syminfo.tickerid, tf, close) +if value > close + strategy.entry("entry", strategy.long) +''') + assert receipt['requirements']['intrabar_persistence'] + assert receipt['unresolved'] == ['request.security[0].timeframe'] + + +def test_nonliteral_declaration_is_not_silently_defaulted(): + receipt = emitted_receipt('''//@version=6 +strategy("unresolved declaration", calc_on_every_tick=TICK) +const bool TICK = true +strategy.entry("entry", strategy.long) +''') + assert 'calc_on_every_tick' in receipt['unresolved'] + assert receipt['declarations']['calc_on_every_tick'] != False + + +@pytest.mark.parametrize(('name', 'value', 'expected'), [ + ('calc_on_order_fills', 'true', True), ('calc_on_every_tick', 'true', True), + ('backtest_fill_limits_assumption', '3', 3), ('currency', 'currency.EUR', 'currency.EUR'), + ('process_orders_on_close', 'true', True), ('use_bar_magnifier', 'true', True), + ('fill_orders_on_standard_ohlc', 'true', True), +]) +def test_positional_declarations(name, value, expected): + arguments = ['"t"', '"s"', 'true', 'format.price', '2', 'scale.right', '1', + 'false', 'false', '500', '0', 'strategy.fixed', '1', '10000', + 'currency.NONE', '0', 'strategy.commission.percent', '0', 'false', + '"FIFO"', '100', '100', 'false', '50', '50', '50', '0', '2', 'false', 'false'] + position = STRATEGY_PARAMETERS.index(name) + arguments[position] = value + receipt = emitted_receipt(f'//@version=6\nstrategy({",".join(arguments[:position + 1])})\nstrategy.entry("entry", strategy.long)') + assert receipt['declarations'][name] == expected + assert not receipt['unresolved'] + + +def test_exact_review_positional_form(): + receipt = emitted_receipt('//@version=6\nstrategy("t","s",true,format.price,2,scale.right,1,true,true)\nstrategy.entry("entry", strategy.long)') + assert receipt['declarations']['calc_on_every_tick'] is True + assert receipt['declarations']['calc_on_order_fills'] is True + + +@pytest.mark.parametrize('declaration', ['strategy(TITLE)', 'strategy("t", pyramiding=COUNT)', + 'strategy("t", "s", true, format.price, 2, scale.right, COUNT)']) +def test_every_nonliteral_argument_is_unresolved(declaration): + receipt = emitted_receipt(f'//@version=6\n{declaration}\nconst string TITLE = "t"\nconst int COUNT = 1\nstrategy.entry("entry", strategy.long)') + assert receipt['unresolved'] == ['title' if declaration == 'strategy(TITLE)' else 'pyramiding'] + + +@pytest.mark.parametrize('body', [ + 'float value = na\nif true\n symbol = "NASDAQ:MSFT"\n value := request.security(symbol, "D", close)', + 'var string symbol = syminfo.tickerid\nif bar_index == 5\n symbol := "NASDAQ:MSFT"\nvalue = request.security(symbol, "D", close)', + 'value = request.security(str.format("{0}:{1}", syminfo.prefix, syminfo.ticker), "D", close)', + 'inner() => request.security("NASDAQ:MSFT", "W", close)\nvalue = request.security(syminfo.tickerid, "D", inner())', + 'value = request.footprint(syminfo.tickerid, 100)', +]) +def test_runtime_unpinned_requests_survive_lowering(body): + receipt = emitted_receipt(f'//@version=6\nstrategy("unpinned")\n{body}\nif not na(value)\n strategy.entry("entry", strategy.long)') + requests = [request for request in receipt['requests'] if request['feed'] == 'unpinned'] + assert requests + for request in requests: + assert any(item.startswith(request['function'] + '[unpinned:') for item in receipt['unresolved']) + + +def test_lower_tf_records_foreign_symbol(): + receipt = emitted_receipt('//@version=6\nstrategy("lower")\nvalues = request.security_lower_tf("NASDAQ:MSFT", "1", close)\nif array.get(values, 0) > close\n strategy.entry("entry", strategy.long)') + assert all(request['symbol'] == 'NASDAQ:MSFT' for request in receipt['requests']) + + +@pytest.mark.parametrize('expression', ['barstate.isrealtime', 'timenow > time', + 'barstate.islast', 'barstate.islastconfirmedhistory', + 'last_bar_index == bar_index', 'last_bar_time == time']) +def test_realtime_approximations_are_named(expression): + receipt = emitted_receipt(f'//@version=6\nstrategy("phase")\nif {expression}\n strategy.entry("entry", strategy.long)') + assert any(expression.split()[0] in name for name in receipt['unresolved']) + + +def test_capabilities_collect_facts_in_one_walk(monkeypatch): + from pineforge_codegen.codegen import capabilities + original = capabilities.iter_ast_nodes + walks = [] + def counted(root): + walks.append(root) + yield from original(root) + monkeypatch.setattr(capabilities, 'iter_ast_nodes', counted) + emitted_receipt('//@version=6\nstrategy("walk")\nvarip int count = 0\nvalue = request.earnings("NASDAQ:MSFT")\nif value > count\n strategy.entry("entry", strategy.long)') + assert len(walks) == 1 + + +@pytest.mark.parametrize('declaration', ['calc_on_every_tick=true', 'process_orders_on_close=true']) +def test_receipt_runtime_buffer_protocol_and_reused_handle(declaration): + cpp = transpile(f'''//@version=6 +strategy("runtime receipt", {declaration}) +if bar_index % 3 == 0 + strategy.entry("entry", strategy.long) +''') + driver = r''' +#include +#include +#include +#include +std::string capability_receipt(void* strategy) { + size_t required = 0; + char error[32]{}; + assert(strategy_capabilities_receipt(strategy, nullptr, 0, &required, error, sizeof(error)) == PF_SETTINGS_BUFFER_TOO_SMALL); + std::vector output(required); + assert(strategy_capabilities_receipt(strategy, output.data(), output.size(), &required, error, sizeof(error)) == PF_SETTINGS_OK); + assert(error[0] == '\0' && output.back() == '\0' && strlen(output.data()) + 1 == required); + return output.data(); +} +int main() { + assert(strategy_capabilities_api_version() == 1); + auto strategy = strategy_create(nullptr); + auto fresh = strategy_create(nullptr); + const auto initial = capability_receipt(strategy); + assert(capability_receipt(fresh) == initial); + size_t required = 99; + char tiny[2] = "x"; + char error[8]{}; + assert(strategy_capabilities_receipt(nullptr, tiny, sizeof(tiny), &required, error, sizeof(error)) == PF_SETTINGS_INVALID_ARGUMENT); + assert(required == 0 && error[7] == '\0'); + assert(strategy_capabilities_receipt(strategy, tiny, sizeof(tiny), &required, error, sizeof(error)) == PF_SETTINGS_BUFFER_TOO_SMALL); + assert(tiny[0] == '\0' && required == initial.size() + 1); + std::vector bars; + for (int index = 0; index < 10; ++index) + bars.push_back({100, 101, 99, 100, 5, 1577836800000LL + index * 60000LL}); + for (int run = 0; run < 2; ++run) { + ReportC report{}; + run_backtest_full(strategy, bars.data(), static_cast(bars.size()), "1", "1", 0, 4, 1, &report); + report_free(&report); + assert(capability_receipt(strategy) == initial); + } + strategy_set_override(strategy, "process_orders_on_close", "false"); + assert(capability_receipt(strategy) == initial); + strategy_free(fresh); + strategy_free(strategy); + std::cout << initial; +} +''' + receipt = json.loads(run_emitted_tu(cpp, driver, opt='-O0')) + name = declaration.split('=')[0] + assert receipt['declarations'][name] is True