From 15c5bbcc94a92b4d0ac35356071af4ab42e0b880 Mon Sep 17 00:00:00 2001 From: vntrevx <20063774+vntrevx@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:03:30 +0900 Subject: [PATCH 1/3] chore(roadmap): start M21-02 --- 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 2a89ddee..fd046235 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": 135, - "updated_at": "2026-08-11T15:54:39+09:00", + "revision": 136, + "updated_at": "2026-08-11T18:03:14+09:00", "acceptance_commands": "planning/acceptance-commands.json", - "active_task_id": null, + "active_task_id": "M21-02", "execution_policy": { "max_in_progress": 1, "selection": "lowest order pending task whose dependencies are completed", @@ -4841,7 +4841,7 @@ "order": 2102, "milestone": "M21", "title": "Compile exact Native tag generation", - "status": "pending", + "status": "in_progress", "depends_on": [ "M21-01" ], @@ -4860,7 +4860,7 @@ "tag priority tests", "compound-tag vector exactness" ], - "started_at": null, + "started_at": "2026-08-11T18:03:14+09:00", "completed_at": null, "commit_sha": null, "evidence": [], @@ -8456,6 +8456,20 @@ "next_eligible_task": "M21-02", "next_task_manual_gate": false } + }, + { + "sequence": 183, + "timestamp": "2026-08-11T18:03:14+09:00", + "task_id": "M21-02", + "event": "task_started", + "details": { + "objective": "compile literal and compound entry and exit tags with exact source-order assignment and original string preservation", + "tag_specific_execution_branches_allowed": false, + "original_tag_strings_must_be_preserved": true, + "compound_tag_order_and_whitespace_exact_required": true, + "official_freqtrade_oracle_required": true, + "latest_upstream_commit": "897a1523391b8222ee711eba9714b59a3e77265a" + } } ] } From a85ae58bf9f689b03d221bf2ebde03371553fd61 Mon Sep 17 00:00:00 2001 From: vntrevx <20063774+vntrevx@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:18:33 +0900 Subject: [PATCH 2/3] feat(vector): compile exact ordered tag programs --- .../strategies/TagProgramContract.py | 28 ++ .../reference/tags/freqtrade-2026.5.1.json | 1 + docs/native-signal-program.md | 6 +- docs/native-tag-program.md | 41 ++ docs/native-vector-core.md | 6 +- python/nfi_backtest_engine/cli.py | 12 + python/nfi_backtest_engine/commands/run.py | 18 + .../schemas/tag-program-v1.schema.json | 258 ++++++++++++ python/nfi_backtest_engine/specs.py | 1 + python/nfi_backtest_engine/tag_fixture.py | 89 ++++ .../tag_program/__init__.py | 15 + .../tag_program/compiler.py | 385 ++++++++++++++++++ .../tag_program/runtime.py | 127 ++++++ .../tag_program/validation.py | 271 ++++++++++++ scripts/generate_tag_fixture.py | 21 + tests/test_tag_fixture.py | 58 +++ tests/test_tag_program.py | 230 +++++++++++ 17 files changed, 1561 insertions(+), 6 deletions(-) create mode 100644 benchmarks/reference/strategies/TagProgramContract.py create mode 100644 benchmarks/reference/tags/freqtrade-2026.5.1.json create mode 100644 docs/native-tag-program.md create mode 100644 python/nfi_backtest_engine/schemas/tag-program-v1.schema.json create mode 100644 python/nfi_backtest_engine/tag_fixture.py create mode 100644 python/nfi_backtest_engine/tag_program/__init__.py create mode 100644 python/nfi_backtest_engine/tag_program/compiler.py create mode 100644 python/nfi_backtest_engine/tag_program/runtime.py create mode 100644 python/nfi_backtest_engine/tag_program/validation.py create mode 100644 scripts/generate_tag_fixture.py create mode 100644 tests/test_tag_fixture.py create mode 100644 tests/test_tag_program.py diff --git a/benchmarks/reference/strategies/TagProgramContract.py b/benchmarks/reference/strategies/TagProgramContract.py new file mode 100644 index 00000000..e879063d --- /dev/null +++ b/benchmarks/reference/strategies/TagProgramContract.py @@ -0,0 +1,28 @@ +from freqtrade.strategy import IStrategy + + +class TagProgramContract(IStrategy): + timeframe = "5m" + + def populate_entry_trend(self, dataframe, metadata): + dataframe.loc[:, ["enter_long", "enter_short"]] = (0, 0) + long_route = 101 + short_route = 562 + long_mask = dataframe["score"] >= 0 + short_mask = dataframe["score"] <= 0 + dataframe.loc[long_mask, "enter_long"] = 1 + dataframe.loc[long_mask, "enter_tag"] += f"{long_route} " + dataframe.loc[short_mask, "enter_short"] = 1 + dataframe.loc[short_mask, "enter_tag"] += f"{short_route} " + override_mask = dataframe["score"] >= 2 + dataframe.loc[override_mask, "enter_tag"] = "override " + dataframe.loc[override_mask, "enter_tag"] += "final " + return dataframe + + def populate_exit_trend(self, dataframe, metadata): + dataframe.loc[:, ["exit_long", "exit_short"]] = (0, 0) + long_mask = (dataframe["enter_long"] == 1) & (dataframe["score"] > 1) + dataframe.loc[long_mask, ["exit_long", "exit_tag"]] = (1, "profit ") + dataframe.loc[dataframe["exit_mask"], "exit_short"] = 1 + dataframe.loc[dataframe["exit_mask"], "exit_tag"] += "signal " + return dataframe diff --git a/benchmarks/reference/tags/freqtrade-2026.5.1.json b/benchmarks/reference/tags/freqtrade-2026.5.1.json new file mode 100644 index 00000000..f7754de5 --- /dev/null +++ b/benchmarks/reference/tags/freqtrade-2026.5.1.json @@ -0,0 +1 @@ +{"call_order":["advise_entry","advise_exit"],"fingerprint":"11219ab2fae512e0e833f05c323a5cb8e47a724154a9d50ac126c6d8dbadba77","input":{"columns":["score","exit_mask","enter_tag","exit_tag"],"dtypes":{"enter_tag":"str","exit_mask":"boolean","exit_tag":"str","score":"float64"},"rows":[[-2.0,false,"stale-entry","stale-exit"],[-0.5,true,"stale-entry","stale-exit"],[0.0,false,"stale-entry","stale-exit"],[0.5,true,"stale-entry","stale-exit"],[1.5,false,"stale-entry","stale-exit"],[2.0,true,"stale-entry","stale-exit"],[2.5,null,"stale-entry","stale-exit"],[null,false,"stale-entry","stale-exit"]]},"output":{"columns":["score","exit_mask","enter_tag","exit_tag","enter_long","enter_short","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,false,"562 ","",0,1,0,0],[-0.5,true,"562 ","signal ",0,1,0,1],[0.0,false,"101 562 ","",1,1,0,0],[0.5,true,"101 ","signal ",1,0,0,1],[1.5,false,"101 ","profit ",1,0,1,0],[2.0,true,"override final ","profit signal ",1,0,1,1],[2.5,null,"override final ","profit ",1,0,1,0],[null,false,"","",0,0,0,0]]},"schema_version":"freqtrade-tag-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/TagProgramContract.py","strategy_sha256":"c7b42a8b6eb855f438bf0243412faea25694f7e3c2be0b41dd1a2ee67ae31919","version":"2026.5.1"}} diff --git a/docs/native-signal-program.md b/docs/native-signal-program.md index f6c2c8d9..b526a697 100644 --- a/docs/native-signal-program.md +++ b/docs/native-signal-program.md @@ -22,9 +22,9 @@ corresponding value is exactly numeric `1`; arbitrary nonzero values are not pro 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. +Tag initialization, literal and compound tag generation, and original whitespace belong to the +separate [`tag-program-v1`](native-tag-program.md) contract. A tag write encountered by the +signal-only compiler still fails closed instead of being discarded or guessed. ## Evidence and regeneration diff --git a/docs/native-tag-program.md b/docs/native-tag-program.md new file mode 100644 index 00000000..05db640e --- /dev/null +++ b/docs/native-tag-program.md @@ -0,0 +1,41 @@ +# Native Tag Program + +`tag-program-v1` is the versioned contract for exact NFI entry and exit tag generation. It +compiles strategy source into ordered data; it does not execute NFI Python in the Native lane. + +## Exact contract + +The runtime mirrors Freqtrade 2026.5.1 by initializing `enter_tag` to `""` immediately before +`populate_entry_trend`, then initializing `exit_tag` immediately before +`populate_exit_trend`. Strategy writes retain source order and each write depends on the prior +DataFrame version. Mask overlap, last-write-wins assignment, and compound append therefore +cannot be reordered. + +Raw strings are preserved byte-for-byte, including repeated and trailing whitespace. NFI route +lookup uses Python `str.split()` token order without replacing the stored value. For example, +`"101 562 "` remains the trade's original tag while its canonical route is `("101", "562")`. +Signal numbers and tag strings are program data, never Python or Rust execution branches. +Formatted fragments such as `f"{signal_id} "` lower to a generic `format-string` node followed +by an ordered append, so adding another numeric Signal does not require a runtime code branch. + +`tag-program-v1` also records numeric signal writes that share the same source function. This +keeps tag masks and read-after-write behavior exact while `signal-program-v1` remains the public +raw-signal contract. M21-03 will run the compiled program independently in Rust and compare every +Indicator, Signal, Tag, and execution-index output. + +## Evidence and limits + +The committed oracle at `benchmarks/reference/tags/freqtrade-2026.5.1.json` executes the exact +pinned `IStrategy.advise_entry` and `advise_exit` methods. It covers simultaneous Long/Short +matches, assignment priority, literal replacement, compound appends, nullable masks, wrapper +initialization, and original trailing whitespace. + +```bash +uv run python scripts/generate_tag_fixture.py +uv run pytest -q tests/test_tag_program.py tests/test_tag_fixture.py +nfi-bte strategy tag-program strategy.py --class Strategy --output tag-program.json +``` + +Dynamic configuration and loop lowering not yet represented by the source compiler fail closed +with an exact source location. They are not inferred from Signal IDs, source hashes, pairs, or +expected results. diff --git a/docs/native-vector-core.md b/docs/native-vector-core.md index d770ae0c..ed6691c8 100644 --- a/docs/native-vector-core.md +++ b/docs/native-vector-core.md @@ -125,9 +125,9 @@ The latest X7 indicator operation set now has exact Native kernels. This does no yet claim that the entire latest X7 source compiles or executes Full Native. M20-05 establishes exact multi-timeframe primitives and compiler contracts, but their in-memory `VectorEngine` connection is deliberately owned by M21. -M21 also owns Native -signal assignments, tag generation, independent Python/Rust vector shadowing, -and the in-memory simulator connection. M22 owns latest-upstream Spot/Futures +M21-01 and M21-02 establish Native signal assignment and exact tag-generation +contracts. M21-03 and M21-04 own independent Python/Rust vector shadowing and +the in-memory simulator connection. M22 owns latest-upstream Spot/Futures full-state qualification, removal of Python strategy execution from the Native lane, and release certification. Until those proofs pass, unsupported source constructs remain fail-closed and the official Freqtrade fallback remains the diff --git a/python/nfi_backtest_engine/cli.py b/python/nfi_backtest_engine/cli.py index 3f307f31..d3ca7c3a 100644 --- a/python/nfi_backtest_engine/cli.py +++ b/python/nfi_backtest_engine/cli.py @@ -704,6 +704,18 @@ def build_parser() -> argparse.ArgumentParser: default="spot", ) strategy_signal_program.add_argument("--output", "-o", type=Path, required=True) + strategy_tag_program = strategy_commands.add_parser( + "tag-program", + help="compile exact ordered entry and exit tags into tag-program-v1", + ) + strategy_tag_program.add_argument("source", type=Path) + strategy_tag_program.add_argument("--class", dest="class_name") + strategy_tag_program.add_argument( + "--trading-mode", + choices=("spot", "futures"), + default="spot", + ) + strategy_tag_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 fe390b14..b03aacd3 100644 --- a/python/nfi_backtest_engine/commands/run.py +++ b/python/nfi_backtest_engine/commands/run.py @@ -329,6 +329,24 @@ def _execute_strategy(args: argparse.Namespace) -> int: ) print(f"signal program report: {args.output}") return 0 + if args.strategy_command == "tag-program": + from ..tag_program import compile_tag_program + + program = compile_tag_program( + args.source, + class_name=args.class_name, + trading_mode=args.trading_mode, + ) + write_json(args.output, program) + print( + "tag program: " + f"class={program['selected_class']}, " + f"mode={program['compile_context']['trading_mode']}, " + f"nodes={len(program['nodes'])}, " + f"tag_mutations={len(program['tag_mutation_nodes'])}" + ) + print(f"tag 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/schemas/tag-program-v1.schema.json b/python/nfi_backtest_engine/schemas/tag-program-v1.schema.json new file mode 100644 index 00000000..3e36915b --- /dev/null +++ b/python/nfi_backtest_engine/schemas/tag-program-v1.schema.json @@ -0,0 +1,258 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/vntrevx/NFI_BackTestEngine/schemas/tag-program-v1.schema.json", + "title": "Tag Program v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "source", + "selected_class", + "compile_context", + "entrypoints", + "functions", + "nodes", + "tag_outputs", + "route_contract", + "required_input_columns", + "mutation_nodes", + "tag_mutation_nodes", + "opcodes", + "max_lookback", + "source_map", + "fingerprint" + ], + "properties": { + "schema_version": {"const": "tag-program-v1"}, + "source": {"$ref": "#/$defs/source"}, + "selected_class": {"$ref": "#/$defs/non_empty_string"}, + "compile_context": {"$ref": "#/$defs/compile_context"}, + "entrypoints": { + "type": "array", + "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"} + }, + "tag_outputs": { + "type": "array", + "prefixItems": [ + {"$ref": "#/$defs/entry_tag_output"}, + {"$ref": "#/$defs/exit_tag_output"} + ], + "items": false + }, + "route_contract": { + "type": "object", + "additionalProperties": false, + "required": ["canonicalization", "original_storage", "trailing_whitespace"], + "properties": { + "canonicalization": {"const": "python-str-split"}, + "original_storage": {"const": "preserve-exact"}, + "trailing_whitespace": {"const": "preserve"} + } + }, + "required_input_columns": {"$ref": "#/$defs/strings"}, + "mutation_nodes": {"$ref": "#/$defs/node_ids"}, + "tag_mutation_nodes": {"$ref": "#/$defs/node_ids"}, + "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}, + "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"} + }, + "node_ids": { + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/node_id"} + }, + "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", + "string-column", + "timestamp-column" + ] + }, + "opcode": { + "enum": [ + "parameter", + "literal", + "column-read", + "metadata-read", + "frame-write", + "format-string", + "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": {"type": ["string", "null"]}, + "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"} + } + }, + "entry_tag_output": { + "type": "object", + "additionalProperties": false, + "required": ["column", "phase", "wrapper_initializer", "final_mutation"], + "properties": { + "column": {"const": "enter_tag"}, + "phase": {"const": "entry"}, + "wrapper_initializer": {"const": ""}, + "final_mutation": {"type": ["string", "null"], "pattern": "^n[1-9][0-9]*$"} + } + }, + "exit_tag_output": { + "type": "object", + "additionalProperties": false, + "required": ["column", "phase", "wrapper_initializer", "final_mutation"], + "properties": { + "column": {"const": "exit_tag"}, + "phase": {"const": "exit"}, + "wrapper_initializer": {"const": ""}, + "final_mutation": {"type": ["string", "null"], "pattern": "^n[1-9][0-9]*$"} + } + } + } +} diff --git a/python/nfi_backtest_engine/specs.py b/python/nfi_backtest_engine/specs.py index 9af382e5..37c5e7dc 100644 --- a/python/nfi_backtest_engine/specs.py +++ b/python/nfi_backtest_engine/specs.py @@ -36,6 +36,7 @@ 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" +TAG_PROGRAM_SCHEMA = "tag-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/tag_fixture.py b/python/nfi_backtest_engine/tag_fixture.py new file mode 100644 index 00000000..30dc40c9 --- /dev/null +++ b/python/nfi_backtest_engine/tag_fixture.py @@ -0,0 +1,89 @@ +"""Generate deterministic tag evidence from pinned Freqtrade wrappers.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +import numpy as np +import pandas as pd + +from .signal_fixture import ( + _canonical_json, + _encode_frame, + _load_strategy, + _repository_root, + _sha256_file, + _source_commit, + _source_version, + canonical_sha256, +) + +PINNED_SOURCE = Path(".nfi/roadmap-acceptance/M20-05/freqtrade-2026.5.1") +CONTRACT_PATH = Path("benchmarks/reference/strategies/TagProgramContract.py") +FIXTURE_PATH = Path("benchmarks/reference/tags/freqtrade-2026.5.1.json") + + +def generate_fixture(source_root: Path | None = None) -> dict[str, object]: + """Execute exact pinned advise_entry/advise_exit around the tag 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-tag-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 tag 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 encode_tag_columns(frame: pd.DataFrame) -> dict[str, object]: + """Encode raw tag strings without canonicalizing or trimming them.""" + return _encode_frame(frame.loc[:, ["enter_tag", "exit_tag"]]) + + +def assert_fixture_identity(fixture: Mapping[str, object]) -> None: + """Reject a changed or self-inconsistent committed oracle.""" + if fixture.get("schema_version") != "freqtrade-tag-fixture-v1": + raise ValueError("tag fixture schema differs") + if fixture.get("fingerprint") != canonical_sha256(fixture): + raise ValueError("tag fixture fingerprint differs") + + +def _input_frame() -> pd.DataFrame: + return pd.DataFrame( + { + "score": [-2.0, -0.5, 0.0, 0.5, 1.5, 2.0, 2.5, np.nan], + "exit_mask": pd.array( + [False, True, False, True, False, True, pd.NA, False], + dtype="boolean", + ), + "enter_tag": ["stale-entry"] * 8, + "exit_tag": ["stale-exit"] * 8, + } + ) diff --git a/python/nfi_backtest_engine/tag_program/__init__.py b/python/nfi_backtest_engine/tag_program/__init__.py new file mode 100644 index 00000000..b08f6311 --- /dev/null +++ b/python/nfi_backtest_engine/tag_program/__init__.py @@ -0,0 +1,15 @@ +"""Compile and execute source-ordered Native tag generation.""" + +from .compiler import TAG_PROGRAM_VERSION, TagProgramCompileError, compile_tag_program +from .runtime import TagProgramExecutionError, canonical_tag_route, execute_tag_program +from .validation import validate_tag_program + +__all__ = [ + "TAG_PROGRAM_VERSION", + "TagProgramCompileError", + "TagProgramExecutionError", + "canonical_tag_route", + "compile_tag_program", + "execute_tag_program", + "validate_tag_program", +] diff --git a/python/nfi_backtest_engine/tag_program/compiler.py b/python/nfi_backtest_engine/tag_program/compiler.py new file mode 100644 index 00000000..22f01a64 --- /dev/null +++ b/python/nfi_backtest_engine/tag_program/compiler.py @@ -0,0 +1,385 @@ +"""Static compiler for exact Freqtrade tag generation.""" + +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 ..signal_program.compiler import ( + _is_full_slice, + _literal_columns, + _literal_string, + _numeric_record_id, + _SignalCompiler, +) +from ..strategy_ir import analyze_strategy +from .validation import ( + OUTPUT_PHASES, + TAG_COLUMNS, + fingerprint_program, + merge_lookbacks, + validate_tag_program, +) + +TAG_PROGRAM_VERSION = "tag-program-v1" +_NUMERIC_VALUE_TYPES = { + "bool-scalar", + "int-scalar", + "f64-scalar", + "bool-column", + "f64-column", +} +_STRING_VALUE_TYPES = {"null", "string-scalar", "string-column"} + + +class TagProgramCompileError(StrategyAnalysisError): + """Tag source cannot be represented exactly by tag-program-v1.""" + + +def compile_tag_program( + source: str | Path, + *, + class_name: str | None = None, + trading_mode: str = "spot", +) -> dict[str, Any]: + """Compile ordered signal and tag writes without executing strategy Python.""" + if trading_mode not in {"spot", "futures"}: + raise TagProgramCompileError(f"unsupported tag 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 TagProgramCompileError("tag 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 TagProgramCompileError("tag 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 TagProgramCompileError("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 TagProgramCompileError(f"strategy does not define {method_name}") + if isinstance(method, ast.AsyncFunctionDef): + _unsupported(method, "async tag entrypoint") + + constants = strategy.get("constants", {}) + compiler = _TagCompiler( + path=path, + methods=methods, + class_constants=constants if isinstance(constants, Mapping) else {}, + ) + 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 TagProgramCompileError: + raise + except StrategyAnalysisError as exc: + raise TagProgramCompileError(str(exc)) from exc + + program: dict[str, Any] = { + "schema_version": TAG_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, + "tag_outputs": [ + { + "column": column, + "phase": OUTPUT_PHASES[column], + "wrapper_initializer": "", + "final_mutation": compiler.final_mutations.get(column), + } + for column in TAG_COLUMNS + ], + "route_contract": { + "canonicalization": "python-str-split", + "original_storage": "preserve-exact", + "trailing_whitespace": "preserve", + }, + "required_input_columns": sorted(compiler.required_input_columns), + "mutation_nodes": compiler.mutation_nodes, + "tag_mutation_nodes": compiler.tag_mutation_nodes, + "opcodes": sorted(compiler.opcodes), + "max_lookback": merge_lookbacks(compiler.nodes), + "source_map": compiler.source_map, + } + program["fingerprint"] = fingerprint_program(program) + validate_tag_program(program) + return program + + +class _TagCompiler(_SignalCompiler): + """Compile the shared signal/tag frame while classifying tag mutations.""" + + 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.tag_mutation_nodes: list[str] = [] + + def statement(self, node: ast.stmt) -> None: + if isinstance(node, ast.AugAssign): + if not isinstance(node.op, ast.Add): + self.unsupported(node, "non-additive tag augmented assignment") + target = node.target + if self._is_loc_target(target): + assert isinstance(target, ast.Subscript) + self._loc_write(target, node.value, node, append=True) + return + if isinstance(target, ast.Subscript): + self.column_write(target, node.value, node, append=True) + return + self.unsupported(target, "tag augmented assignment target") + super().statement(node) + + def expression(self, node: ast.expr) -> str: + if isinstance(node, ast.JoinedStr): + return self._format_string(node) + return super().expression(node) + + def column_write( + self, + target: ast.Subscript, + value_node: ast.expr, + node: ast.AST, + *, + append: bool = False, + ) -> 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 tag output column") + self._require_output_columns(target, [column]) + value = self.expression(value_node) + assignment = "string-append" if append else "column-values" + self._require_assignment_values(value_node, [column], [value], assignment) + written = self._emit_write( + node, + dataframe=dataframe, + mask=None, + values=[value], + columns=[column], + mode="column", + assignment=assignment, + ) + self.bindings[target.value.id] = written + if column in TAG_COLUMNS: + self.tag_mutation_nodes.append(written) + + def _loc_write( + self, + target: ast.Subscript, + value_node: ast.expr, + node: ast.AST, + *, + append: bool = False, + ) -> None: + assert isinstance(target.value, ast.Attribute) + owner = target.value.value + if not isinstance(owner, ast.Name): + self.unsupported(owner, "nested tag 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, "tag 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 tag loc columns") + if len(set(columns)) != len(columns): + self.unsupported(columns_node, "duplicate tag loc columns") + self._require_output_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 tag loc mask") + + if append and (len(columns) != 1 or columns[0] not in TAG_COLUMNS): + self.unsupported(target, "tag append must target one tag column") + assignment = "string-append" if append else "column-values" + value_nodes: Sequence[ast.expr] + if not append and len(columns) > 1 and isinstance(value_node, ast.Tuple | ast.List): + if len(value_node.elts) != len(columns): + self.unsupported(value_node, "tag loc value arity") + value_nodes = value_node.elts + elif not append and len(columns) > 1: + assignment = "scalar-broadcast" + value_nodes = [value_node] + else: + value_nodes = [value_node] + values = [self.expression(value) for value in value_nodes] + self._require_assignment_values(value_node, columns, values, assignment) + written = self._emit_write( + node, + dataframe=dataframe, + mask=mask, + values=values, + columns=columns, + mode="loc", + assignment=assignment, + ) + self.bindings[owner.id] = written + if any(column in TAG_COLUMNS for column in columns): + self.tag_mutation_nodes.append(written) + + def subscript(self, node: ast.Subscript) -> str: + value = super().subscript(node) + column = _literal_string(node.slice) + if column in TAG_COLUMNS and self.nodes[int(value[1:]) - 1]["op"] == "column-read": + self.nodes[int(value[1:]) - 1]["value_type"] = "string-column" + self.node_types[value] = "string-column" + self.required_input_columns.discard(column) + return value + + def binary(self, node: ast.BinOp) -> str: + if isinstance(node.op, ast.Add): + left = self.expression(node.left) + right = self.expression(node.right) + value_types = {self.node_types[left], self.node_types[right]} + if value_types & _STRING_VALUE_TYPES: + if not value_types <= _STRING_VALUE_TYPES: + self.unsupported(node, "mixed string and numeric tag concatenation") + value_type = ( + "string-column" if "string-column" in value_types else "string-scalar" + ) + return self.emit( + node, + "binary", + value_type, + inputs=[left, right], + parameters={"operator": "add"}, + lookback=self.merged_lookback([left, right]), + ) + return super().binary(node) + + def unsupported(self, node: ast.AST, description: str) -> Never: + _unsupported(node, description) + + def _format_string(self, node: ast.JoinedStr) -> str: + inputs: list[str] = [] + segments = [""] + for value in node.values: + if isinstance(value, ast.Constant) and isinstance(value.value, str): + segments[-1] += value.value + continue + if not isinstance(value, ast.FormattedValue): + self.unsupported(value, "dynamic formatted tag component") + if value.conversion not in {-1, ord("s")} or value.format_spec is not None: + self.unsupported(value, "formatted tag conversion or format specification") + input_id = self.expression(value.value) + if self.node_types[input_id] not in { + "bool-scalar", + "int-scalar", + "f64-scalar", + "string-scalar", + }: + self.unsupported(value, "non-scalar formatted tag value") + inputs.append(input_id) + segments.append("") + return self.emit( + node, + "format-string", + "string-scalar", + inputs=inputs, + parameters={"segments": segments}, + lookback=self.merged_lookback(inputs), + ) + + def _require_output_columns(self, node: ast.AST, columns: Sequence[str]) -> None: + if self.current_function not in {"f1", "f2"}: + self.unsupported(node, "dataframe output mutation inside a helper") + for column in columns: + phase = OUTPUT_PHASES.get(column) + if phase is None: + self.unsupported(node, f"non-signal/tag dataframe output {column!r}") + if phase != self.current_phase: + self.unsupported(node, f"{column} mutation during the {self.current_phase} phase") + + def _require_assignment_values( + self, + node: ast.AST, + columns: Sequence[str], + values: Sequence[str], + assignment: str, + ) -> None: + if assignment == "scalar-broadcast": + wants_tag = any(column in TAG_COLUMNS for column in columns) + wants_numeric = any(column not in TAG_COLUMNS for column in columns) + if wants_tag and wants_numeric: + self.unsupported(node, "mixed signal/tag scalar broadcast") + expected = _STRING_VALUE_TYPES if wants_tag else _NUMERIC_VALUE_TYPES + if self.node_types[values[0]] not in expected: + self.unsupported(node, "scalar broadcast value type") + return + if len(columns) != len(values): + self.unsupported(node, "tag assignment value arity") + for column, value in zip(columns, values, strict=True): + value_type = self.node_types[value] + if column in TAG_COLUMNS: + excluded = {"null"} if assignment == "string-append" else set() + allowed = _STRING_VALUE_TYPES - excluded + if value_type not in allowed: + self.unsupported(node, "non-string tag assignment value") + elif value_type 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 TagProgramCompileError( + f"{location['path']}:{location['line']}:{location['column']}: " + f"{first['code']}: {first['message']}" + ) + if len(analysis["strategies"]) != 1: + raise TagProgramCompileError("tag program compilation requires one selected strategy") + return analysis["strategies"][0] + + +def _unsupported(node: ast.AST, description: str) -> Never: + line = getattr(node, "lineno", 1) + column = getattr(node, "col_offset", 0) + raise TagProgramCompileError( + f"strategy.py:{line}:{column}: tag-program-v1 does not support {description}" + ) diff --git a/python/nfi_backtest_engine/tag_program/runtime.py b/python/nfi_backtest_engine/tag_program/runtime.py new file mode 100644 index 00000000..08dd3225 --- /dev/null +++ b/python/nfi_backtest_engine/tag_program/runtime.py @@ -0,0 +1,127 @@ +"""Independent Python reference executor for tag-program-v1.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +import pandas as pd + +from ..signal_program.runtime import ( + SignalProgramExecutionError, + _require_frame, + _require_mask, +) +from ..signal_program.runtime import ( + _Runtime as _SignalRuntime, +) +from .validation import validate_tag_program + + +class TagProgramExecutionError(SignalProgramExecutionError): + """A validated tag program cannot execute with the supplied frame.""" + + +def execute_tag_program( + program: Mapping[str, Any], + dataframe: pd.DataFrame, + *, + metadata: Mapping[str, Any] | None = None, +) -> pd.DataFrame: + """Execute Freqtrade wrapper initialization and ordered strategy mutations.""" + validate_tag_program(program) + runtime = _Runtime(program) + frame = dataframe.copy(deep=True) + metadata_value = dict(metadata or {}) + initializer_by_phase = { + output["phase"]: (output["column"], output["wrapper_initializer"]) + for output in program["tag_outputs"] + } + for entrypoint in program["entrypoints"]: + column, initializer = initializer_by_phase[entrypoint["phase"]] + frame.loc[:, column] = initializer + value = runtime.function(entrypoint["function"], [frame, metadata_value]) + if not isinstance(value, pd.DataFrame): + raise TagProgramExecutionError( + f"tag {entrypoint['phase']} entrypoint did not return a DataFrame" + ) + frame = value + return frame + + +def canonical_tag_route(value: str | None) -> tuple[str, ...]: + """Return NFI's whitespace-token route without altering the stored tag.""" + return () if value is None else tuple(value.split()) + + +class _Runtime(_SignalRuntime): + def _node( + self, + node: Mapping[str, Any], + values: Mapping[str, Any], + parameters: Mapping[str, Any], + ) -> Any: + if node["op"] == "format-string": + inputs = [values[input_id] for input_id in node["inputs"]] + segments = node["parameters"]["segments"] + if len(segments) != len(inputs) + 1: + raise TagProgramExecutionError( + f"tag node {node['id']} format-string segment count differs" + ) + result = segments[0] + for value, suffix in zip(inputs, segments[1:], strict=True): + result += f"{value}{suffix}" + return result + if node["op"] == "frame-write": + inputs = [values[input_id] for input_id in node["inputs"]] + try: + return _frame_write(node, inputs) + except TagProgramExecutionError: + raise + except Exception as exc: + raise TagProgramExecutionError( + f"tag node {node['id']} (frame-write) failed: {exc}" + ) from exc + try: + return super()._node(node, values, parameters) + except SignalProgramExecutionError as exc: + raise TagProgramExecutionError(str(exc).replace("signal node", "tag node")) from exc + + +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: Any = 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"] + if assignment == "scalar-broadcast": + assigned: Any = values[0] + else: + assigned = values[0] if len(columns) == 1 else values + + rows: Any = slice(None) if mask is None else mask + selector: Any = columns[0] if len(columns) == 1 else columns + if assignment == "string-append": + if len(columns) != 1 or len(values) != 1: + raise TagProgramExecutionError( + f"tag node {node['id']} has an invalid append contract" + ) + frame.loc[rows, selector] = frame.loc[rows, selector] + assigned + elif options["mode"] == "column": + if mask is not None or len(columns) != 1: + raise TagProgramExecutionError( + f"tag node {node['id']} has an invalid direct-column contract" + ) + frame[columns[0]] = assigned + elif options["mode"] == "loc": + frame.loc[rows, selector] = assigned + else: + raise TagProgramExecutionError( + f"tag node {node['id']} has unknown assignment mode {options['mode']!r}" + ) + return frame diff --git a/python/nfi_backtest_engine/tag_program/validation.py b/python/nfi_backtest_engine/tag_program/validation.py new file mode 100644 index 00000000..827adbfd --- /dev/null +++ b/python/nfi_backtest_engine/tag_program/validation.py @@ -0,0 +1,271 @@ +"""Semantic validation and content identity for tag-program-v1.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ..errors import SpecValidationError +from ..signal_program.validation import fingerprint_program, merge_lookbacks +from ..specs import TAG_PROGRAM_SCHEMA, validate_schema + +TAG_COLUMNS = ("enter_tag", "exit_tag") +OUTPUT_PHASES = { + "enter_long": "entry", + "enter_short": "entry", + "enter_tag": "entry", + "exit_long": "exit", + "exit_short": "exit", + "exit_tag": "exit", +} +_NUMERIC_VALUE_TYPES = { + "bool-scalar", + "int-scalar", + "f64-scalar", + "bool-column", + "f64-column", +} +_STRING_VALUE_TYPES = {"null", "string-scalar", "string-column"} + + +def validate_tag_program(program: Any) -> None: + """Validate ordered writes, wrapper initialization, raw storage, and identity.""" + validate_schema(program, TAG_PROGRAM_SCHEMA) + if not isinstance(program, Mapping): # pragma: no cover - schema owns it + return + + nodes = program["nodes"] + actual_node_ids = [node["id"] for node in nodes] + if actual_node_ids != [f"n{index}" for index in range(1, len(nodes) + 1)]: + raise SpecValidationError("tag-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"tag-program-v1 node {node['id']} has a non-prior input {input_id}" + ) + + functions = program["functions"] + if [item["id"] for item in functions] != [ + f"f{index}" for index in range(1, len(functions) + 1) + ]: + raise SpecValidationError("tag-program-v1 function IDs are not canonical") + function_by_id = {item["id"]: item for item in functions} + if [(item["phase"], item["function"]) for item in program["entrypoints"]] != [ + ("entry", "f1"), + ("exit", "f2"), + ]: + raise SpecValidationError("tag-program-v1 entrypoints are not canonical") + for entrypoint in program["entrypoints"]: + function = function_by_id.get(entrypoint["function"]) + expected_name = f"populate_{entrypoint['phase']}_trend" + if function is None or function["source_name"] != expected_name or function["kind"] != ( + f"entrypoint-{entrypoint['phase']}" + ): + raise SpecValidationError( + f"tag-program-v1 {entrypoint['phase']} entrypoint identity differs" + ) + + owned_nodes: set[str] = set() + for function in functions: + for source_order, node_id in enumerate(function["node_ids"]): + position = positions.get(node_id) + if position is None: + raise SpecValidationError( + f"tag-program-v1 function {function['id']} references a missing node" + ) + node = nodes[position] + if node["function"] != function["id"] or node["source_order"] != source_order: + raise SpecValidationError( + f"tag-program-v1 function {function['id']} node ownership differs" + ) + if node_id in owned_nodes: + raise SpecValidationError( + f"tag-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"tag-program-v1 function {function['id']} return node is external" + ) + if owned_nodes != set(actual_node_ids): + raise SpecValidationError("tag-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("tag-program-v1 mutation inventory differs from nodes") + tag_mutation_nodes = [ + node["id"] + for node in nodes + if node["op"] == "frame-write" + and any(column in TAG_COLUMNS for column in node["parameters"].get("columns", [])) + ] + if program["tag_mutation_nodes"] != tag_mutation_nodes: + raise SpecValidationError("tag-program-v1 tag mutation inventory differs from nodes") + if set(program["source_map"]) != set(actual_node_ids): + raise SpecValidationError("tag-program-v1 source map does not cover every node") + if program["opcodes"] != sorted({node["op"] for node in nodes}): + raise SpecValidationError("tag-program-v1 opcode inventory differs from nodes") + if program["required_input_columns"] != sorted(program["required_input_columns"]): + raise SpecValidationError("tag-program-v1 input columns are not canonical") + if program["max_lookback"] != merge_lookbacks(nodes): + raise SpecValidationError("tag-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"] == "format-string": + _validate_format_string(node, node_by_id) + 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 OUTPUT_PHASES[column] != phase: + raise SpecValidationError( + f"tag-program-v1 {column} is written during the {phase} phase" + ) + if column in TAG_COLUMNS: + final_by_column[column] = node["id"] + expected_outputs = [ + { + "column": column, + "phase": OUTPUT_PHASES[column], + "wrapper_initializer": "", + "final_mutation": final_by_column.get(column), + } + for column in TAG_COLUMNS + ] + if program["tag_outputs"] != expected_outputs: + raise SpecValidationError("tag-program-v1 final output inventory differs") + if program["route_contract"] != { + "canonicalization": "python-str-split", + "original_storage": "preserve-exact", + "trailing_whitespace": "preserve", + }: + raise SpecValidationError("tag-program-v1 route contract differs") + + identity = dict(program) + fingerprint = identity.pop("fingerprint") + if fingerprint != fingerprint_program(identity): + raise SpecValidationError("tag-program-v1 fingerprint differs") + + +def _validate_format_string( + node: Mapping[str, Any], + node_by_id: Mapping[str, Mapping[str, Any]], +) -> None: + parameters = node["parameters"] + segments = parameters.get("segments") + if ( + set(parameters) != {"segments"} + or not isinstance(segments, list) + or len(segments) != len(node["inputs"]) + 1 + or any(not isinstance(segment, str) for segment in segments) + or node["value_type"] != "string-scalar" + ): + raise SpecValidationError( + f"tag-program-v1 node {node['id']} format-string contract is invalid" + ) + allowed = {"bool-scalar", "int-scalar", "f64-scalar", "string-scalar"} + if any(node_by_id[input_id]["value_type"] not in allowed for input_id in node["inputs"]): + raise SpecValidationError( + f"tag-program-v1 node {node['id']} format-string input type 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"tag-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", "string-append"} + ): + raise SpecValidationError( + f"tag-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 OUTPUT_PHASES for column in columns) + ): + raise SpecValidationError( + f"tag-program-v1 node {node['id']} frame-write columns are invalid" + ) + if mode == "column" and (rows != "all" or len(columns) != 1): + raise SpecValidationError( + f"tag-program-v1 node {node['id']} direct-column contract is invalid" + ) + if assignment == "string-append" and ( + len(columns) != 1 or columns[0] not in TAG_COLUMNS + ): + raise SpecValidationError( + f"tag-program-v1 node {node['id']} append target is invalid" + ) + + inputs = node["inputs"] + expected_values = 1 if assignment in {"scalar-broadcast", "string-append"} else len(columns) + expected_inputs = 1 + int(rows == "mask") + expected_values + if len(inputs) != expected_inputs: + raise SpecValidationError( + f"tag-program-v1 node {node['id']} frame-write input arity differs" + ) + if node_by_id[inputs[0]]["value_type"] != "dataframe": + raise SpecValidationError( + f"tag-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"tag-program-v1 node {node['id']} frame-write mask type differs" + ) + value_offset = 2 + value_types = [node_by_id[input_id]["value_type"] for input_id in inputs[value_offset:]] + if assignment == "scalar-broadcast": + wants_tag = any(column in TAG_COLUMNS for column in columns) + wants_numeric = any(column not in TAG_COLUMNS for column in columns) + allowed = _STRING_VALUE_TYPES if wants_tag else _NUMERIC_VALUE_TYPES + if (wants_tag and wants_numeric) or value_types[0] not in allowed: + raise SpecValidationError( + f"tag-program-v1 node {node['id']} scalar broadcast type differs" + ) + return + for column, value_type in zip(columns, value_types, strict=True): + allowed = _STRING_VALUE_TYPES if column in TAG_COLUMNS else _NUMERIC_VALUE_TYPES + if assignment == "string-append": + allowed = allowed - {"null"} + if value_type not in allowed: + raise SpecValidationError( + f"tag-program-v1 node {node['id']} frame-write value type differs" + ) + + +__all__ = [ + "OUTPUT_PHASES", + "TAG_COLUMNS", + "fingerprint_program", + "merge_lookbacks", + "validate_tag_program", +] diff --git a/scripts/generate_tag_fixture.py b/scripts/generate_tag_fixture.py new file mode 100644 index 00000000..2b34c9ea --- /dev/null +++ b/scripts/generate_tag_fixture.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Regenerate the pinned Freqtrade tag-generation 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.tag_fixture") + module.write_fixture(ROOT / module.FIXTURE_PATH) + + +if __name__ == "__main__": + main() diff --git a/tests/test_tag_fixture.py b/tests/test_tag_fixture.py new file mode 100644 index 00000000..82380bf5 --- /dev/null +++ b/tests/test_tag_fixture.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +from nfi_backtest_engine.signal_fixture import decode_frame +from nfi_backtest_engine.tag_fixture import ( + CONTRACT_PATH, + FIXTURE_PATH, + PINNED_SOURCE, + assert_fixture_identity, + encode_tag_columns, + generate_fixture, +) +from nfi_backtest_engine.tag_program import compile_tag_program, execute_tag_program + +ROOT = Path(__file__).parents[1] + + +def test_committed_tag_fixture_has_pinned_identity_and_compound_rows() -> None: + fixture = json.loads((ROOT / FIXTURE_PATH).read_text(encoding="utf-8")) + + assert_fixture_identity(fixture) + assert fixture["source"]["version"] == "2026.5.1" + assert fixture["source"]["commit"] == "6fa470939cc74bf0672e0e348a4d9b293072e43c" + assert fixture["source"]["interface_sha256"] == ( + "93ddb2f5579acd7a20d489174ffb68cd191428ff996d291b33be81d97fa9bf66" + ) + contract = ROOT / CONTRACT_PATH + assert fixture["source"]["strategy_sha256"] == hashlib.sha256(contract.read_bytes()).hexdigest() + output = decode_frame(fixture["output"]) + assert output.loc[2, "enter_tag"] == "101 562 " + assert output.loc[5, "enter_tag"] == "override final " + assert output.loc[5, "exit_tag"] == "profit signal " + + +def test_tag_program_matches_committed_freqtrade_fixture_exactly() -> None: + fixture = json.loads((ROOT / FIXTURE_PATH).read_text(encoding="utf-8")) + frame = decode_frame(fixture["input"]) + program = compile_tag_program(ROOT / CONTRACT_PATH, class_name="TagProgramContract") + + output = execute_tag_program(program, frame, metadata={"pair": "ETH/USDT"}) + expected = decode_frame(fixture["output"]) + + assert encode_tag_columns(output) == encode_tag_columns(expected) + for column in ("enter_long", "enter_short", "exit_long", "exit_short"): + assert output[column].tolist() == expected[column].tolist() + + +def test_tag_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(source) == stored diff --git a/tests/test_tag_program.py b/tests/test_tag_program.py new file mode 100644 index 00000000..1795ddc1 --- /dev/null +++ b/tests/test_tag_program.py @@ -0,0 +1,230 @@ +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.specs import TAG_PROGRAM_SCHEMA, validate_schema +from nfi_backtest_engine.tag_program import ( + TagProgramCompileError, + TagProgramExecutionError, + canonical_tag_route, + compile_tag_program, + execute_tag_program, + validate_tag_program, +) + +ROOT = Path(__file__).parents[1] +CONTRACT = ROOT / "benchmarks" / "reference" / "strategies" / "TagProgramContract.py" + + +def _input_frame() -> pd.DataFrame: + return pd.DataFrame( + { + "score": [-2.0, -0.5, 0.0, 0.5, 1.5, 2.0, 2.5, float("nan")], + "exit_mask": pd.array( + [False, True, False, True, False, True, pd.NA, False], + dtype="boolean", + ), + "enter_tag": ["stale-entry"] * 8, + "exit_tag": ["stale-exit"] * 8, + } + ) + + +def test_tag_program_compiles_ordered_literal_and_compound_mutations() -> None: + program = compile_tag_program(CONTRACT, class_name="TagProgramContract") + + validate_schema(program, TAG_PROGRAM_SCHEMA) + validate_tag_program(program) + assert program["entrypoints"] == [ + {"phase": "entry", "function": "f1"}, + {"phase": "exit", "function": "f2"}, + ] + writes = [node for node in program["nodes"] if node["op"] == "frame-write"] + tag_writes = [ + node + for node in writes + if any(column.endswith("_tag") for column in node["parameters"]["columns"]) + ] + assert program["mutation_nodes"] == [node["id"] for node in writes] + assert program["tag_mutation_nodes"] == [node["id"] for node in tag_writes] + assert program["tag_outputs"] == [ + { + "column": "enter_tag", + "phase": "entry", + "wrapper_initializer": "", + "final_mutation": tag_writes[3]["id"], + }, + { + "column": "exit_tag", + "phase": "exit", + "wrapper_initializer": "", + "final_mutation": tag_writes[-1]["id"], + }, + ] + assert [node["parameters"]["assignment"] for node in tag_writes] == [ + "string-append", + "string-append", + "column-values", + "string-append", + "column-values", + "string-append", + ] + assert program["required_input_columns"] == ["exit_mask", "score"] + formatted = [node for node in program["nodes"] if node["op"] == "format-string"] + assert [node["parameters"]["segments"] for node in formatted] == [["", " "], ["", " "]] + assert all(node["value_type"] == "string-scalar" for node in formatted) + assert program["route_contract"]["original_storage"] == "preserve-exact" + assert len(program["fingerprint"]) == 64 + + +def test_tag_program_executes_priority_and_original_whitespace_exactly() -> None: + frame = _input_frame() + program = compile_tag_program( + CONTRACT, + class_name="TagProgramContract", + trading_mode="futures", + ) + + actual = execute_tag_program(program, frame, metadata={"pair": "ETH/USDT"}) + + assert actual["enter_tag"].tolist() == [ + "562 ", + "562 ", + "101 562 ", + "101 ", + "101 ", + "override final ", + "override final ", + "", + ] + assert actual["exit_tag"].tolist() == [ + "", + "signal ", + "", + "signal ", + "profit ", + "profit signal ", + "profit ", + "", + ] + assert actual.loc[2, ["enter_long", "enter_short"]].tolist() == [1, 1] + assert actual.loc[2, "enter_tag"] == "101 562 " + assert actual.loc[5, "exit_tag"] == "profit signal " + assert frame["enter_tag"].eq("stale-entry").all() + assert frame["exit_tag"].eq("stale-exit").all() + + +def test_canonical_route_does_not_change_original_tag() -> None: + original = "101 562 \t" + + assert canonical_tag_route(original) == ("101", "562") + assert original == "101 562 \t" + assert canonical_tag_route("") == () + assert canonical_tag_route(None) == () + + +def test_tag_program_identity_rejects_order_or_route_mutation(tmp_path: Path) -> None: + copied = tmp_path / "Renamed.py" + copied.write_bytes(CONTRACT.read_bytes()) + first = compile_tag_program(CONTRACT, class_name="TagProgramContract") + second = compile_tag_program(copied, class_name="TagProgramContract") + assert first["fingerprint"] == second["fingerprint"] + + reordered = copy.deepcopy(first) + reordered["tag_mutation_nodes"][:2] = reversed(reordered["tag_mutation_nodes"][:2]) + with pytest.raises(SpecValidationError, match="tag mutation inventory differs"): + validate_tag_program(reordered) + + trimmed_contract = copy.deepcopy(first) + trimmed_contract["route_contract"]["trailing_whitespace"] = "trim" + with pytest.raises(SpecValidationError, match="tag-program-v1.schema.json"): + validate_tag_program(trimmed_contract) + + changed_location = copy.deepcopy(first) + changed_location["source_map"]["n1"]["line"] += 1 + with pytest.raises(SpecValidationError, match="fingerprint differs"): + validate_tag_program(changed_location) + + malformed_format = copy.deepcopy(first) + format_node = next(node for node in malformed_format["nodes"] if node["op"] == "format-string") + format_node["parameters"]["segments"] = [""] + with pytest.raises(SpecValidationError, match="format-string contract is invalid"): + validate_tag_program(malformed_format) + + +@pytest.mark.parametrize( + ("statement", "message"), + [ + ("dataframe.loc[:, 'enter_tag'] = 1", "non-string tag assignment"), + ("dataframe.loc[:, 'enter_long'] = '1'", "non-numeric signal assignment"), + ("dataframe.loc[:, 'exit_tag'] = 'wrong-phase'", "during the entry phase"), + ("dataframe.loc[:, 'feature'] = 'tag'", "non-signal/tag dataframe output"), + ("dataframe.loc[:, 'enter_tag'] -= 'tag'", "non-additive tag"), + ], +) +def test_tag_program_fails_closed_outside_exact_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(TagProgramCompileError, match=message): + compile_tag_program(source, class_name="Unsupported") + + +def test_tag_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_tag'] += '101 '\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_tag_program(source, class_name="NumericMask") + + with pytest.raises(TagProgramExecutionError, match="mask dtype is not boolean"): + execute_tag_program(program, pd.DataFrame({"mask": [0, 1]})) + + +def test_tag_program_parser_seals_mode_and_output() -> None: + args = cli.build_parser().parse_args( + [ + "strategy", + "tag-program", + "latest.py", + "--class", + "NostalgiaForInfinityX7", + "--trading-mode", + "futures", + "--output", + ".nfi/tag-program.json", + ] + ) + + assert args.strategy_command == "tag-program" + assert args.trading_mode == "futures" + assert args.output == Path(".nfi/tag-program.json") From 5593064e7dde0d7cb50e429f91603a7f2ee82571 Mon Sep 17 00:00:00 2001 From: vntrevx <20063774+vntrevx@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:32:05 +0900 Subject: [PATCH 3/3] chore(roadmap): complete M21-02 --- 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 fd046235..70aef9c8 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": 136, - "updated_at": "2026-08-11T18:03:14+09:00", + "revision": 137, + "updated_at": "2026-08-11T18:31:20+09:00", "acceptance_commands": "planning/acceptance-commands.json", - "active_task_id": "M21-02", + "active_task_id": null, "execution_policy": { "max_in_progress": 1, "selection": "lowest order pending task whose dependencies are completed", @@ -4841,7 +4841,7 @@ "order": 2102, "milestone": "M21", "title": "Compile exact Native tag generation", - "status": "in_progress", + "status": "completed", "depends_on": [ "M21-01" ], @@ -4861,9 +4861,17 @@ "compound-tag vector exactness" ], "started_at": "2026-08-11T18:03:14+09:00", - "completed_at": null, - "commit_sha": null, - "evidence": [], + "completed_at": "2026-08-11T18:31:20+09:00", + "commit_sha": "a85ae58bf9f689b03d221bf2ebde03371553fd61", + "evidence": [ + ".nfi/roadmap-acceptance/M21-02/a85ae58bf9f689b03d221bf2ebde03371553fd61/acceptance-report.json", + ".nfi/roadmap-acceptance/M21-02/a85ae58bf9f689b03d221bf2ebde03371553fd61/latest-tag-program-gap.json", + ".nfi/roadmap-acceptance/M21-02/a85ae58bf9f689b03d221bf2ebde03371553fd61/tag-program.json", + ".nfi/roadmap-acceptance/M21-02/a85ae58bf9f689b03d221bf2ebde03371553fd61/SHA256SUMS.txt", + ".nfi/roadmap-acceptance/M21-02/a85ae58bf9f689b03d221bf2ebde03371553fd61/x7-spot/run.json", + ".nfi/roadmap-acceptance/M21-02/a85ae58bf9f689b03d221bf2ebde03371553fd61/x7-futures/run.json", + "benchmarks/reference/tags/freqtrade-2026.5.1.json" + ], "blocker": null }, { @@ -8470,6 +8478,39 @@ "official_freqtrade_oracle_required": true, "latest_upstream_commit": "897a1523391b8222ee711eba9714b59a3e77265a" } + }, + { + "sequence": 184, + "timestamp": "2026-08-11T18:31:20+09:00", + "task_id": "M21-02", + "event": "task_completed", + "details": { + "implementation_commit": "a85ae58bf9f689b03d221bf2ebde03371553fd61", + "latest_upstream_commit": "897a1523391b8222ee711eba9714b59a3e77265a", + "latest_strategy_sha256": "e99b4f58ecf507da86d2ba94e641c2994f17a1456133c2cff26946fa6b4a1afb", + "freqtrade_version": "2026.5.1", + "tag_oracle_row_count": 8, + "tag_oracle_fingerprint": "11219ab2fae512e0e833f05c323a5cb8e47a724154a9d50ac126c6d8dbadba77", + "tag_program_node_count": 51, + "tag_mutation_count": 6, + "formatted_signal_id_is_program_data": true, + "compound_tag_order_exact": true, + "original_tag_whitespace_preserved": true, + "wrapper_initialization_exact": true, + "python_test_count": 800, + "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, + "tag_specific_execution_branches_added": false, + "latest_x7_fully_compiled": false, + "latest_x7_fail_closed_source": "strategy.py:13034:13", + "rust_vector_shadow_complete": false, + "full_native_strategy_claim": false, + "next_eligible_task": "M21-03", + "next_task_manual_gate": false + } } ] }