From ce4f4d2d43fddcfafa7f135b9b3650c6510b22b1 Mon Sep 17 00:00:00 2001 From: vntrevx <20063774+vntrevx@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:03:35 +0900 Subject: [PATCH 1/4] chore(roadmap): start M21-01 --- planning/roadmap-state.json | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/planning/roadmap-state.json b/planning/roadmap-state.json index c02dd7b..cce8fbc 100644 --- a/planning/roadmap-state.json +++ b/planning/roadmap-state.json @@ -1,10 +1,10 @@ { "schema_version": "1.0.0", "roadmap_id": "nfi-backtest-engine-post-v1.1.0", - "revision": 133, - "updated_at": "2026-08-11T02:31:11+09:00", + "revision": 134, + "updated_at": "2026-08-11T03:03:16+09:00", "acceptance_commands": "planning/acceptance-commands.json", - "active_task_id": null, + "active_task_id": "M21-01", "execution_policy": { "max_in_progress": 1, "selection": "lowest order pending task whose dependencies are completed", @@ -4804,7 +4804,7 @@ "order": 2101, "milestone": "M21", "title": "Define and compile signal-program-v1", - "status": "pending", + "status": "in_progress", "depends_on": [ "M20-05" ], @@ -4823,7 +4823,7 @@ "signal schema tests", "entry and exit mutation tests" ], - "started_at": null, + "started_at": "2026-08-11T03:03:16+09:00", "completed_at": null, "commit_sha": null, "evidence": [], @@ -8401,6 +8401,20 @@ "next_eligible_task": "M21-01", "next_task_manual_gate": false } + }, + { + "sequence": 181, + "timestamp": "2026-08-11T03:03:16+09:00", + "task_id": "M21-01", + "event": "task_started", + "details": { + "objective": "define and compile signal-program-v1 with exact dataframe assignment and source-order overwrite semantics", + "signal_number_execution_branches_allowed": false, + "unordered_assignment_evaluation_allowed": false, + "entry_and_exit_long_short_required": true, + "original_source_order_must_be_preserved": true, + "latest_upstream_commit": "897a1523391b8222ee711eba9714b59a3e77265a" + } } ] } From b2255b57fb47cec88c2d97308a77cfa5f891f9ac Mon Sep 17 00:00:00 2001 From: vntrevx <20063774+vntrevx@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:50:50 +0900 Subject: [PATCH 2/4] feat(vector): compile ordered signal mutations --- .../reference/signals/freqtrade-2026.5.1.json | 1 + .../strategies/SignalProgramContract.py | 22 ++ docs/native-signal-program.md | 45 +++ python/nfi_backtest_engine/cli.py | 12 + python/nfi_backtest_engine/commands/run.py | 18 + python/nfi_backtest_engine/generic_adapter.py | 2 +- .../schemas/signal-program-v1.schema.json | 256 ++++++++++++ python/nfi_backtest_engine/signal_fixture.py | 227 +++++++++++ .../signal_program/__init__.py | 18 + .../signal_program/compiler.py | 366 ++++++++++++++++++ .../signal_program/runtime.py | 281 ++++++++++++++ .../signal_program/validation.py | 246 ++++++++++++ python/nfi_backtest_engine/specs.py | 1 + python/nfi_backtest_engine/vector_worker.py | 44 ++- python/nfi_backtest_engine/x7/vectors.py | 2 +- rust/crates/nfi-sim-core/src/nfi/exit.rs | 18 +- .../nfi-sim-core/src/nfi/legacy_grind.rs | 2 +- rust/crates/nfi-vector-io/src/values.rs | 32 +- scripts/generate_signal_fixture.py | 21 + tests/test_generic_adapter.py | 22 ++ tests/test_signal_fixture.py | 47 +++ tests/test_signal_program.py | 191 +++++++++ tests/test_vector_worker.py | 66 ++++ 23 files changed, 1916 insertions(+), 24 deletions(-) create mode 100644 benchmarks/reference/signals/freqtrade-2026.5.1.json create mode 100644 benchmarks/reference/strategies/SignalProgramContract.py create mode 100644 docs/native-signal-program.md create mode 100644 python/nfi_backtest_engine/schemas/signal-program-v1.schema.json create mode 100644 python/nfi_backtest_engine/signal_fixture.py create mode 100644 python/nfi_backtest_engine/signal_program/__init__.py create mode 100644 python/nfi_backtest_engine/signal_program/compiler.py create mode 100644 python/nfi_backtest_engine/signal_program/runtime.py create mode 100644 python/nfi_backtest_engine/signal_program/validation.py create mode 100644 scripts/generate_signal_fixture.py create mode 100644 tests/test_signal_fixture.py create mode 100644 tests/test_signal_program.py diff --git a/benchmarks/reference/signals/freqtrade-2026.5.1.json b/benchmarks/reference/signals/freqtrade-2026.5.1.json new file mode 100644 index 0000000..80abe08 --- /dev/null +++ b/benchmarks/reference/signals/freqtrade-2026.5.1.json @@ -0,0 +1 @@ +{"call_order":["advise_entry","advise_exit"],"fingerprint":"4c3150439cc45b282919562bce0e9273ab87a9b14022413b34133ac8fccf3249","input":{"columns":["score","exit_mask"],"dtypes":{"exit_mask":"boolean","score":"float64"},"rows":[[-2.0,null],[-0.5,false],[0.5,true],[1.5,true],[2.0,false],[2.5,true],[null,false],[0.0,null]]},"output":{"columns":["score","exit_mask","enter_tag","enter_long","enter_short","exit_tag","exit_long","exit_short"],"dtypes":{"enter_long":"int64","enter_short":"int64","enter_tag":"str","exit_long":"int64","exit_mask":"boolean","exit_short":"int64","exit_tag":"str","score":"float64"},"rows":[[-2.0,null,"",0,1,"",0,0],[-0.5,false,"",0,1,"",0,0],[0.5,true,"",1,0,"",0,1],[1.5,true,"",1,0,"",0,1],[2.0,false,"",0,0,"",1,0],[2.5,true,"",0,0,"",1,1],[null,false,"",0,0,"",0,0],[0.0,null,"",0,0,"",0,0]]},"schema_version":"freqtrade-signal-fixture-v1","source":{"commit":"6fa470939cc74bf0672e0e348a4d9b293072e43c","interface":"freqtrade/strategy/interface.py","interface_sha256":"93ddb2f5579acd7a20d489174ffb68cd191428ff996d291b33be81d97fa9bf66","method_sha256":{"advise_entry":"768ac9a3356d6a99b67334932814c727ed29649dd9d5d5220ac1412ee26dba83","advise_exit":"074403ac03325690972e8899878f08e5ee50e20dbfc72115e59b116d2de2caa9"},"pandas":"3.0.3","strategy":"benchmarks/reference/strategies/SignalProgramContract.py","strategy_sha256":"d3e1316e336273490b2da8dcfefc5a8ecca64046250a9225971910d78a3e1746","version":"2026.5.1"}} diff --git a/benchmarks/reference/strategies/SignalProgramContract.py b/benchmarks/reference/strategies/SignalProgramContract.py new file mode 100644 index 0000000..fae0446 --- /dev/null +++ b/benchmarks/reference/strategies/SignalProgramContract.py @@ -0,0 +1,22 @@ +from freqtrade.strategy import IStrategy + + +class SignalProgramContract(IStrategy): + timeframe = "5m" + + def populate_entry_trend(self, dataframe, metadata): + dataframe.loc[:, ["enter_long", "enter_short"]] = (0, 0) + positive = dataframe["score"] > 0 + dataframe.loc[positive, "enter_long"] = 1 + dataframe.loc[dataframe["score"] >= 2, "enter_long"] = 0 + dataframe["enter_short"] = (dataframe["score"] < 0).astype(int) + return dataframe + + def populate_exit_trend(self, dataframe, metadata): + dataframe.loc[:, ["exit_long", "exit_short"]] = 0 + dataframe.loc[ + (dataframe["enter_long"] == 0) & (dataframe["score"] > 1), + "exit_long", + ] = 1 + dataframe.loc[dataframe["exit_mask"], "exit_short"] = 1 + return dataframe diff --git a/docs/native-signal-program.md b/docs/native-signal-program.md new file mode 100644 index 0000000..f6c2c8d --- /dev/null +++ b/docs/native-signal-program.md @@ -0,0 +1,45 @@ +# Native Signal Program + +`signal-program-v1` is the versioned contract between NFI's Python signal source and the +Native vector runtime. It compiles source; it does not execute strategy Python. + +## Exact boundary + +The compiler processes `populate_entry_trend` and then `populate_exit_trend`, matching +Freqtrade 2026.5.1. It records every full-column or masked `.loc` write as an ordered +`frame-write` node. Each node depends on the prior DataFrame version, so overlapping masks, +read-after-write, and last-write-wins behavior cannot be reordered by an optimizer. + +M21-01 owns only the four raw numeric columns: + +- `enter_long` +- `enter_short` +- `exit_long` +- `exit_short` + +Signal values remain raw through this stage. Freqtrade opens a new position only when the +corresponding value is exactly numeric `1`; arbitrary nonzero values are not promoted to +orders. Same-candle long/short/exit conflicts are resolved later by the Freqtrade-compatible +simulation kernel, never by the compiler. + +Tag initialization, literal and compound tag generation, and original whitespace are the +separate M21-02 contract. A tag write encountered by the M21-01 compiler therefore fails +closed instead of being discarded or guessed. + +## Evidence and regeneration + +The committed oracle at +`benchmarks/reference/signals/freqtrade-2026.5.1.json` executes the exact pinned +`IStrategy.advise_entry` and `advise_exit` source around the compact contract strategy. It +covers entry-to-exit dependencies, nullable Boolean masks, overlapping writes, all four +directions, and raw dtype preservation. + +```bash +uv run python scripts/generate_signal_fixture.py +uv run pytest -q tests/test_signal_program.py tests/test_signal_fixture.py +nfi-bte strategy signal-program strategy.py --class Strategy --output signal-program.json +``` + +Unsupported masks, assignments, phase-crossing writes, and dynamic behavior stop with a +source location. Signal numbers, strategy names, pairs, timeranges, and source hashes are +never runtime branches; the source hash is evidence identity only. diff --git a/python/nfi_backtest_engine/cli.py b/python/nfi_backtest_engine/cli.py index 1c10c0a..3f307f3 100644 --- a/python/nfi_backtest_engine/cli.py +++ b/python/nfi_backtest_engine/cli.py @@ -692,6 +692,18 @@ def build_parser() -> argparse.ArgumentParser: strategy_indicator_program.add_argument("source", type=Path) strategy_indicator_program.add_argument("--class", dest="class_name") strategy_indicator_program.add_argument("--output", "-o", type=Path, required=True) + strategy_signal_program = strategy_commands.add_parser( + "signal-program", + help="compile ordered entry and exit mutations into signal-program-v1", + ) + strategy_signal_program.add_argument("source", type=Path) + strategy_signal_program.add_argument("--class", dest="class_name") + strategy_signal_program.add_argument( + "--trading-mode", + choices=("spot", "futures"), + default="spot", + ) + strategy_signal_program.add_argument("--output", "-o", type=Path, required=True) strategy_callback_ir = strategy_commands.add_parser( "callback-ir", help="compile source-ordered callback routes, tags, and data dependencies", diff --git a/python/nfi_backtest_engine/commands/run.py b/python/nfi_backtest_engine/commands/run.py index f6a9d4e..fe390b1 100644 --- a/python/nfi_backtest_engine/commands/run.py +++ b/python/nfi_backtest_engine/commands/run.py @@ -311,6 +311,24 @@ def _execute_strategy(args: argparse.Namespace) -> int: ) print(f"indicator program report: {args.output}") return 0 + if args.strategy_command == "signal-program": + from ..signal_program import compile_signal_program + + program = compile_signal_program( + args.source, + class_name=args.class_name, + trading_mode=args.trading_mode, + ) + write_json(args.output, program) + print( + "signal program: " + f"class={program['selected_class']}, " + f"mode={program['compile_context']['trading_mode']}, " + f"nodes={len(program['nodes'])}, " + f"mutations={len(program['mutation_nodes'])}" + ) + print(f"signal program report: {args.output}") + return 0 if args.strategy_command == "callback-ir": from ..callback_source_ir import compile_callback_source_ir diff --git a/python/nfi_backtest_engine/generic_adapter.py b/python/nfi_backtest_engine/generic_adapter.py index 8bd0077..066566e 100644 --- a/python/nfi_backtest_engine/generic_adapter.py +++ b/python/nfi_backtest_engine/generic_adapter.py @@ -778,7 +778,7 @@ def _surface_trade( def _enabled(value: Any) -> bool: - return not pd.isna(value) and float(value) != 0.0 + return not pd.isna(value) and bool(value == 1) def _optional_text(value: Any) -> str | None: diff --git a/python/nfi_backtest_engine/schemas/signal-program-v1.schema.json b/python/nfi_backtest_engine/schemas/signal-program-v1.schema.json new file mode 100644 index 0000000..43c63d8 --- /dev/null +++ b/python/nfi_backtest_engine/schemas/signal-program-v1.schema.json @@ -0,0 +1,256 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/vntrevx/NFI_BackTestEngine/schemas/signal-program-v1.schema.json", + "title": "Signal Program v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "source", + "selected_class", + "compile_context", + "entrypoints", + "functions", + "nodes", + "signal_outputs", + "required_input_columns", + "mutation_nodes", + "opcodes", + "max_lookback", + "source_map", + "fingerprint" + ], + "properties": { + "schema_version": {"const": "signal-program-v1"}, + "source": {"$ref": "#/$defs/source"}, + "selected_class": {"$ref": "#/$defs/non_empty_string"}, + "compile_context": {"$ref": "#/$defs/compile_context"}, + "entrypoints": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + {"$ref": "#/$defs/entry_entrypoint"}, + {"$ref": "#/$defs/exit_entrypoint"} + ], + "items": false + }, + "functions": { + "type": "array", + "minItems": 2, + "items": {"$ref": "#/$defs/function"} + }, + "nodes": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/node"} + }, + "signal_outputs": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": {"$ref": "#/$defs/signal_output"} + }, + "required_input_columns": {"$ref": "#/$defs/strings"}, + "mutation_nodes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/node_id"} + }, + "opcodes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/opcode"} + }, + "max_lookback": {"$ref": "#/$defs/lookback"}, + "source_map": { + "type": "object", + "propertyNames": {"pattern": "^n[1-9][0-9]*$"}, + "additionalProperties": {"$ref": "#/$defs/location"} + }, + "fingerprint": {"$ref": "#/$defs/sha256"} + }, + "$defs": { + "non_empty_string": {"type": "string", "minLength": 1}, + "nullable_string": { + "oneOf": [ + {"type": "null"}, + {"$ref": "#/$defs/non_empty_string"} + ] + }, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "function_id": {"type": "string", "pattern": "^f[1-9][0-9]*$"}, + "node_id": {"type": "string", "pattern": "^n[1-9][0-9]*$"}, + "strings": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string"} + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": {"$ref": "#/$defs/non_empty_string"}, + "sha256": {"$ref": "#/$defs/sha256"} + } + }, + "compile_context": { + "type": "object", + "additionalProperties": false, + "required": ["run_mode", "trading_mode"], + "properties": { + "run_mode": {"const": "backtest"}, + "trading_mode": {"enum": ["spot", "futures"]} + } + }, + "entry_entrypoint": { + "type": "object", + "additionalProperties": false, + "required": ["phase", "function"], + "properties": { + "phase": {"const": "entry"}, + "function": {"const": "f1"} + } + }, + "exit_entrypoint": { + "type": "object", + "additionalProperties": false, + "required": ["phase", "function"], + "properties": { + "phase": {"const": "exit"}, + "function": {"const": "f2"} + } + }, + "value_type": { + "enum": [ + "dataframe", + "metadata", + "dynamic", + "null", + "bool-scalar", + "int-scalar", + "f64-scalar", + "string-scalar", + "json-scalar", + "bool-column", + "f64-column", + "timestamp-column" + ] + }, + "opcode": { + "enum": [ + "parameter", + "literal", + "column-read", + "metadata-read", + "frame-write", + "binary", + "compare", + "logical", + "unary", + "select", + "array-call", + "scalar-call", + "cast", + "shift", + "function-call", + "instrumentation", + "return" + ] + }, + "lookback": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "candles", "expression", "causal"], + "properties": { + "kind": { + "enum": ["finite", "recursive", "library-defined", "function-defined", "mixed"] + }, + "candles": {"type": ["integer", "null"], "minimum": 0}, + "expression": {"$ref": "#/$defs/nullable_string"}, + "causal": {"const": true} + } + }, + "location": { + "type": "object", + "additionalProperties": false, + "required": ["path", "line", "column", "end_line", "end_column"], + "properties": { + "path": {"const": "strategy.py"}, + "line": {"type": "integer", "minimum": 1}, + "column": {"type": "integer", "minimum": 0}, + "end_line": {"type": "integer", "minimum": 1}, + "end_column": {"type": "integer", "minimum": 0} + } + }, + "parameter": { + "type": "object", + "additionalProperties": false, + "required": ["name", "node", "value_type"], + "properties": { + "name": {"$ref": "#/$defs/non_empty_string"}, + "node": {"$ref": "#/$defs/node_id"}, + "value_type": {"$ref": "#/$defs/value_type"} + } + }, + "function": { + "type": "object", + "additionalProperties": false, + "required": ["id", "source_name", "kind", "parameters", "node_ids", "return_node"], + "properties": { + "id": {"$ref": "#/$defs/function_id"}, + "source_name": {"$ref": "#/$defs/non_empty_string"}, + "kind": {"enum": ["entrypoint-entry", "entrypoint-exit", "helper"]}, + "parameters": {"type": "array", "items": {"$ref": "#/$defs/parameter"}}, + "node_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/node_id"} + }, + "return_node": {"$ref": "#/$defs/node_id"} + } + }, + "node": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "function", + "source_order", + "op", + "value_type", + "inputs", + "parameters", + "lookback" + ], + "properties": { + "id": {"$ref": "#/$defs/node_id"}, + "function": {"$ref": "#/$defs/function_id"}, + "source_order": {"type": "integer", "minimum": 0}, + "op": {"$ref": "#/$defs/opcode"}, + "value_type": {"$ref": "#/$defs/value_type"}, + "inputs": { + "type": "array", + "items": {"$ref": "#/$defs/node_id"} + }, + "parameters": {"type": "object"}, + "lookback": {"$ref": "#/$defs/lookback"} + } + }, + "signal_output": { + "type": "object", + "additionalProperties": false, + "required": ["column", "phase", "side", "final_mutation"], + "properties": { + "column": {"enum": ["enter_long", "enter_short", "exit_long", "exit_short"]}, + "phase": {"enum": ["entry", "exit"]}, + "side": {"enum": ["long", "short"]}, + "final_mutation": {"$ref": "#/$defs/node_id"} + } + } + } +} diff --git a/python/nfi_backtest_engine/signal_fixture.py b/python/nfi_backtest_engine/signal_fixture.py new file mode 100644 index 0000000..8592131 --- /dev/null +++ b/python/nfi_backtest_engine/signal_fixture.py @@ -0,0 +1,227 @@ +"""Generate deterministic signal evidence from pinned Freqtrade wrappers.""" + +from __future__ import annotations + +import ast +import hashlib +import json +import subprocess +from collections.abc import Mapping +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import numpy as np +import pandas as pd + +PINNED_SOURCE = Path(".nfi/roadmap-acceptance/M20-05/freqtrade-2026.5.1") +CONTRACT_PATH = Path("benchmarks/reference/strategies/SignalProgramContract.py") +FIXTURE_PATH = Path("benchmarks/reference/signals/freqtrade-2026.5.1.json") +_METHODS = ("advise_entry", "advise_exit") + + +def generate_fixture(source_root: Path | None = None) -> dict[str, object]: + """Execute the exact pinned wrapper AST around the committed strategy contract.""" + repository = _repository_root() + root = source_root or repository / PINNED_SOURCE + interface = root / "freqtrade/strategy/interface.py" + contract = repository / CONTRACT_PATH + strategy, method_hashes = _load_strategy(interface, contract) + input_frame = _input_frame() + entry = strategy.advise_entry(input_frame.copy(deep=True), {"pair": "ETH/USDT"}) + output = strategy.advise_exit(entry, {"pair": "ETH/USDT"}) + fixture: dict[str, object] = { + "schema_version": "freqtrade-signal-fixture-v1", + "source": { + "version": _source_version(root), + "commit": _source_commit(root), + "interface": "freqtrade/strategy/interface.py", + "interface_sha256": _sha256_file(interface), + "method_sha256": method_hashes, + "strategy": str(CONTRACT_PATH), + "strategy_sha256": _sha256_file(contract), + "pandas": pd.__version__, + }, + "call_order": ["advise_entry", "advise_exit"], + "input": _encode_frame(input_frame), + "output": _encode_frame(output), + } + fixture["fingerprint"] = canonical_sha256(fixture) + return fixture + + +def write_fixture(destination: Path, source_root: Path | None = None) -> dict[str, object]: + """Generate and persist canonical fixture evidence.""" + fixture = generate_fixture(source_root) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(_canonical_json(fixture) + "\n", encoding="utf-8") + return fixture + + +def canonical_sha256(document: Mapping[str, object]) -> str: + """Hash fixture contents without its self-referential fingerprint.""" + identity = {key: value for key, value in document.items() if key != "fingerprint"} + return hashlib.sha256(_canonical_json(identity).encode("utf-8")).hexdigest() + + +def decode_frame(document: Mapping[str, object]) -> pd.DataFrame: + """Decode the compact fixture frame with its nullable dtypes.""" + raw_columns = document["columns"] + if not isinstance(raw_columns, list): + raise TypeError("signal fixture columns must be a list") + columns = [str(value) for value in raw_columns] + rows = document["rows"] + if not isinstance(rows, list): + raise TypeError("signal fixture rows must be a list") + frame = pd.DataFrame(rows, columns=columns) + dtypes = document["dtypes"] + if not isinstance(dtypes, Mapping): + raise TypeError("signal fixture dtypes must be an object") + for column in columns: + dtype = dtypes[column] + series = cast(pd.Series, frame[column]) + if dtype == "boolean": + frame[column] = pd.array(series.tolist(), dtype="boolean") + elif dtype == "float64": + numeric = cast(pd.Series, pd.to_numeric(series, errors="raise")) + frame[column] = numeric.astype("float64") + elif dtype == "int64": + numeric = cast(pd.Series, pd.to_numeric(series, errors="raise")) + frame[column] = numeric.astype("int64") + return frame + + +def encode_signal_columns(frame: pd.DataFrame) -> dict[str, object]: + """Encode only M21-01's four raw signal outputs.""" + columns = ["enter_long", "enter_short", "exit_long", "exit_short"] + return _encode_frame(frame.loc[:, columns]) + + +def _load_strategy(interface: Path, contract: Path) -> tuple[Any, dict[str, str]]: + interface_source = interface.read_text(encoding="utf-8") + interface_tree = ast.parse(interface_source, filename=str(interface)) + interface_class = next( + node + for node in interface_tree.body + if isinstance(node, ast.ClassDef) and node.name == "IStrategy" + ) + wrapper_methods = [ + node + for node in interface_class.body + if isinstance(node, ast.FunctionDef) and node.name in _METHODS + ] + if [node.name for node in wrapper_methods] != list(_METHODS): + raise RuntimeError("pinned Freqtrade signal wrappers changed") + + contract_source = contract.read_text(encoding="utf-8") + contract_tree = ast.parse(contract_source, filename=str(contract)) + contract_class = next(node for node in contract_tree.body if isinstance(node, ast.ClassDef)) + populate_methods = [ + node + for node in contract_class.body + if isinstance(node, ast.FunctionDef) + and node.name in {"populate_entry_trend", "populate_exit_trend"} + ] + if sorted(node.name for node in populate_methods) != sorted( + ("populate_entry_trend", "populate_exit_trend") + ): + raise RuntimeError("signal fixture strategy contract is incomplete") + method_hashes = { + node.name: hashlib.sha256( + (ast.get_source_segment(interface_source, node) or "").encode("utf-8") + ).hexdigest() + for node in wrapper_methods + } + for node in [*wrapper_methods, *populate_methods]: + node.decorator_list = [] + node.returns = None + for argument in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]: + argument.annotation = None + generated = ast.ClassDef( + name="PinnedSignalStrategy", + bases=[], + keywords=[], + body=[*wrapper_methods, *populate_methods], + decorator_list=[], + type_params=[], + ) + module = ast.fix_missing_locations(ast.Module(body=[generated], type_ignores=[])) + namespace: dict[str, object] = { + "logger": SimpleNamespace(debug=lambda *args, **kwargs: None), + } + exec(compile(module, str(interface), "exec"), namespace) # noqa: S102 - pinned source oracle + strategy_class = namespace["PinnedSignalStrategy"] + if not isinstance(strategy_class, type): + raise TypeError("pinned signal strategy did not compile") + return strategy_class(), method_hashes + + +def _input_frame() -> pd.DataFrame: + return pd.DataFrame( + { + "score": [-2.0, -0.5, 0.5, 1.5, 2.0, 2.5, np.nan, 0.0], + "exit_mask": pd.array( + [pd.NA, False, True, True, False, True, False, pd.NA], + dtype="boolean", + ), + } + ) + + +def _encode_frame(frame: pd.DataFrame) -> dict[str, object]: + columns = [str(column) for column in frame.columns] + return { + "columns": columns, + "dtypes": {column: str(frame[column].dtype) for column in columns}, + "rows": [[_json_value(value) for value in row] for row in frame.itertuples(index=False)], + } + + +def _json_value(value: object) -> object: + if value is None or value is pd.NA: + return None + if isinstance(value, np.generic): + value = value.item() + if isinstance(value, float) and np.isnan(value): + return None + if isinstance(value, bool | int | float | str): + return value + raise TypeError(f"signal fixture value is not JSON-safe: {type(value).__name__}") + + +def _source_version(root: Path) -> str: + version_file = root / "freqtrade/__init__.py" + tree = ast.parse(version_file.read_text(encoding="utf-8")) + for node in tree.body: + if ( + isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "__version__" + for target in node.targets + ) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ): + return node.value.value + raise RuntimeError("pinned Freqtrade version is missing") + + +def _source_commit(root: Path) -> str: + return subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _sha256_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _canonical_json(document: Mapping[str, object]) -> str: + return json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _repository_root() -> Path: + return Path(__file__).resolve().parents[2] diff --git a/python/nfi_backtest_engine/signal_program/__init__.py b/python/nfi_backtest_engine/signal_program/__init__.py new file mode 100644 index 0000000..23dfb96 --- /dev/null +++ b/python/nfi_backtest_engine/signal_program/__init__.py @@ -0,0 +1,18 @@ +"""Compile and execute the source-ordered Native signal contract.""" + +from .compiler import ( + SIGNAL_PROGRAM_VERSION, + SignalProgramCompileError, + compile_signal_program, +) +from .runtime import SignalProgramExecutionError, execute_signal_program +from .validation import validate_signal_program + +__all__ = [ + "SIGNAL_PROGRAM_VERSION", + "SignalProgramCompileError", + "SignalProgramExecutionError", + "compile_signal_program", + "execute_signal_program", + "validate_signal_program", +] diff --git a/python/nfi_backtest_engine/signal_program/compiler.py b/python/nfi_backtest_engine/signal_program/compiler.py new file mode 100644 index 0000000..7f0e69a --- /dev/null +++ b/python/nfi_backtest_engine/signal_program/compiler.py @@ -0,0 +1,366 @@ +"""Static compiler for source-ordered Freqtrade signal mutations.""" + +from __future__ import annotations + +import ast +import hashlib +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any, Never + +from ..errors import StrategyAnalysisError +from ..indicator_program import _Compiler as _VectorExpressionCompiler +from ..strategy_ir import analyze_strategy +from .validation import ( + SIGNAL_COLUMNS, + SIGNAL_PHASES, + SIGNAL_SIDES, + fingerprint_program, + merge_lookbacks, + validate_signal_program, +) + +SIGNAL_PROGRAM_VERSION = "signal-program-v1" +_NUMERIC_VALUE_TYPES = { + "bool-scalar", + "int-scalar", + "f64-scalar", + "bool-column", + "f64-column", +} + + +class SignalProgramCompileError(StrategyAnalysisError): + """Signal source cannot be represented exactly by signal-program-v1.""" + + +def compile_signal_program( + source: str | Path, + *, + class_name: str | None = None, + trading_mode: str = "spot", +) -> dict[str, Any]: + """Compile entry and exit DataFrame mutations without executing strategy Python.""" + if trading_mode not in {"spot", "futures"}: + raise SignalProgramCompileError(f"unsupported signal trading mode: {trading_mode}") + path = Path(source).resolve() + analysis = analyze_strategy(path, class_name=class_name) + strategy = _selected_strategy(analysis) + source_bytes = path.read_bytes() + source_sha = hashlib.sha256(source_bytes).hexdigest() + if source_sha != analysis["source"]["sha256"]: + raise SignalProgramCompileError("signal source changed after static analysis") + try: + tree = ast.parse(source_bytes.decode("utf-8"), filename=str(path), type_comments=True) + except (SyntaxError, UnicodeDecodeError) as exc: # pragma: no cover - analyzed above + raise SignalProgramCompileError("signal source no longer parses") from exc + class_node = next( + ( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == strategy["name"] + ), + None, + ) + if class_node is None: # pragma: no cover - analyze_strategy selected it + raise SignalProgramCompileError("selected strategy class disappeared") + methods = { + node.name: node + for node in class_node.body + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + } + for method_name in ("populate_entry_trend", "populate_exit_trend"): + method = methods.get(method_name) + if method is None: + raise SignalProgramCompileError(f"strategy does not define {method_name}") + if isinstance(method, ast.AsyncFunctionDef): + _unsupported(method, "async signal entrypoint") + + constants = strategy.get("constants", {}) + compiler = _SignalCompiler( + path=path, + methods=methods, + class_constants=constants if isinstance(constants, Mapping) else {}, + ) + # Reserve the two public IDs before helper discovery so their identity never + # depends on how many helper functions one phase happens to call. + compiler.method_ids.update({"populate_entry_trend": "f1", "populate_exit_trend": "f2"}) + try: + compiler.current_phase = "entry" + entry_id = compiler.compile_method( + "populate_entry_trend", + kind="entrypoint-entry", + ) + compiler.current_phase = "exit" + exit_id = compiler.compile_method( + "populate_exit_trend", + kind="entrypoint-exit", + ) + except SignalProgramCompileError: + raise + except StrategyAnalysisError as exc: + raise SignalProgramCompileError(str(exc)) from exc + + program: dict[str, Any] = { + "schema_version": SIGNAL_PROGRAM_VERSION, + "source": {"path": str(path), "sha256": source_sha}, + "selected_class": strategy["name"], + "compile_context": {"run_mode": "backtest", "trading_mode": trading_mode}, + "entrypoints": [ + {"phase": "entry", "function": entry_id}, + {"phase": "exit", "function": exit_id}, + ], + "functions": sorted(compiler.functions, key=_numeric_record_id), + "nodes": compiler.nodes, + "signal_outputs": [ + { + "column": column, + "phase": SIGNAL_PHASES[column], + "side": SIGNAL_SIDES[column], + "final_mutation": compiler.final_mutations[column], + } + for column in SIGNAL_COLUMNS + if column in compiler.final_mutations + ], + "required_input_columns": sorted(compiler.required_input_columns), + "mutation_nodes": compiler.mutation_nodes, + "opcodes": sorted(compiler.opcodes), + "max_lookback": merge_lookbacks(compiler.nodes), + "source_map": compiler.source_map, + } + program["fingerprint"] = fingerprint_program(program) + validate_signal_program(program) + return program + + +class _SignalCompiler(_VectorExpressionCompiler): + """Reuse the vector expression DAG while replacing whole-column writes.""" + + def __init__( + self, + *, + path: Path, + methods: Mapping[str, ast.FunctionDef | ast.AsyncFunctionDef], + class_constants: Mapping[str, Any], + ) -> None: + super().__init__(path=path, methods=methods, class_constants=class_constants) + self.current_phase = "entry" + self.mutation_nodes: list[str] = [] + self.final_mutations: dict[str, str] = {} + + def statement(self, node: ast.stmt) -> None: + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if self._is_loc_target(target): + assert isinstance(target, ast.Subscript) + self._loc_write(target, node.value, node) + return + super().statement(node) + + def column_write(self, target: ast.Subscript, value_node: ast.expr, node: ast.AST) -> None: + if not isinstance(target.value, ast.Name): + self.unsupported(target, "nested dataframe write") + dataframe = self.bindings.get(target.value.id) + if not isinstance(dataframe, str) or self.node_types[dataframe] != "dataframe": + self.unsupported(target, "write target is not a dataframe") + column = _literal_string(target.slice) + if column is None: + self.unsupported(target, "dynamic signal output column") + self._require_signal_columns(target, [column]) + value = self.expression(value_node) + self._require_numeric_value(value_node, value) + written = self._emit_write( + node, + dataframe=dataframe, + mask=None, + values=[value], + columns=[column], + mode="column", + assignment="column-values", + ) + self.bindings[target.value.id] = written + + def method_call(self, node: ast.Call, callable_name: str) -> str: + if isinstance(node.func, ast.Attribute) and node.func.attr == "astype": + if len(node.args) != 1 or node.keywords: + self.unsupported(node, "signal astype signature") + target = _cast_target(node.args[0]) + if target is None: + self.unsupported(node.args[0], "dynamic signal astype target") + base = self.expression(node.func.value) + return self.emit( + node, + "cast", + self.node_types[base], + inputs=[base], + parameters={"target": target}, + lookback=self.lookback(base), + ) + return super().method_call(node, callable_name) + + def unsupported(self, node: ast.AST, description: str) -> Never: + _unsupported(node, description) + + @staticmethod + def _is_loc_target(target: ast.expr) -> bool: + return ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Attribute) + and target.value.attr == "loc" + ) + + def _loc_write(self, target: ast.Subscript, value_node: ast.expr, node: ast.AST) -> None: + assert isinstance(target.value, ast.Attribute) + owner = target.value.value + if not isinstance(owner, ast.Name): + self.unsupported(owner, "nested signal loc write") + dataframe = self.bindings.get(owner.id) + if not isinstance(dataframe, str) or self.node_types[dataframe] != "dataframe": + self.unsupported(owner, "loc write target is not a dataframe") + if not isinstance(target.slice, ast.Tuple) or len(target.slice.elts) != 2: + self.unsupported(target, "signal loc selector") + rows_node, columns_node = target.slice.elts + columns = _literal_columns(columns_node) + if columns is None or not columns: + self.unsupported(columns_node, "dynamic signal loc columns") + if len(set(columns)) != len(columns): + self.unsupported(columns_node, "duplicate signal loc columns") + self._require_signal_columns(columns_node, columns) + + mask: str | None = None + if not _is_full_slice(rows_node): + mask = self.expression(rows_node) + if self.node_types[mask] not in {"bool-scalar", "bool-column", "f64-column"}: + self.unsupported(rows_node, "non-boolean signal loc mask") + + assignment = "column-values" + value_nodes: Sequence[ast.expr] + if len(columns) > 1 and isinstance(value_node, ast.Tuple | ast.List): + if len(value_node.elts) != len(columns): + self.unsupported(value_node, "signal loc value arity") + value_nodes = value_node.elts + elif len(columns) > 1: + assignment = "scalar-broadcast" + value_nodes = [value_node] + else: + value_nodes = [value_node] + values = [self.expression(value) for value in value_nodes] + for source_node, value in zip(value_nodes, values, strict=True): + self._require_numeric_value(source_node, value) + written = self._emit_write( + node, + dataframe=dataframe, + mask=mask, + values=values, + columns=columns, + mode="loc", + assignment=assignment, + ) + self.bindings[owner.id] = written + + def _emit_write( + self, + node: ast.AST, + *, + dataframe: str, + mask: str | None, + values: Sequence[str], + columns: Sequence[str], + mode: str, + assignment: str, + ) -> str: + inputs = [dataframe] + if mask is not None: + inputs.append(mask) + inputs.extend(values) + written = self.emit( + node, + "frame-write", + "dataframe", + inputs=inputs, + parameters={ + "rows": "all" if mask is None else "mask", + "columns": list(columns), + "mode": mode, + "assignment": assignment, + }, + lookback=self.merged_lookback(inputs), + ) + self.mutation_nodes.append(written) + for column in columns: + self.produced_columns.add(column) + self.final_mutations[column] = written + return written + + def _require_signal_columns(self, node: ast.AST, columns: Sequence[str]) -> None: + for column in columns: + if column not in SIGNAL_COLUMNS: + if column in {"enter_tag", "exit_tag"}: + self.unsupported(node, "tag mutation before tag-program lowering") + self.unsupported(node, f"non-signal dataframe output {column!r}") + if SIGNAL_PHASES[column] != self.current_phase: + self.unsupported( + node, + f"{column} mutation during the {self.current_phase} phase", + ) + + def _require_numeric_value(self, node: ast.AST, value: str) -> None: + if self.node_types[value] not in _NUMERIC_VALUE_TYPES: + self.unsupported(node, "non-numeric signal assignment value") + + +def _selected_strategy(analysis: dict[str, Any]) -> dict[str, Any]: + errors = [item for item in analysis["diagnostics"] if item["severity"] == "error"] + if errors: + first = errors[0] + location = first["location"] + raise SignalProgramCompileError( + f"{location['path']}:{location['line']}:{location['column']}: " + f"{first['code']}: {first['message']}" + ) + if len(analysis["strategies"]) != 1: + raise SignalProgramCompileError("signal program compilation requires one selected strategy") + return analysis["strategies"][0] + + +def _literal_string(node: ast.expr) -> str | None: + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def _literal_columns(node: ast.expr) -> list[str] | None: + scalar = _literal_string(node) + if scalar is not None: + return [scalar] + if not isinstance(node, ast.Tuple | ast.List): + return None + result = [_literal_string(item) for item in node.elts] + return None if any(item is None for item in result) else [str(item) for item in result] + + +def _is_full_slice(node: ast.expr) -> bool: + return ( + isinstance(node, ast.Slice) + and node.lower is None + and node.upper is None + and node.step is None + ) + + +def _cast_target(node: ast.expr) -> str | None: + if isinstance(node, ast.Name) and node.id in {"bool", "float", "int"}: + return node.id + if isinstance(node, ast.Constant) and node.value in {"bool", "float", "int"}: + return str(node.value) + return None + + +def _numeric_record_id(record: Mapping[str, Any]) -> int: + return int(str(record["id"])[1:]) + + +def _unsupported(node: ast.AST, description: str) -> Never: + line = getattr(node, "lineno", 1) + column = getattr(node, "col_offset", 0) + raise SignalProgramCompileError( + f"strategy.py:{line}:{column}: signal-program-v1 does not support {description}" + ) diff --git a/python/nfi_backtest_engine/signal_program/runtime.py b/python/nfi_backtest_engine/signal_program/runtime.py new file mode 100644 index 0000000..25a464b --- /dev/null +++ b/python/nfi_backtest_engine/signal_program/runtime.py @@ -0,0 +1,281 @@ +"""Independent Python reference executor for signal-program-v1.""" + +from __future__ import annotations + +import operator +from collections.abc import Mapping, Sequence +from typing import Any + +import numpy as np +import pandas as pd +from pandas.api.types import is_bool_dtype + +from ..errors import StrategyAnalysisError +from .validation import validate_signal_program + + +class SignalProgramExecutionError(StrategyAnalysisError): + """A validated signal program cannot execute with the supplied frame.""" + + +def execute_signal_program( + program: Mapping[str, Any], + dataframe: pd.DataFrame, + *, + metadata: Mapping[str, Any] | None = None, +) -> pd.DataFrame: + """Execute raw entry then exit mutations while preserving Pandas null semantics.""" + validate_signal_program(program) + runtime = _Runtime(program) + frame = dataframe.copy(deep=True) + metadata_value = dict(metadata or {}) + for entrypoint in program["entrypoints"]: + value = runtime.function(entrypoint["function"], [frame, metadata_value]) + if not isinstance(value, pd.DataFrame): + raise SignalProgramExecutionError( + f"signal {entrypoint['phase']} entrypoint did not return a DataFrame" + ) + frame = value + return frame + + +class _Runtime: + def __init__(self, program: Mapping[str, Any]) -> None: + self.functions = {item["id"]: item for item in program["functions"]} + self.nodes = {item["id"]: item for item in program["nodes"]} + + def function(self, function_id: str, arguments: Sequence[Any]) -> Any: + function = self.functions[function_id] + if len(arguments) != len(function["parameters"]): + raise SignalProgramExecutionError( + f"signal function {function_id} argument count differs" + ) + parameters = { + parameter["node"]: value + for parameter, value in zip(function["parameters"], arguments, strict=True) + } + values: dict[str, Any] = {} + for node_id in function["node_ids"]: + node = self.nodes[node_id] + try: + values[node_id] = self._node(node, values, parameters) + except SignalProgramExecutionError: + raise + except Exception as exc: + raise SignalProgramExecutionError( + f"signal node {node_id} ({node['op']}) failed: {exc}" + ) from exc + return values[function["return_node"]] + + def _node( + self, + node: Mapping[str, Any], + values: Mapping[str, Any], + parameters: Mapping[str, Any], + ) -> Any: + opcode = node["op"] + if opcode == "parameter": + return parameters[node["id"]] + inputs = [values[input_id] for input_id in node["inputs"]] + options = node["parameters"] + if opcode == "literal": + return options.get("value") + if opcode == "column-read": + frame = _require_frame(inputs[0], node["id"]) + column = options["column"] + if column not in frame: + raise SignalProgramExecutionError( + f"signal node {node['id']} requires missing column {column!r}" + ) + return frame[column] + if opcode == "metadata-read": + metadata = inputs[0] + if not isinstance(metadata, Mapping): + raise SignalProgramExecutionError( + f"signal node {node['id']} metadata input is invalid" + ) + key = options["key"] + if key not in metadata: + raise SignalProgramExecutionError( + f"signal node {node['id']} requires missing metadata key {key!r}" + ) + return metadata[key] + if opcode == "binary": + return _binary(options["operator"], inputs[0], inputs[1]) + if opcode == "compare": + return _compare(options["operator"], inputs[0], inputs[1]) + if opcode == "logical": + return _logical(options["operator"], inputs) + if opcode == "unary": + return _unary(options["operator"], inputs[0]) + if opcode == "select": + return np.where(inputs[0], inputs[1], inputs[2]) + if opcode == "shift": + value = inputs[0] + periods = int(options["periods"]) + if isinstance(value, pd.Series): + return value.shift(periods) + result = np.full(len(value), np.nan, dtype=np.float64) + if periods == 0: + return np.asarray(value).copy() + result[periods:] = np.asarray(value)[:-periods] + return result + if opcode == "cast": + return _cast(inputs[0], options["target"]) + if opcode == "scalar-call": + return _scalar_call(options["name"], inputs) + if opcode == "array-call": + return _array_call(options["name"], inputs, options.get("arguments", {})) + if opcode == "function-call": + return self.function(options["function"], inputs) + if opcode == "frame-write": + return _frame_write(node, inputs) + if opcode == "instrumentation": + return 0.0 if options["name"] == "time.perf_counter" else None + if opcode == "return": + return inputs[0] + raise SignalProgramExecutionError( + f"signal node {node['id']} uses unsupported opcode {opcode!r}" + ) + + +def _frame_write(node: Mapping[str, Any], inputs: Sequence[Any]) -> pd.DataFrame: + options = node["parameters"] + frame = _require_frame(inputs[0], node["id"]).copy(deep=True) + offset = 1 + mask: pd.Series | np.ndarray[Any, np.dtype[np.bool_]] | None = None + if options["rows"] == "mask": + mask = _require_mask(inputs[offset], frame.index, node["id"]) + offset += 1 + values = list(inputs[offset:]) + columns = options["columns"] + assignment = options["assignment"] + assigned: Any + if assignment == "scalar-broadcast": + if len(values) != 1: + raise SignalProgramExecutionError( + f"signal node {node['id']} scalar broadcast has multiple values" + ) + assigned = values[0] + else: + if len(values) != len(columns): + raise SignalProgramExecutionError( + f"signal node {node['id']} column/value arity differs" + ) + assigned = values[0] if len(columns) == 1 else values + + if options["mode"] == "column": + if mask is not None or len(columns) != 1: + raise SignalProgramExecutionError( + f"signal node {node['id']} has an invalid direct-column contract" + ) + frame[columns[0]] = assigned + elif options["mode"] == "loc": + rows: Any = slice(None) if mask is None else mask + selector: Any = columns[0] if len(columns) == 1 else columns + frame.loc[rows, selector] = assigned + else: + raise SignalProgramExecutionError( + f"signal node {node['id']} has unknown assignment mode {options['mode']!r}" + ) + return frame + + +def _require_mask(value: Any, index: pd.Index, node_id: str) -> pd.Series | np.ndarray[Any, Any]: + if isinstance(value, pd.Series): + if not value.index.equals(index): + value = value.reindex(index) + if not is_bool_dtype(value.dtype): + raise SignalProgramExecutionError( + f"signal node {node_id} mask dtype is not boolean: {value.dtype}" + ) + return value + array = np.asarray(value) + if array.ndim != 1 or len(array) != len(index) or not is_bool_dtype(array.dtype): + raise SignalProgramExecutionError( + f"signal node {node_id} mask is not a row-aligned boolean vector" + ) + return array + + +def _require_frame(value: Any, node_id: str) -> pd.DataFrame: + if not isinstance(value, pd.DataFrame): + raise SignalProgramExecutionError(f"signal node {node_id} input is not a DataFrame") + return value + + +def _binary(name: str, left: Any, right: Any) -> Any: + operation = { + "add": operator.add, + "subtract": operator.sub, + "multiply": operator.mul, + "divide": operator.truediv, + "floor-divide": operator.floordiv, + "modulo": operator.mod, + "power": operator.pow, + }.get(name) + if operation is None: + raise SignalProgramExecutionError(f"unknown signal binary operator {name!r}") + return operation(left, right) + + +def _compare(name: str, left: Any, right: Any) -> Any: + operation = { + "equal": operator.eq, + "not-equal": operator.ne, + "less-than": operator.lt, + "less-than-or-equal": operator.le, + "greater-than": operator.gt, + "greater-than-or-equal": operator.ge, + }.get(name) + if operation is None: + raise SignalProgramExecutionError(f"unknown signal comparison {name!r}") + return operation(left, right) + + +def _logical(name: str, values: Sequence[Any]) -> Any: + if not values: + raise SignalProgramExecutionError("signal logical operation has no inputs") + operation = operator.and_ if name == "and" else operator.or_ if name == "or" else None + if operation is None: + raise SignalProgramExecutionError(f"unknown signal logical operator {name!r}") + result = values[0] + for value in values[1:]: + result = operation(result, value) + return result + + +def _unary(name: str, value: Any) -> Any: + if name == "negate": + return -value + if name == "positive": + return +value + if name in {"not", "invert"}: + return ~value + raise SignalProgramExecutionError(f"unknown signal unary operator {name!r}") + + +def _cast(value: Any, target: str) -> Any: + dtype = {"bool": bool, "float": float, "int": int, "array": None}.get(target) + if target == "array": + return np.asarray(value) + if dtype is None: + raise SignalProgramExecutionError(f"unknown signal cast target {target!r}") + if isinstance(value, pd.Series): + return value.astype(dtype) + return np.asarray(value).astype(dtype) if isinstance(value, np.ndarray) else dtype(value) + + +def _scalar_call(name: str, values: Sequence[Any]) -> Any: + functions = {"abs": abs, "bool": bool, "float": float, "int": int, "max": max, "min": min} + function = functions.get(name) + if function is None: + raise SignalProgramExecutionError(f"unknown signal scalar call {name!r}") + return function(*values) + + +def _array_call(name: str, values: Sequence[Any], arguments: Mapping[str, Any]) -> Any: + function = getattr(np, name, None) + if function is None or not callable(function): + raise SignalProgramExecutionError(f"unknown signal NumPy call {name!r}") + return function(*values, **arguments) diff --git a/python/nfi_backtest_engine/signal_program/validation.py b/python/nfi_backtest_engine/signal_program/validation.py new file mode 100644 index 0000000..bb111d3 --- /dev/null +++ b/python/nfi_backtest_engine/signal_program/validation.py @@ -0,0 +1,246 @@ +"""Semantic validation and content identity for signal-program-v1.""" + +from __future__ import annotations + +import copy +import hashlib +import json +from collections.abc import Mapping, Sequence +from typing import Any + +from ..errors import SpecValidationError +from ..specs import SIGNAL_PROGRAM_SCHEMA, validate_schema + +SIGNAL_COLUMNS = ("enter_long", "enter_short", "exit_long", "exit_short") +SIGNAL_PHASES = { + "enter_long": "entry", + "enter_short": "entry", + "exit_long": "exit", + "exit_short": "exit", +} +SIGNAL_SIDES = { + "enter_long": "long", + "enter_short": "short", + "exit_long": "long", + "exit_short": "short", +} +_NUMERIC_VALUE_TYPES = { + "bool-scalar", + "int-scalar", + "f64-scalar", + "bool-column", + "f64-column", +} + + +def validate_signal_program(program: Any) -> None: + """Validate schema, ordered references, mutation surface, and fingerprint.""" + validate_schema(program, SIGNAL_PROGRAM_SCHEMA) + if not isinstance(program, Mapping): # pragma: no cover - schema owns it + return + + nodes = program["nodes"] + expected_node_ids = [f"n{index}" for index in range(1, len(nodes) + 1)] + actual_node_ids = [node["id"] for node in nodes] + if actual_node_ids != expected_node_ids: + raise SpecValidationError("signal-program-v1 node IDs are not canonical") + positions = {identifier: index for index, identifier in enumerate(actual_node_ids)} + node_by_id = {node["id"]: node for node in nodes} + for index, node in enumerate(nodes): + for input_id in node["inputs"]: + position = positions.get(input_id) + if position is None or position >= index: + raise SpecValidationError( + f"signal-program-v1 node {node['id']} has a non-prior input {input_id}" + ) + + expected_function_ids = [f"f{index}" for index in range(1, len(program["functions"]) + 1)] + actual_function_ids = [function["id"] for function in program["functions"]] + if actual_function_ids != expected_function_ids: + raise SpecValidationError("signal-program-v1 function IDs are not canonical") + functions = {function["id"]: function for function in program["functions"]} + if [(item["phase"], item["function"]) for item in program["entrypoints"]] != [ + ("entry", "f1"), + ("exit", "f2"), + ]: + raise SpecValidationError("signal-program-v1 entrypoints are not canonical") + if any(item["function"] not in functions for item in program["entrypoints"]): + raise SpecValidationError("signal-program-v1 entrypoint function is missing") + for entrypoint in program["entrypoints"]: + function = functions[entrypoint["function"]] + expected_name = f"populate_{entrypoint['phase']}_trend" + if function["source_name"] != expected_name or function["kind"] != ( + f"entrypoint-{entrypoint['phase']}" + ): + raise SpecValidationError( + f"signal-program-v1 {entrypoint['phase']} entrypoint identity differs" + ) + + owned_nodes: set[str] = set() + for function in program["functions"]: + for source_order, node_id in enumerate(function["node_ids"]): + node = nodes[positions[node_id]] + if node["function"] != function["id"] or node["source_order"] != source_order: + raise SpecValidationError( + f"signal-program-v1 function {function['id']} node ownership differs" + ) + if node_id in owned_nodes: + raise SpecValidationError( + f"signal-program-v1 node {node_id} has multiple function owners" + ) + owned_nodes.add(node_id) + if function["return_node"] not in function["node_ids"]: + raise SpecValidationError( + f"signal-program-v1 function {function['id']} return node is external" + ) + if owned_nodes != set(actual_node_ids): + raise SpecValidationError("signal-program-v1 function node ownership is incomplete") + + mutation_nodes = [node["id"] for node in nodes if node["op"] == "frame-write"] + if program["mutation_nodes"] != mutation_nodes: + raise SpecValidationError("signal-program-v1 mutation inventory differs from nodes") + if set(program["source_map"]) != set(actual_node_ids): + raise SpecValidationError("signal-program-v1 source map does not cover every node") + if program["opcodes"] != sorted({node["op"] for node in nodes}): + raise SpecValidationError("signal-program-v1 opcode inventory differs from nodes") + if program["required_input_columns"] != sorted(program["required_input_columns"]): + raise SpecValidationError("signal-program-v1 input columns are not canonical") + if program["max_lookback"] != merge_lookbacks(nodes): + raise SpecValidationError("signal-program-v1 aggregate lookback differs") + + final_by_column: dict[str, str] = {} + phase_by_function = {item["function"]: item["phase"] for item in program["entrypoints"]} + for node in nodes: + if node["op"] != "frame-write": + continue + _validate_frame_write(node, node_by_id) + phase = phase_by_function.get(node["function"]) + if phase is None: + continue + for column in node["parameters"]["columns"]: + if SIGNAL_PHASES[column] != phase: + raise SpecValidationError( + f"signal-program-v1 {column} is written during the {phase} phase" + ) + final_by_column[column] = node["id"] + expected_outputs = [ + { + "column": column, + "phase": SIGNAL_PHASES[column], + "side": SIGNAL_SIDES[column], + "final_mutation": final_by_column[column], + } + for column in SIGNAL_COLUMNS + if column in final_by_column + ] + if program["signal_outputs"] != expected_outputs: + raise SpecValidationError("signal-program-v1 final output inventory differs") + + identity = dict(program) + fingerprint = identity.pop("fingerprint") + if fingerprint != fingerprint_program(identity): + raise SpecValidationError("signal-program-v1 fingerprint differs") + + +def _validate_frame_write( + node: Mapping[str, Any], + node_by_id: Mapping[str, Mapping[str, Any]], +) -> None: + parameters = node["parameters"] + if set(parameters) != {"rows", "columns", "mode", "assignment"}: + raise SpecValidationError( + f"signal-program-v1 node {node['id']} frame-write parameters differ" + ) + rows = parameters["rows"] + mode = parameters["mode"] + assignment = parameters["assignment"] + columns = parameters["columns"] + if ( + rows not in {"all", "mask"} + or mode not in {"column", "loc"} + or assignment + not in { + "column-values", + "scalar-broadcast", + } + ): + raise SpecValidationError( + f"signal-program-v1 node {node['id']} frame-write contract is invalid" + ) + if ( + not isinstance(columns, list) + or not columns + or len(set(columns)) != len(columns) + or any(column not in SIGNAL_COLUMNS for column in columns) + ): + raise SpecValidationError( + f"signal-program-v1 node {node['id']} frame-write columns are invalid" + ) + if mode == "column" and (rows != "all" or len(columns) != 1): + raise SpecValidationError( + f"signal-program-v1 node {node['id']} direct-column contract is invalid" + ) + inputs = node["inputs"] + expected_values = 1 if assignment == "scalar-broadcast" else len(columns) + expected_inputs = 1 + int(rows == "mask") + expected_values + if len(inputs) != expected_inputs: + raise SpecValidationError( + f"signal-program-v1 node {node['id']} frame-write input arity differs" + ) + if node_by_id[inputs[0]]["value_type"] != "dataframe": + raise SpecValidationError( + f"signal-program-v1 node {node['id']} frame-write base is not a dataframe" + ) + value_offset = 1 + if rows == "mask": + if node_by_id[inputs[1]]["value_type"] not in { + "bool-scalar", + "bool-column", + "f64-column", + }: + raise SpecValidationError( + f"signal-program-v1 node {node['id']} frame-write mask type differs" + ) + value_offset = 2 + if any( + node_by_id[input_id]["value_type"] not in _NUMERIC_VALUE_TYPES + for input_id in inputs[value_offset:] + ): + raise SpecValidationError( + f"signal-program-v1 node {node['id']} frame-write value type differs" + ) + + +def fingerprint_program(program: Mapping[str, Any]) -> str: + """Return a path-independent canonical content hash.""" + identity = copy.deepcopy(dict(program)) + source = identity.get("source") + if isinstance(source, dict): + source.pop("path", None) + return hashlib.sha256( + json.dumps( + identity, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + + +def merge_lookbacks(nodes: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + """Aggregate the same causal lookback contract used by vector nodes.""" + lookbacks = [node["lookback"] for node in nodes] + if all(item["kind"] == "finite" and isinstance(item["candles"], int) for item in lookbacks): + return { + "kind": "finite", + "candles": max((int(item["candles"]) for item in lookbacks), default=0), + "expression": None, + "causal": True, + } + kinds = sorted({str(item["kind"]) for item in lookbacks}) + return { + "kind": kinds[0] if len(kinds) == 1 else "mixed", + "candles": None, + "expression": "+".join(kinds), + "causal": all(bool(item["causal"]) for item in lookbacks), + } diff --git a/python/nfi_backtest_engine/specs.py b/python/nfi_backtest_engine/specs.py index cebfc6d..9af382e 100644 --- a/python/nfi_backtest_engine/specs.py +++ b/python/nfi_backtest_engine/specs.py @@ -35,6 +35,7 @@ SEMANTIC_INVENTORY_SCHEMA = "semantic-inventory-v1.schema.json" INDICATOR_INVENTORY_SCHEMA = "indicator-operation-inventory-v1.schema.json" INDICATOR_PROGRAM_SCHEMA = "indicator-program-v1.schema.json" +SIGNAL_PROGRAM_SCHEMA = "signal-program-v1.schema.json" STATEFUL_COVERAGE_SCHEMA = "stateful-coverage-v1.schema.json" FREQTRADE_SEMANTIC_PROFILE_SCHEMA = "freqtrade-semantic-profile-v1.schema.json" SEMANTIC_OBSERVER_REPORT_SCHEMA = "semantic-observer-report-v1.schema.json" diff --git a/python/nfi_backtest_engine/vector_worker.py b/python/nfi_backtest_engine/vector_worker.py index 31535fa..353d91d 100644 --- a/python/nfi_backtest_engine/vector_worker.py +++ b/python/nfi_backtest_engine/vector_worker.py @@ -81,8 +81,7 @@ def run_vector_request(request: dict[str, Any]) -> dict[str, Any]: raise StrategyAnalysisError(f"base candle frame is missing for {pair} {timeframe}") from exc metadata = {"pair": pair} indicators = strategy.populate_indicators(base, metadata) - entries = strategy.populate_entry_trend(indicators, metadata) - signals = strategy.populate_exit_trend(entries, metadata) + signals = _advise_signals(strategy, indicators, metadata) _validate_output_frame(signals) prepared = _prepare_execution_frame( signals, @@ -121,6 +120,34 @@ def run_vector_request(request: dict[str, Any]) -> dict[str, Any]: return record +def _advise_signals( + strategy: Any, + dataframe: pd.DataFrame, + metadata: dict[str, str], +) -> pd.DataFrame: + """Apply Freqtrade's entry/exit wrappers in real and compatibility environments.""" + advise_entry = getattr(strategy, "advise_entry", None) + if callable(advise_entry): + entries = cast(pd.DataFrame, advise_entry(dataframe, metadata)) + else: + dataframe.loc[:, "enter_tag"] = "" + entries = cast(pd.DataFrame, strategy.populate_entry_trend(dataframe, metadata)) + if "enter_long" not in entries.columns: + entries = entries.rename( + {"buy": "enter_long", "buy_tag": "enter_tag"}, + axis="columns", + ) + + advise_exit = getattr(strategy, "advise_exit", None) + if callable(advise_exit): + return cast(pd.DataFrame, advise_exit(entries, metadata)) + entries.loc[:, "exit_tag"] = "" + signals = strategy.populate_exit_trend(entries, metadata) + if "exit_long" not in signals.columns: + signals = signals.rename({"sell": "exit_long"}, axis="columns") + return cast(pd.DataFrame, signals) + + def _stabilize_compressed_tag_columns(frame: pd.DataFrame) -> pd.DataFrame: """Keep nullable strings readable in every compressed Arrow record batch. @@ -174,11 +201,8 @@ def _signal_counts(frame: pd.DataFrame) -> dict[str, int]: raw = frame[execution_column] if not isinstance(raw, pd.Series): raise StrategyAnalysisError(f"vector signal column is not one-dimensional: {column}") - values = pd.to_numeric(raw, errors="coerce") - if not isinstance(values, pd.Series): - raise StrategyAnalysisError(f"vector signal conversion failed: {column}") - values = values.fillna(0) - result[column] = int(values.ne(0).sum()) + enabled = raw.eq(1).fillna(False) + result[column] = int(enabled.sum()) return result @@ -192,10 +216,8 @@ def _materialize_execution_signals(frame: pd.DataFrame) -> pd.DataFrame: raw = result[column] if not isinstance(raw, pd.Series): raise StrategyAnalysisError(f"vector signal column is not one-dimensional: {column}") - numeric = pd.to_numeric(raw, errors="coerce") - if not isinstance(numeric, pd.Series): - raise StrategyAnalysisError(f"vector signal conversion failed: {column}") - result[f"nfi_exec_{column}"] = numeric.fillna(0).shift( + normalized = raw.mask(raw.isna(), 0) + result[f"nfi_exec_{column}"] = normalized.shift( SIGNAL_SOURCE_ROW_SHIFT, fill_value=0, ) diff --git a/python/nfi_backtest_engine/x7/vectors.py b/python/nfi_backtest_engine/x7/vectors.py index 6a17084..b22b9c1 100644 --- a/python/nfi_backtest_engine/x7/vectors.py +++ b/python/nfi_backtest_engine/x7/vectors.py @@ -250,7 +250,7 @@ def _scalar_feature_value(value: Any, column: str) -> Any: def _enabled(value: Any) -> bool: - return not pd.isna(value) and float(value) != 0.0 + return not pd.isna(value) and bool(value == 1) def _optional_text(value: Any) -> str | None: diff --git a/rust/crates/nfi-sim-core/src/nfi/exit.rs b/rust/crates/nfi-sim-core/src/nfi/exit.rs index ff1d681..91d708e 100644 --- a/rust/crates/nfi-sim-core/src/nfi/exit.rs +++ b/rust/crates/nfi-sim-core/src/nfi/exit.rs @@ -599,19 +599,19 @@ fn evaluate_generic_managed_long_exit( } } } - ManagedExitStateOperation::TerminalExit => { + ManagedExitStateOperation::TerminalExit if route.terminal_exit.as_ref().is_some_and(|terminal| { enter_tags == terminal.entry_tags && candle.timestamp_ms - trade.open_timestamp_ms >= terminal.minimum_age_ms && snapshot.initial_stake_ratio >= terminal.minimum_profit_ratio - }) { - let reason = &route - .terminal_exit - .as_ref() - .expect("terminal exit was checked immediately above") - .reason; - return Some(CustomExitDecision::Exit(nfi_exit_reason(reason, entry_tag))); - } + }) => + { + let reason = &route + .terminal_exit + .as_ref() + .expect("terminal exit was checked immediately above") + .reason; + return Some(CustomExitDecision::Exit(nfi_exit_reason(reason, entry_tag))); } _ => {} } diff --git a/rust/crates/nfi-sim-core/src/nfi/legacy_grind.rs b/rust/crates/nfi-sim-core/src/nfi/legacy_grind.rs index b5b8984..79a23f3 100644 --- a/rust/crates/nfi-sim-core/src/nfi/legacy_grind.rs +++ b/rust/crates/nfi-sim-core/src/nfi/legacy_grind.rs @@ -423,7 +423,7 @@ fn evaluate_compiled_grind( let has_derisk_buyback = program.schema_version == "grind-transition-program-v3"; if complete_program != stop_action.is_some() { return None; - }; + } let first_entry_closed = trade .orders .iter() diff --git a/rust/crates/nfi-vector-io/src/values.rs b/rust/crates/nfi-vector-io/src/values.rs index 2c2d80c..359b6bb 100644 --- a/rust/crates/nfi-vector-io/src/values.rs +++ b/rust/crates/nfi-vector-io/src/values.rs @@ -143,6 +143,9 @@ pub(crate) fn is_numeric_type(data_type: &DataType) -> bool { ) } +#[allow(clippy::float_cmp)] +// Freqtrade intentionally activates a signal only for exact numeric 1. An +// epsilon comparison would incorrectly turn nearby strategy outputs into orders. pub(crate) fn enabled( array: &dyn Array, row: usize, @@ -154,7 +157,7 @@ pub(crate) fn enabled( return Ok(false); } let value = required_number(array, row, pair, column, absolute_row)?; - Ok(!value.is_nan() && value != 0.0) + Ok(value == 1.0) } pub(crate) fn optional_number( @@ -202,3 +205,30 @@ pub(crate) fn optional_text( }; Ok((!value.is_empty() && value != EMPTY_TAG_TRANSPORT_SENTINEL).then(|| value.to_owned())) } + +#[cfg(test)] +mod tests { + use arrow2::array::PrimitiveArray; + + use super::enabled; + + #[test] + fn freqtrade_signal_enablement_requires_exact_numeric_one() { + let values = PrimitiveArray::::from([ + Some(1.0), + Some(0.0), + Some(2.0), + Some(-1.0), + Some(f64::NAN), + None, + ]); + let expected = [true, false, false, false, false, false]; + + for (row, expected) in expected.into_iter().enumerate() { + assert_eq!( + enabled(&values, row, "ETH/USDT", "enter_long", row).unwrap(), + expected + ); + } + } +} diff --git a/scripts/generate_signal_fixture.py b/scripts/generate_signal_fixture.py new file mode 100644 index 0000000..2454d2e --- /dev/null +++ b/scripts/generate_signal_fixture.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Regenerate the pinned Freqtrade signal-assignment oracle fixture.""" + +from __future__ import annotations + +import sys +from importlib import import_module +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + + +def main() -> None: + """Regenerate the fixture from the pinned source tree.""" + module = import_module("nfi_backtest_engine.signal_fixture") + module.write_fixture(ROOT / module.FIXTURE_PATH) + + +if __name__ == "__main__": + main() diff --git a/tests/test_generic_adapter.py b/tests/test_generic_adapter.py index ac97b4b..84ef836 100644 --- a/tests/test_generic_adapter.py +++ b/tests/test_generic_adapter.py @@ -9,6 +9,7 @@ from nfi_backtest_engine.errors import BenchmarkError from nfi_backtest_engine.fixture import sha256_file from nfi_backtest_engine.generic_adapter import ( + _enabled, _optional_text, build_generic_simulation_input, build_generic_vector_manifest, @@ -34,6 +35,27 @@ ) +@pytest.mark.parametrize( + ("value", "expected"), + [ + (1, True), + (True, True), + (1.0, True), + (0, False), + (2, False), + (-1, False), + ("1", False), + (float("nan"), False), + (None, False), + ], +) +def test_freqtrade_signal_enablement_requires_exact_numeric_one( + value: object, + expected: bool, +) -> None: + assert _enabled(value) is expected + + def _analysis(tmp_path: Path) -> dict: source = tmp_path / "Simple.py" source.write_text( diff --git a/tests/test_signal_fixture.py b/tests/test_signal_fixture.py new file mode 100644 index 0000000..007701c --- /dev/null +++ b/tests/test_signal_fixture.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd +from nfi_backtest_engine.signal_fixture import ( + CONTRACT_PATH, + FIXTURE_PATH, + canonical_sha256, + decode_frame, + encode_signal_columns, + generate_fixture, +) +from nfi_backtest_engine.signal_program import compile_signal_program, execute_signal_program + +ROOT = Path(__file__).resolve().parents[1] + + +def test_committed_signal_fixture_has_pinned_official_identity() -> None: + fixture = json.loads((ROOT / FIXTURE_PATH).read_text(encoding="utf-8")) + + assert fixture["fingerprint"] == canonical_sha256(fixture) + assert fixture["schema_version"] == "freqtrade-signal-fixture-v1" + assert fixture["source"]["version"] == "2026.5.1" + assert fixture["source"]["commit"] == "6fa470939cc74bf0672e0e348a4d9b293072e43c" + assert fixture["call_order"] == ["advise_entry", "advise_exit"] + assert set(fixture["source"]["method_sha256"]) == {"advise_entry", "advise_exit"} + + +def test_signal_program_matches_pinned_freqtrade_raw_signal_columns() -> None: + fixture = json.loads((ROOT / FIXTURE_PATH).read_text(encoding="utf-8")) + frame = decode_frame(fixture["input"]) + program = compile_signal_program(CONTRACT_PATH, class_name="SignalProgramContract") + + output = execute_signal_program(program, frame, metadata={"pair": "ETH/USDT"}) + expected = decode_frame(fixture["output"]) + + assert encode_signal_columns(output) == encode_signal_columns(expected) + assert output.loc[4, ["enter_long", "exit_long"]].tolist() == [0, 1] + assert pd.isna(frame.loc[6, "score"]) + + +def test_signal_fixture_regeneration_is_deterministic() -> None: + stored = json.loads((ROOT / FIXTURE_PATH).read_text(encoding="utf-8")) + + assert generate_fixture() == stored diff --git a/tests/test_signal_program.py b/tests/test_signal_program.py new file mode 100644 index 0000000..51ece91 --- /dev/null +++ b/tests/test_signal_program.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import copy +from pathlib import Path + +import pandas as pd +import pytest +from nfi_backtest_engine import cli +from nfi_backtest_engine.errors import SpecValidationError +from nfi_backtest_engine.signal_program import ( + SignalProgramCompileError, + SignalProgramExecutionError, + compile_signal_program, + execute_signal_program, + validate_signal_program, +) +from nfi_backtest_engine.specs import SIGNAL_PROGRAM_SCHEMA, validate_schema + +ROOT = Path(__file__).parents[1] +CONTRACT = ROOT / "benchmarks" / "reference" / "strategies" / "SignalProgramContract.py" + + +def _source_result(frame: pd.DataFrame) -> pd.DataFrame: + result = frame.copy(deep=True) + result.loc[:, ["enter_long", "enter_short"]] = (0, 0) + positive = result["score"] > 0 + result.loc[positive, "enter_long"] = 1 + result.loc[result["score"] >= 2, "enter_long"] = 0 + result["enter_short"] = (result["score"] < 0).astype(int) + result.loc[:, ["exit_long", "exit_short"]] = 0 + result.loc[(result["enter_long"] == 0) & (result["score"] > 1), "exit_long"] = 1 + result.loc[result["exit_mask"], "exit_short"] = 1 + return result + + +def test_signal_program_compiles_ordered_entry_and_exit_mutations() -> None: + program = compile_signal_program(CONTRACT, class_name="SignalProgramContract") + + validate_schema(program, SIGNAL_PROGRAM_SCHEMA) + validate_signal_program(program) + assert program["entrypoints"] == [ + {"phase": "entry", "function": "f1"}, + {"phase": "exit", "function": "f2"}, + ] + assert [item["column"] for item in program["signal_outputs"]] == [ + "enter_long", + "enter_short", + "exit_long", + "exit_short", + ] + writes = [node for node in program["nodes"] if node["op"] == "frame-write"] + assert program["mutation_nodes"] == [node["id"] for node in writes] + assert [node["parameters"]["columns"] for node in writes] == [ + ["enter_long", "enter_short"], + ["enter_long"], + ["enter_long"], + ["enter_short"], + ["exit_long", "exit_short"], + ["exit_long"], + ["exit_short"], + ] + assert [node["parameters"]["rows"] for node in writes] == [ + "all", + "mask", + "mask", + "all", + "all", + "mask", + "mask", + ] + assert program["required_input_columns"] == ["exit_mask", "score"] + assert len(program["fingerprint"]) == 64 + + +def test_signal_program_executes_nullable_mask_and_source_order_exactly() -> None: + frame = pd.DataFrame( + { + "score": [-2.0, -0.5, 0.5, 1.5, 2.5], + "exit_mask": pd.array([pd.NA, False, True, True, False], dtype="boolean"), + } + ) + program = compile_signal_program( + CONTRACT, + class_name="SignalProgramContract", + trading_mode="futures", + ) + + actual = execute_signal_program(program, frame, metadata={"pair": "ETH/USDT"}) + expected = _source_result(frame) + + pd.testing.assert_frame_equal(actual, expected, check_dtype=True, check_exact=True) + assert actual["enter_long"].tolist() == [0, 0, 1, 1, 0] + assert actual["enter_short"].tolist() == [1, 1, 0, 0, 0] + assert actual["exit_long"].tolist() == [0, 0, 0, 0, 1] + assert actual["exit_short"].tolist() == [0, 0, 1, 1, 0] + + +def test_signal_program_identity_rejects_order_or_source_map_mutation(tmp_path: Path) -> None: + copied = tmp_path / "Renamed.py" + copied.write_bytes(CONTRACT.read_bytes()) + first = compile_signal_program(CONTRACT, class_name="SignalProgramContract") + second = compile_signal_program(copied, class_name="SignalProgramContract") + assert first["fingerprint"] == second["fingerprint"] + + reordered = copy.deepcopy(first) + reordered["mutation_nodes"][:2] = reversed(reordered["mutation_nodes"][:2]) + with pytest.raises(SpecValidationError, match="mutation inventory differs"): + validate_signal_program(reordered) + + changed_location = copy.deepcopy(first) + changed_location["source_map"]["n1"]["line"] += 1 + with pytest.raises(SpecValidationError, match="fingerprint differs"): + validate_signal_program(changed_location) + + invalid_write = copy.deepcopy(first) + write = next(node for node in invalid_write["nodes"] if node["op"] == "frame-write") + write["parameters"]["rows"] = "unordered" + with pytest.raises(SpecValidationError, match="frame-write contract is invalid"): + validate_signal_program(invalid_write) + + +@pytest.mark.parametrize( + ("statement", "message"), + [ + ("dataframe.loc[:, 'enter_tag'] = '1 '", "tag mutation"), + ("dataframe.loc[:, 'feature'] = 1", "non-signal dataframe output"), + ("dataframe.loc[:, 'exit_long'] = 1", "during the entry phase"), + ("dataframe.iloc[:, 0] = 1", "nested dataframe write"), + ], +) +def test_signal_program_fails_closed_outside_m21_signal_surface( + tmp_path: Path, + statement: str, + message: str, +) -> None: + source = tmp_path / "Unsupported.py" + source.write_text( + "from freqtrade.strategy import IStrategy\n" + "class Unsupported(IStrategy):\n" + " timeframe = '5m'\n" + " def populate_entry_trend(self, dataframe, metadata):\n" + f" {statement}\n" + " return dataframe\n" + " def populate_exit_trend(self, dataframe, metadata):\n" + " dataframe.loc[:, 'exit_long'] = 0\n" + " return dataframe\n", + encoding="utf-8", + ) + + with pytest.raises(SignalProgramCompileError, match=message): + compile_signal_program(source, class_name="Unsupported") + + +def test_signal_program_runtime_fails_closed_for_numeric_mask(tmp_path: Path) -> None: + source = tmp_path / "NumericMask.py" + source.write_text( + "from freqtrade.strategy import IStrategy\n" + "class NumericMask(IStrategy):\n" + " timeframe = '5m'\n" + " def populate_entry_trend(self, dataframe, metadata):\n" + " dataframe.loc[dataframe['mask'], 'enter_long'] = 1\n" + " return dataframe\n" + " def populate_exit_trend(self, dataframe, metadata):\n" + " dataframe.loc[:, 'exit_long'] = 0\n" + " return dataframe\n", + encoding="utf-8", + ) + program = compile_signal_program(source, class_name="NumericMask") + + with pytest.raises(SignalProgramExecutionError, match="mask dtype is not boolean"): + execute_signal_program(program, pd.DataFrame({"mask": [0, 1]})) + + +def test_signal_program_parser_seals_mode_and_output() -> None: + args = cli.build_parser().parse_args( + [ + "strategy", + "signal-program", + "latest.py", + "--class", + "NostalgiaForInfinityX7", + "--trading-mode", + "futures", + "--output", + ".nfi/signal-program.json", + ] + ) + + assert args.strategy_command == "signal-program" + assert args.trading_mode == "futures" + assert args.output == Path(".nfi/signal-program.json") diff --git a/tests/test_vector_worker.py b/tests/test_vector_worker.py index 230f4e5..db1fa35 100644 --- a/tests/test_vector_worker.py +++ b/tests/test_vector_worker.py @@ -8,10 +8,12 @@ from nfi_backtest_engine.runtime_versions import vector_dependency_versions from nfi_backtest_engine.vector_manifest import EMPTY_TAG_TRANSPORT_SENTINEL from nfi_backtest_engine.vector_worker import ( + _advise_signals, _attach_funding_events, _bound_indicator_frames, _clean_ohlcv_like_freqtrade, _prepare_execution_frame, + _signal_counts, _stabilize_compressed_tag_columns, _trim_timerange, ) @@ -233,6 +235,70 @@ def test_execution_frame_keeps_startup_rows_as_callback_only_context() -> None: ] +def test_signal_shift_preserves_raw_values_and_counts_only_exact_one() -> None: + frame = pd.DataFrame( + { + "date": pd.date_range("2022-04-30T23:00:00Z", periods=6, freq="5min"), + "enter_long": [1, True, 1.0, 2, -1, "1"], + } + ) + + prepared = _prepare_execution_frame( + frame, + "1651359600-1651361100", + startup_candles=0, + ) + + assert prepared.frame["nfi_exec_enter_long"].tolist() == [0, 1, True, 1.0, 2, -1] + assert _signal_counts(prepared.frame) == { + "enter_long": 3, + "enter_short": 0, + "exit_long": 0, + "exit_short": 0, + } + + +def test_compatibility_worker_applies_freqtrade_signal_wrappers_in_order() -> None: + class CompatibilityStrategy: + def __init__(self) -> None: + self.events: list[str] = [] + + def populate_entry_trend( + self, + dataframe: pd.DataFrame, + metadata: dict[str, str], + ) -> pd.DataFrame: + self.events.append("entry") + assert dataframe["enter_tag"].tolist() == ["", ""] + dataframe.loc[:, "buy"] = [1, 0] + dataframe.loc[:, "enter_tag"] = ["legacy", ""] + return dataframe + + def populate_exit_trend( + self, + dataframe: pd.DataFrame, + metadata: dict[str, str], + ) -> pd.DataFrame: + self.events.append("exit") + assert dataframe["enter_long"].tolist() == [1, 0] + assert dataframe["exit_tag"].tolist() == ["", ""] + dataframe.loc[:, "sell"] = [0, 1] + return dataframe + + strategy = CompatibilityStrategy() + + result = _advise_signals( + strategy, + pd.DataFrame({"close": [1.0, 2.0]}), + {"pair": "ETH/USDT"}, + ) + + assert strategy.events == ["entry", "exit"] + assert result["enter_long"].tolist() == [1, 0] + assert result["enter_tag"].tolist() == ["legacy", ""] + assert result["exit_long"].tolist() == [0, 1] + + def test_all_null_tag_transport_marks_every_nullable_value() -> None: frame = pd.DataFrame( { From 9b25f24e8e20423d42ed4797318e5e4f3087e07a Mon Sep 17 00:00:00 2001 From: vntrevx <20063774+vntrevx@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:55:35 +0900 Subject: [PATCH 3/4] chore(roadmap): complete M21-01 --- planning/roadmap-state.json | 55 ++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/planning/roadmap-state.json b/planning/roadmap-state.json index cce8fbc..2a89dde 100644 --- a/planning/roadmap-state.json +++ b/planning/roadmap-state.json @@ -1,10 +1,10 @@ { "schema_version": "1.0.0", "roadmap_id": "nfi-backtest-engine-post-v1.1.0", - "revision": 134, - "updated_at": "2026-08-11T03:03:16+09:00", + "revision": 135, + "updated_at": "2026-08-11T15:54:39+09:00", "acceptance_commands": "planning/acceptance-commands.json", - "active_task_id": "M21-01", + "active_task_id": null, "execution_policy": { "max_in_progress": 1, "selection": "lowest order pending task whose dependencies are completed", @@ -4804,7 +4804,7 @@ "order": 2101, "milestone": "M21", "title": "Define and compile signal-program-v1", - "status": "in_progress", + "status": "completed", "depends_on": [ "M20-05" ], @@ -4824,9 +4824,16 @@ "entry and exit mutation tests" ], "started_at": "2026-08-11T03:03:16+09:00", - "completed_at": null, - "commit_sha": null, - "evidence": [], + "completed_at": "2026-08-11T15:54:39+09:00", + "commit_sha": "b2255b57fb47cec88c2d97308a77cfa5f891f9ac", + "evidence": [ + ".nfi/roadmap-acceptance/M21-01/b2255b57fb47cec88c2d97308a77cfa5f891f9ac/acceptance-report.json", + ".nfi/roadmap-acceptance/M21-01/b2255b57fb47cec88c2d97308a77cfa5f891f9ac/latest-signal-program-gap.json", + ".nfi/roadmap-acceptance/M21-01/b2255b57fb47cec88c2d97308a77cfa5f891f9ac/SHA256SUMS.txt", + ".nfi/roadmap-acceptance/M21-01/b2255b57fb47cec88c2d97308a77cfa5f891f9ac/x7-spot/run.json", + ".nfi/roadmap-acceptance/M21-01/b2255b57fb47cec88c2d97308a77cfa5f891f9ac/x7-futures/run.json", + "benchmarks/reference/signals/freqtrade-2026.5.1.json" + ], "blocker": null }, { @@ -8415,6 +8422,40 @@ "original_source_order_must_be_preserved": true, "latest_upstream_commit": "897a1523391b8222ee711eba9714b59a3e77265a" } + }, + { + "sequence": 182, + "timestamp": "2026-08-11T15:54:39+09:00", + "task_id": "M21-01", + "event": "task_completed", + "details": { + "implementation_commit": "b2255b57fb47cec88c2d97308a77cfa5f891f9ac", + "latest_upstream_commit": "897a1523391b8222ee711eba9714b59a3e77265a", + "latest_strategy_sha256": "e99b4f58ecf507da86d2ba94e641c2994f17a1456133c2cff26946fa6b4a1afb", + "freqtrade_version": "2026.5.1", + "signal_oracle_row_count": 8, + "signal_oracle_fingerprint": "4c3150439cc45b282919562bce0e9273ab87a9b14022413b34133ac8fccf3249", + "signal_program_node_count": 38, + "signal_mutation_count": 7, + "entry_then_exit_order_exact": true, + "overlapping_mask_last_write_exact": true, + "nullable_boolean_mask_exact": true, + "freqtrade_exact_numeric_one_semantics": true, + "python_test_count": 786, + "future_compatibility_test_count": 143, + "rust_workspace_test_count": 212, + "spot_trade_surface_and_full_state_exact": true, + "futures_trade_surface_and_full_state_exact": true, + "runtime_hardcoding_added": false, + "signal_number_execution_branches_added": false, + "latest_x7_fully_compiled": false, + "latest_x7_fail_closed_source": "strategy.py:13034:13", + "tag_program_complete": false, + "rust_vector_shadow_complete": false, + "full_native_strategy_claim": false, + "next_eligible_task": "M21-02", + "next_task_manual_gate": false + } } ] } From 4447335c0a112a5423010eb95d833743218205d4 Mon Sep 17 00:00:00 2001 From: vntrevx <20063774+vntrevx@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:09:04 +0900 Subject: [PATCH 4/4] fix(ci): make signal oracle regeneration checkout-aware --- tests/test_signal_fixture.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_signal_fixture.py b/tests/test_signal_fixture.py index 007701c..ccd9d5c 100644 --- a/tests/test_signal_fixture.py +++ b/tests/test_signal_fixture.py @@ -4,9 +4,11 @@ from pathlib import Path import pandas as pd +import pytest from nfi_backtest_engine.signal_fixture import ( CONTRACT_PATH, FIXTURE_PATH, + PINNED_SOURCE, canonical_sha256, decode_frame, encode_signal_columns, @@ -43,5 +45,8 @@ def test_signal_program_matches_pinned_freqtrade_raw_signal_columns() -> None: def test_signal_fixture_regeneration_is_deterministic() -> None: stored = json.loads((ROOT / FIXTURE_PATH).read_text(encoding="utf-8")) + source = ROOT / PINNED_SOURCE + if not source.is_dir(): + pytest.skip("pinned Freqtrade source checkout is required for regeneration") - assert generate_fixture() == stored + assert generate_fixture(source) == stored