From 9322f812e9fecdb334c6c4684d7aabc48a8f4d9f Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 30 Sep 2026 13:55:07 +0800 Subject: [PATCH 1/6] Pin pineforge-release 1.0.0 and test the in-process runner against it Move the default runtime image and the server Dockerfile to pineforge-release 1.0.0 (engine 1.0.0, codegen 1.0.0), pinned by its multi-arch index digest. Tests now expect engine 1.0.0's C ABI 4: - unit tests pin the ABI 4 report layout, the refusal of ABI 2 and 3 libraries, and the open_at_end trade field; - the Docker suite compiles a strategy inside the pinned image, checks every mirrored struct against the image's pineforge.h with offsetof, and compares PineForgeBacktestRunner's report with the image's own run_json.py report for the same library. The Docker fixture strategy now states its order size (fixed, 1). Codegen 1.0 gives an omitted size TradingView's Pine v6 default, 100% of equity, and at that size every entry in the fixture's short series is refused for want of funds, so the run had no trades to compare. Co-Authored-By: Claude Opus 5.5 (1M context) --- docker/server.Dockerfile | 2 +- src/pineforge_data/release_contract.py | 6 +- tests/fixtures/sma_cross.pine | 2 +- tests/in_process_release_check.py | 255 +++++++++++++++++++++++++ tests/test_backtest.py | 38 +++- tests/test_docker_integration.py | 118 +++++++++++- tests/test_docker_runtime.py | 2 +- 7 files changed, 412 insertions(+), 11 deletions(-) create mode 100644 tests/in_process_release_check.py diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile index 313f6b1..8e3b3de 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 -ARG PINEFORGE_RELEASE_IMAGE=ghcr.io/pineforge-4pass/pineforge-release:0.1.12@sha256:312b9d908390b828484617472c749d5815feb75507da87eae2f6902cfe3d47b1 +ARG PINEFORGE_RELEASE_IMAGE=ghcr.io/pineforge-4pass/pineforge-release:1.0.0@sha256:b271873d0e91a9eeeb4595daaded9bdfbdbcd3925d3ec1300a833395e2004c86 FROM python:3.11-slim-bookworm@sha256:f5cf0344c9886ff24d34797578d5d7dd6e8911ae0fe5962bb55d0f89603ec361 AS server-builder RUN python -m pip install --no-cache-dir --target /opt/pineforge-server-deps \ diff --git a/src/pineforge_data/release_contract.py b/src/pineforge_data/release_contract.py index 50f2b29..251b844 100644 --- a/src/pineforge_data/release_contract.py +++ b/src/pineforge_data/release_contract.py @@ -12,8 +12,8 @@ from .models import Bar, Instrument DEFAULT_RELEASE_IMAGE = ( - "ghcr.io/pineforge-4pass/pineforge-release:0.1.12@" - "sha256:312b9d908390b828484617472c749d5815feb75507da87eae2f6902cfe3d47b1" + "ghcr.io/pineforge-4pass/pineforge-release:1.0.0@" + "sha256:b271873d0e91a9eeeb4595daaded9bdfbdbcd3925d3ec1300a833395e2004c86" ) RELEASE_ENTRYPOINT = "/opt/pineforge/bin/entrypoint.sh" RESPONSE_SCHEMA_VERSION = 1 @@ -88,7 +88,7 @@ def release_environment( """Translate PineForge options into the release entrypoint environment.""" if options.trace_enabled: - raise ReleaseContractError("pineforge-release 0.1.12 does not expose trace collection") + raise ReleaseContractError("pineforge-release 1.0.0 does not expose trace collection") if options.bar_magnifier and options.magnifier_samples < 2: raise ReleaseContractError("bar magnifier requires at least two samples") environment = { diff --git a/tests/fixtures/sma_cross.pine b/tests/fixtures/sma_cross.pine index f9dd87a..9a97ee5 100644 --- a/tests/fixtures/sma_cross.pine +++ b/tests/fixtures/sma_cross.pine @@ -1,5 +1,5 @@ //@version=6 -strategy("Docker SMA cross", initial_capital=10000) +strategy("Docker SMA cross", initial_capital=10000, default_qty_type=strategy.fixed, default_qty_value=1) fast = ta.sma(close, 2) slow = ta.sma(close, 4) diff --git a/tests/in_process_release_check.py b/tests/in_process_release_check.py new file mode 100644 index 0000000..03c0eb5 --- /dev/null +++ b/tests/in_process_release_check.py @@ -0,0 +1,255 @@ +"""Compile a strategy inside pineforge-release and read it in-process. + +This script runs inside the release image with the repository's ``src`` on +``PYTHONPATH``; ``tests/test_docker_integration.py`` starts it. It transpiles +``/in/strategy.pine`` with the image's entrypoint, compiles the library the way +the entrypoint does, and prints one JSON document on stdout: + +- ``layout``: every structure ``pineforge_data`` mirrors, as the C compiler lays + it out from the image's ``pineforge.h`` (``c``) and as ``ctypes`` does; +- ``header_abi`` and ``library_abi``: ``PF_ABI_VERSION`` and the compiled + library's ``pf_abi_version()``; +- ``release``: the image's own report (``run_json.py``) for that library; +- ``in_process``: ``PineForgeBacktestRunner``'s report for the same library, + bars and options, or ``in_process_error`` when the runner refuses it. +""" + +from __future__ import annotations + +import argparse +import csv +import ctypes +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +from pineforge_data import ( + BacktestOptions, + Bar, + EngineBacktestError, + Instrument, + PineForgeBacktestRunner, +) +from pineforge_data.backtest import ( + _PfEquityPoint, + _PfEquityStats, + _PfMetrics, + _PfReport, + _PfSecurityDiagnostic, + _PfTraceEntry, + _PfTrade, + _PfTradeStats, +) +from pineforge_data.engine import PfBar, PfTradeTick + +PREFIX = Path(os.environ.get("PINEFORGE_PREFIX", "/opt/pineforge")) +IN_DIR = Path("/in") +MIRRORS: tuple[tuple[str, type[ctypes.Structure]], ...] = ( + ("pf_bar_t", PfBar), + ("pf_trade_tick_t", PfTradeTick), + ("pf_trade_t", _PfTrade), + ("pf_trade_stats_t", _PfTradeStats), + ("pf_equity_stats_t", _PfEquityStats), + ("pf_metrics_t", _PfMetrics), + ("pf_equity_point_t", _PfEquityPoint), + ("pf_security_diag_t", _PfSecurityDiagnostic), + ("pf_trace_entry_t", _PfTraceEntry), + ("pf_report_t", _PfReport), +) +# pineforge.h 1.0 spells these two doubles sharpe_monthly / sortino_monthly; the +# report keys, and so the ctypes names, stay sharpe_tv / sortino_tv. +C_MEMBERS = { + ("pf_equity_stats_t", "sharpe_tv"): "sharpe_monthly", + ("pf_equity_stats_t", "sortino_tv"): "sortino_monthly", +} + +Layout = dict[str, dict[str, object]] + + +def _members(c_name: str, structure: type[ctypes.Structure]) -> list[tuple[str, str]]: + return [(name, C_MEMBERS.get((c_name, name), name)) for name, *_ in structure._fields_] + + +def ctypes_layout() -> Layout: + layout: Layout = {} + for c_name, structure in MIRRORS: + fields = [] + for name, member in _members(c_name, structure): + descriptor = getattr(structure, name) + fields.append([member, descriptor.offset, descriptor.size]) + layout[c_name] = {"size": ctypes.sizeof(structure), "fields": fields} + return layout + + +def c_layout(work: Path) -> tuple[int, Layout]: + lines = [ + "#include ", + "#include ", + "#include ", + "int main(void) {", + ' printf("abi %d\\n", PF_ABI_VERSION);', + ] + for c_name, structure in MIRRORS: + lines.append(f' printf("size {c_name} %zu\\n", sizeof({c_name}));') + for _, member in _members(c_name, structure): + lines.append( + f' printf("field {c_name} {member} %zu %zu\\n", ' + f"offsetof({c_name}, {member}), sizeof((({c_name} *)0)->{member}));" + ) + lines += [" return 0;", "}"] + source = work / "layout.c" + source.write_text("\n".join(lines) + "\n", encoding="utf-8") + binary = work / "layout" + subprocess.run( + ["gcc", "-std=c11", f"-I{PREFIX / 'include'}", str(source), "-o", str(binary)], + check=True, + ) + output = subprocess.run([str(binary)], check=True, stdout=subprocess.PIPE, text=True).stdout + header_abi = 0 + layout: Layout = {} + for line in output.splitlines(): + kind, *values = line.split() + if kind == "abi": + header_abi = int(values[0]) + elif kind == "size": + layout[values[0]] = {"size": int(values[1]), "fields": []} + else: + c_name, member, offset, size = values + fields = layout[c_name]["fields"] + assert isinstance(fields, list) + fields.append([member, int(offset), int(size)]) + return header_abi, layout + + +def build_library(work: Path) -> tuple[Path, Path]: + environment = dict(os.environ, PINEFORGE_IN_DIR=str(IN_DIR), PINEFORGE_TRANSPILE_ONLY="1") + generated = subprocess.run( + [str(PREFIX / "bin/entrypoint.sh")], + env=environment, + check=True, + stdout=subprocess.PIPE, + ).stdout + source = work / "strategy.cpp" + source.write_bytes(generated) + library = work / "strategy.so" + # The compile line of the image's entrypoint.sh. + subprocess.run( + [ + "g++", + "-std=c++17", + "-O2", + "-ffp-contract=off", + "-fPIC", + "-shared", + f"-I{PREFIX / 'include'}", + "-I/usr/include/eigen3", + str(source), + "-Wl,--whole-archive", + str(PREFIX / "lib/libpineforge.a"), + "-Wl,--no-whole-archive", + "-o", + str(library), + ], + check=True, + ) + return source, library + + +def read_inputs() -> tuple[Instrument, list[Bar]]: + syminfo = json.loads((IN_DIR / "syminfo.json").read_text(encoding="utf-8"))["syminfo"] + instrument = Instrument( + syminfo["ticker"], timezone=syminfo["timezone"], session=syminfo["session"] + ) + with (IN_DIR / "ohlcv.csv").open(newline="", encoding="utf-8") as handle: + bars = [ + Bar( + instrument, + int(row["timestamp"]), + float(row["open"]), + float(row["high"]), + float(row["low"]), + float(row["close"]), + float(row["volume"]), + "fixture", + ) + for row in csv.DictReader(handle) + ] + return instrument, bars + + +def release_report( + source: Path, library: Path, instrument: Instrument, options: BacktestOptions +) -> object: + command = [ + "python3", + str(PREFIX / "bin/run_json.py"), + "--so", + str(library), + "--ohlcv", + str(IN_DIR / "ohlcv.csv"), + "--input-tf", + options.input_timeframe, + "--script-tf", + options.script_timeframe, + "--generated-cpp", + str(source), + "--transpiled", + "true", + "--syminfo", + str(IN_DIR / "syminfo.json"), + # PineForgeBacktestRunner sets the chart timezone to the instrument's. + "--chart-tz", + instrument.timezone, + ] + if options.trade_start_time_ms is not None: + command += ["--trade-start-ms", str(options.trade_start_time_ms)] + completed = subprocess.run(command, check=True, stdout=subprocess.PIPE, text=True) + return json.loads(completed.stdout) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input-tf", required=True) + parser.add_argument("--script-tf", required=True) + parser.add_argument("--trade-start-ms", type=int) + args = parser.parse_args() + + instrument, bars = read_inputs() + options = BacktestOptions( + input_timeframe=args.input_tf, + script_timeframe=args.script_tf, + trade_start_time_ms=args.trade_start_ms, + ) + with tempfile.TemporaryDirectory(prefix="pineforge-data-in-process-") as temporary: + work = Path(temporary) + header_abi, c_structures = c_layout(work) + source, library = build_library(work) + library_abi = int(ctypes.CDLL(str(library)).pf_abi_version()) + release = release_report(source, library, instrument, options) + in_process: object = None + in_process_error = None + try: + runner = PineForgeBacktestRunner.load(library) + in_process = runner.run(bars, instrument=instrument, options=options).to_dict() + except EngineBacktestError as error: + in_process_error = str(error) + + json.dump( + { + "header_abi": header_abi, + "library_abi": library_abi, + "layout": {"c": c_structures, "ctypes": ctypes_layout()}, + "release": release, + "in_process": in_process, + "in_process_error": in_process_error, + }, + sys.stdout, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_backtest.py b/tests/test_backtest.py index 5a8abdc..5a28c86 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -16,7 +16,7 @@ Instrument, PineForgeBacktestRunner, ) -from pineforge_data.backtest import _PfEquityPoint, _PfReport, _PfTrade +from pineforge_data.backtest import _PfEquityPoint, _PfEquityStats, _PfReport, _PfTrade from pineforge_data.cli.backtest import ( build_parser, ccxt_timeframe_to_pine, @@ -44,7 +44,7 @@ def __call__(self, *args: object) -> object: class FakeBacktestLibrary: - def __init__(self, *, abi: int = 2, error: bytes = b"") -> None: + def __init__(self, *, abi: int = 4, error: bytes = b"") -> None: self.pf_abi_version = FakeFunction(abi) self.strategy_create = FakeFunction(123) self.strategy_free = FakeFunction() @@ -58,7 +58,7 @@ def __init__(self, *, abi: int = 2, error: bytes = b"") -> None: self.strategy_set_trade_start_time = FakeFunction() self.run_backtest_full = FakeFunction(callback=self._fill_report) self._trades = (_PfTrade * 1)( - _PfTrade(1_000, 2_000, 10.0, 12.0, 20.0, 20.0, 1, 2.5, 0.5, 10.0, 0.0, 0, 1) + _PfTrade(1_000, 2_000, 10.0, 12.0, 20.0, 20.0, 1, 25.0, 5.0, 10.0, 0.0, 0, 1, 1) ) self._equity = (_PfEquityPoint * 2)( _PfEquityPoint(1_000, 10_000.0, 0.0), @@ -115,6 +115,9 @@ def test_runner_returns_detached_json_safe_report() -> None: assert payload["summary"]["net_profit"] == 20.0 # type: ignore[index] assert payload["metrics"]["all"]["profit_factor"] is None # type: ignore[index] assert payload["trades"][0]["is_long"] is True # type: ignore[index] + assert payload["trades"][0]["open_at_end"] is True # type: ignore[index] + assert payload["trades"][0]["max_runup"] == 25.0 # type: ignore[index] + assert payload["trades"][0]["max_drawdown"] == 5.0 # type: ignore[index] assert len(payload["equity_curve"]) == 2 # type: ignore[arg-type] json.dumps(payload, allow_nan=False) assert len(fake.report_free.calls) == 1 @@ -145,6 +148,35 @@ def test_runner_rejects_abi_mismatch_and_unsorted_bars() -> None: runner.run(list(reversed(bars())), instrument=bars()[0].instrument) +@pytest.mark.parametrize(("abi", "engine"), [(2, "v0.10.2 to v0.12.3"), (3, "v0.13")]) +def test_runner_refuses_pre_1_0_libraries_with_a_rebuild_hint(abi: int, engine: str) -> None: + fake = FakeBacktestLibrary(abi=abi) + + with pytest.raises(EngineBacktestError, match=f"strategy reports {abi}, expected 4") as error: + PineForgeBacktestRunner(cast(ctypes.CDLL, fake)) + + assert engine in str(error.value) + assert "rebuild" in str(error.value) + assert fake.strategy_create.calls == [] + + +def test_report_structures_match_pineforge_abi_4_layout() -> None: + # Offsets and sizes measured by the C compiler against engine v1.0.0's + # include/pineforge/pineforge.h (LP64). + assert ctypes.sizeof(_PfTrade) == 104 + assert _PfTrade.exit_bar_index.offset == 92 + assert _PfTrade.open_at_end.offset == 96 + assert _PfEquityStats.sharpe_tv.offset == 48 + assert _PfEquityStats.sortino_tv.offset == 56 + assert ctypes.sizeof(_PfEquityStats) == 120 + assert ctypes.sizeof(_PfReport) == 960 + assert _PfReport.metrics.offset == 160 + assert _PfReport.equity_curve.offset == 928 + assert _PfReport.equity_curve_len.offset == 936 + assert _PfReport.broker_state_hash.offset == 944 + assert _PfReport.broker_state_hash_len.offset == 952 + + @pytest.mark.parametrize( ("ccxt", "pine"), [("15m", "15"), ("2h", "120"), ("1d", "1D"), ("1w", "1W"), ("1M", "1M")], diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index f77cb89..345720e 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -1,11 +1,12 @@ from __future__ import annotations +import json import os import subprocess import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import cast +from typing import Any, cast from urllib.request import urlopen import pytest @@ -17,6 +18,8 @@ FastApiBacktestClient, Instrument, ) +from pineforge_data.backtest import EXPECTED_PF_ABI +from pineforge_data.release_contract import write_release_inputs pytestmark = pytest.mark.skipif( os.environ.get("PINEFORGE_DOCKER_TEST") != "1", @@ -61,10 +64,121 @@ def test_local_runtime_uses_published_release_image() -> None: backtest = cast(dict[str, object], result["backtest"]) summary = cast(dict[str, object], backtest["summary"]) assert runtime["mode"] == "local-container" - assert "pineforge-release:0.1.12@sha256:" in cast(str, runtime["release_image"]) + assert "pineforge-release:1.0.0@sha256:" in cast(str, runtime["release_image"]) assert summary["bars_processed"] == len(bars) +@pytest.fixture(scope="module") +def in_process_check(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]: + """Compile the SMA fixture in the release image and read it both ways.""" + + pine, instrument, _ = fixture_values() + # A rising tail opens a long that is still open after the final bar, so the + # report ends with a range-end (open_at_end) row. + closes = [10, 11, 12, 13, 12, 11, 10, 9] * 3 + [10, 11, 12, 13] + bars = [ + Bar( + instrument, + 1_700_000_000_000 + index * 60_000, + float(close), + float(close + 1), + float(close - 1), + float(close), + 100.0, + "fixture", + ) + for index, close in enumerate(closes) + ] + workspace = tmp_path_factory.mktemp("in-process") / "in" + write_release_inputs(workspace, pine, bars, instrument) + image = DockerBacktestRuntime().ensure_image() + completed = subprocess.run( + [ + "docker", + "run", + "--rm", + "--network", + "none", + "--read-only", + "--tmpfs", + "/tmp:rw,exec,nosuid,nodev,size=512m", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--mount", + f"type=bind,src={workspace},dst=/in,readonly", + "--mount", + f"type=bind,src={ROOT / 'src'},dst=/opt/pineforge-data/src,readonly", + "--mount", + f"type=bind,src={ROOT / 'tests'},dst=/opt/pineforge-data/tests,readonly", + "--env", + "PYTHONPATH=/opt/pineforge-data/src:/opt/pineforge/pycodegen", + "--env", + "PYTHONDONTWRITEBYTECODE=1", + "--entrypoint", + "python3", + image, + "/opt/pineforge-data/tests/in_process_release_check.py", + "--input-tf", + "1", + "--script-tf", + "1", + ], + text=True, + capture_output=True, + check=False, + timeout=600, + ) + assert completed.returncode == 0, f"in-process check failed:\n{completed.stderr}" + return cast(dict[str, Any], json.loads(completed.stdout)) + + +def test_report_structures_match_the_release_header(in_process_check: dict[str, Any]) -> None: + layout = in_process_check["layout"] + + assert layout["ctypes"].keys() == layout["c"].keys() + for name, c_structure in layout["c"].items(): + assert layout["ctypes"][name] == c_structure, name + assert in_process_check["header_abi"] == in_process_check["library_abi"] == EXPECTED_PF_ABI + + +def test_in_process_runner_matches_the_release_harness(in_process_check: dict[str, Any]) -> None: + assert in_process_check["in_process_error"] is None + release = in_process_check["release"] + in_process = in_process_check["in_process"] + expected_trades = [ + { + "entry_time": trade["entry_time"], + "exit_time": trade["exit_time"], + "entry_price": trade["entry_price"], + "exit_price": trade["exit_price"], + "pnl": trade["pnl"], + "pnl_pct": trade["pnl_pct"], + "is_long": trade["side"] == "long", + "max_runup": trade["max_runup"], + "max_drawdown": trade["max_drawdown"], + "qty": trade["qty"], + "commission": trade["commission"], + "entry_bar_index": trade["entry_bar_index"], + "exit_bar_index": trade["exit_bar_index"], + "open_at_end": trade["open_at_end"], + } + for trade in release["trades"] + ] + + assert len(expected_trades) >= 2 + assert in_process["trades"] == expected_trades + assert [trade["open_at_end"] for trade in in_process["trades"]][-2:] == [False, True] + assert in_process["metrics"] == release["metrics"] + assert in_process["equity_curve"] == release["equity_curve"] + summary = in_process["summary"] + assert summary["total_trades"] == release["summary"]["total_trades"] == len(expected_trades) + assert summary["net_profit"] == release["summary"]["net_pnl"] + assert summary["input_bars_processed"] == release["diagnostics"]["input_bars_processed"] + assert summary["script_bars_processed"] == release["diagnostics"]["script_bars_processed"] + + def _wait_for_server(container_id: str) -> str: port_result = subprocess.run( ["docker", "port", container_id, "8000/tcp"], diff --git a/tests/test_docker_runtime.py b/tests/test_docker_runtime.py index 8ed3b03..babf088 100644 --- a/tests/test_docker_runtime.py +++ b/tests/test_docker_runtime.py @@ -15,7 +15,7 @@ def test_default_runtime_is_a_digest_pinned_release_image() -> None: runtime = DockerBacktestRuntime() assert runtime.resolved_image() == DEFAULT_RELEASE_IMAGE - assert "pineforge-release:0.1.12@sha256:" in runtime.resolved_image() + assert "pineforge-release:1.0.0@sha256:" in runtime.resolved_image() def test_latest_is_an_explicit_rolling_channel() -> None: From bc4ce7ee61e291fd85379934ba0d11b5ff1ddbc2 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 30 Sep 2026 14:05:50 +0800 Subject: [PATCH 2/6] Read engine 1.0 (C ABI 4) libraries in PineForgeBacktestRunner Mirror pineforge.h v1.0.0 field by field: - pf_trade_t gains open_at_end (appended in ABI 3), so a trade row is 104 bytes; the copied trades carry it as a boolean; - pf_report_t gains broker_state_hash and broker_state_hash_len (ABI 4), so the caller-allocated report is 960 bytes; report_free releases the array and the detached report does not copy it; - sharpe_tv / sortino_tv keep offsets 48 / 56 and their report keys; the header now spells them sharpe_monthly / sortino_monthly. The runner accepts ABI 4 only, the exact match pineforge.h asks of every caller and the release image's harness enforces. ABI 2 (engine v0.10.2 to v0.12.3) and ABI 3 (engine v0.13.x) libraries are refused before any strategy call, with a rebuild hint: ABI 2 trade rows are 8 bytes shorter, and ABI 3 reports lack the broker-state hash. BacktestReport documents the unit of trade max_runup / max_drawdown: whole-trade excursions in account currency, net of entry fees, as the 1.0 header now states (older headers said price travel per unit of quantity; the engine's values did not change). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/pineforge_data/backtest.py | 48 ++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/pineforge_data/backtest.py b/src/pineforge_data/backtest.py index 42a7a94..a88b01d 100644 --- a/src/pineforge_data/backtest.py +++ b/src/pineforge_data/backtest.py @@ -18,7 +18,15 @@ JsonScalar: TypeAlias = str | int | float | bool | None JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] -EXPECTED_PF_ABI = 2 +EXPECTED_PF_ABI = 4 +# The engine releases behind each refused pre-1.0 C ABI, and the way forward. +_REFUSED_ABI_HINTS = { + 2: ( + "engine v0.10.2 to v0.12.3 built it: rebuild the strategy with engine 1.x, " + "or read it with pineforge-data 0.2.0" + ), + 3: "engine v0.13.0 to v0.13.1 built it: rebuild the strategy with engine 1.x", +} class EngineBacktestError(RuntimeError): @@ -64,7 +72,13 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class BacktestReport: - """A detached, JSON-safe copy of ``pf_report_t``.""" + """A detached, JSON-safe copy of ``pf_report_t``. + + Each trade's ``max_runup`` and ``max_drawdown`` are the whole-trade favorable + and adverse excursions in account currency, net of entry fees. A trade whose + ``open_at_end`` is true closes a position that was still open after the final + bar, at that bar's close; ``summary["total_trades"]`` counts it. + """ summary: Mapping[str, JsonValue] metrics: Mapping[str, Mapping[str, JsonValue]] @@ -95,12 +109,14 @@ class _PfTrade(ctypes.Structure): ("pnl", ctypes.c_double), ("pnl_pct", ctypes.c_double), ("is_long", ctypes.c_int), + # Whole-trade excursions in account currency, net of entry fees. ("max_runup", ctypes.c_double), ("max_drawdown", ctypes.c_double), ("qty", ctypes.c_double), ("commission", ctypes.c_double), ("entry_bar_index", ctypes.c_int32), ("exit_bar_index", ctypes.c_int32), + ("open_at_end", ctypes.c_int32), ] @@ -147,6 +163,8 @@ class _PfEquityStats(ctypes.Structure): ("max_equity_runup_pct", ctypes.c_double), ("buy_hold_return", ctypes.c_double), ("buy_hold_return_pct", ctypes.c_double), + # pineforge.h 1.0 names these sharpe_monthly and sortino_monthly at the + # same offsets (48 and 56); the report keys stay sharpe_tv and sortino_tv. ("sharpe_tv", ctypes.c_double), ("sortino_tv", ctypes.c_double), ("sharpe_bar", ctypes.c_double), @@ -221,6 +239,10 @@ class _PfReport(ctypes.Structure): ("metrics", _PfMetrics), ("equity_curve", ctypes.POINTER(_PfEquityPoint)), ("equity_curve_len", ctypes.c_int64), + # Per-script-bar broker-state hash (ABI 4). report_free releases it; + # BacktestReport does not copy it. + ("broker_state_hash", ctypes.POINTER(ctypes.c_uint64)), + ("broker_state_hash_len", ctypes.c_int64), ] @@ -299,7 +321,16 @@ def _decode(value: bytes | None) -> str: class PineForgeBacktestRunner: - """Thin owner-safe wrapper around one compiled PineForge strategy library.""" + """Thin owner-safe wrapper around one compiled PineForge strategy library. + + The library must report PineForge C ABI 4, which engine 1.x builds; like the + release image's own harness, the runner requires the exact ABI that + ``pineforge.h`` asks every caller to check. Libraries that report ABI 2 + (engine v0.10.2 to v0.12.3) or ABI 3 (engine v0.13.x) are refused with + ``EngineBacktestError`` before any strategy call. An ABI 2 trade row lacks + ``open_at_end`` and is 8 bytes shorter, so every row after the first would be + misread; an ABI 3 report lacks the broker-state hash. + """ def __init__(self, library: ctypes.CDLL) -> None: self._library = library @@ -325,9 +356,11 @@ def _check_abi(self) -> None: "strategy library predates pf_abi_version; rebuild it with the current engine" ) from exc if actual != EXPECTED_PF_ABI: - raise EngineBacktestError( + message = ( f"PineForge ABI mismatch: strategy reports {actual}, expected {EXPECTED_PF_ABI}" ) + hint = _REFUSED_ABI_HINTS.get(actual) + raise EngineBacktestError(f"{message}; {hint}" if hint else message) def _configure_signatures(self) -> None: library = self._library @@ -496,6 +529,7 @@ def _copy_report(self, report: _PfReport) -> BacktestReport: "commission": _json_number(trade.commission), "entry_bar_index": trade.entry_bar_index, "exit_bar_index": trade.exit_bar_index, + "open_at_end": bool(trade.open_at_end), } ) @@ -548,5 +582,9 @@ def _copy_report(self, report: _PfReport) -> BacktestReport: ) -if ctypes.sizeof(_PfReport) != 944 or _PfReport.metrics.offset != 160: +if ( + ctypes.sizeof(_PfReport) != 960 + or _PfReport.metrics.offset != 160 + or ctypes.sizeof(_PfTrade) != 104 +): raise RuntimeError("unsupported platform ABI layout for PineForge reports") From 84d3627ebb79341b5882315b8a3824684d5e336d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 30 Sep 2026 14:05:50 +0800 Subject: [PATCH 3/6] Admit twine 7 so strict metadata checks pass hatchling now writes Metadata-Version 2.5. twine 6.2 rejects it ("'2.5' is not a valid metadata version"), which fails the CI and release `twine check --strict` step; twine 7.0.0 (packaging >= 26.1) accepts it. Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 664913f..35ecd75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ server = [ ] release = [ "build>=1.2,<2", - "twine>=6.2,<7", + "twine>=7,<8", ] dev = [ "build>=1.2", From d2bb2c21b51fcfa6d7690833ca4da7173c515ac8 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 30 Sep 2026 14:05:50 +0800 Subject: [PATCH 4/6] Document engine 1.0 as the supported runtime - README, backtesting and server guides: release 1.0.0 (engine 1.0.0, codegen 1.0.0) is the pinned default; 0.1.12 is the digest-pinned way to reproduce earlier results. The README quick start reports 89 trades on 1.0.0 and 88 on 0.1.12: the same 88 trades plus the open_at_end close of the position left open after the last bar. - API reference: PineForgeBacktestRunner reads ABI 4 (engine 1.x) and refuses ABI 2 and 3 with a rebuild hint; trade excursions are in account currency, net of entry fees; open_at_end is explained. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 +++--- docs/api/backtesting.md | 22 ++++++++++++++++------ docs/backtesting.md | 13 +++++++------ docs/server.md | 9 +++++---- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 86be998..0d9c5a0 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,9 @@ pineforge-backtest \ --pretty ``` -The first run pulls the digest-pinned `pineforge-release` 0.1.12 image (engine -0.11.0, codegen 0.9.0). Release 1.0.0 (engine 1.0.0, codegen 1.0.0) also runs -this example when its digest-pinned image is passed to `--runtime-image`; see the +The first run pulls the digest-pinned `pineforge-release` 1.0.0 image (engine +1.0.0, codegen 1.0.0). To run another release, pass its digest-pinned image to +`--runtime-image`; see the [runtime image policy](https://pineforge-4pass.github.io/pineforge-data/backtesting/#runtime-image-policy). The JSON report includes trades, performance statistics, the equity curve, data provenance, and exact runtime versions. The diff --git a/docs/api/backtesting.md b/docs/api/backtesting.md index 75e9211..47b29f0 100644 --- a/docs/api/backtesting.md +++ b/docs/api/backtesting.md @@ -8,12 +8,22 @@ ::: pineforge_data.backtest.BacktestReport -`PineForgeBacktestRunner` accepts only strategy libraries that report PineForge -C ABI 2, which engine releases v0.10.2 through v0.12.3 build. It refuses -libraries built with engine v0.13.x (ABI 3) or v1.0.0 (ABI 4) with an -`EngineBacktestError` such as "PineForge ABI mismatch: strategy reports 4, -expected 2". The release container and the FastAPI server do not use this -class. +`PineForgeBacktestRunner` runs a compiled strategy library in-process. It +accepts libraries that report PineForge C ABI 4, which engine 1.x builds. It +refuses older libraries before any strategy call, with an `EngineBacktestError` +that names the engine releases behind their ABI: ABI 2 (engine v0.10.2 through +v0.12.3) and ABI 3 (engine v0.13.x). Rebuild such a library with engine 1.x; +pineforge-data 0.2.0 still reads ABI 2. The release container and the FastAPI +server do not use this class. + +A trade's `max_runup` and `max_drawdown` are whole-trade excursions in account +currency, net of entry fees, as engine 1.0's `pineforge.h` documents them. +Headers before 1.0 described them as price travel per unit of quantity, but the +values did not change: on the README quick start, engines 0.11.0 and 1.0.0 +report identical excursions for the 88 trades both report. A trade whose +`open_at_end` is true closes a position still open after the final bar, at that +bar's close. Engine 0.11.0 reports no such row, so the README quick start has 88 +trades on it and 89 on engine 1.0.0. ::: pineforge_data.backtest.PineForgeBacktestRunner diff --git a/docs/backtesting.md b/docs/backtesting.md index 02bfa8b..da72f1e 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -133,19 +133,20 @@ guides. ## Runtime image policy The package default is an exact `pineforge-release` version and OCI digest: -0.1.12, which bundles engine 0.11.0 and codegen 0.9.0. The `missing` pull +1.0.0, which bundles engine 1.0.0 and codegen 1.0.0. The `missing` pull policy downloads it only when absent; `never` supports offline runs; `always` refreshes a tag before running. -Release 1.0.0 bundles engine 1.0.0 and codegen 1.0.0 and accepts the same -inputs. To use it, pin it by digest: +The previous default, release 0.1.12 (engine 0.11.0, codegen 0.9.0), accepts the +same inputs. To reproduce results made with it, pin it by digest: ```bash ---runtime-image ghcr.io/pineforge-4pass/pineforge-release:1.0.0@sha256:b271873d0e91a9eeeb4595daaded9bdfbdbcd3925d3ec1300a833395e2004c86 +--runtime-image ghcr.io/pineforge-4pass/pineforge-release:0.1.12@sha256:312b9d908390b828484617472c749d5815feb75507da87eae2f6902cfe3d47b1 ``` -Results can differ between releases: the README quick start reports 88 trades -on 0.1.12 and 89 on 1.0.0. +Results can differ between releases: the README quick start reports 89 trades +on 1.0.0 and 88 on 0.1.12. The first 88 are the same trades; 1.0.0 adds the +close of the position still open after the last bar, marked `open_at_end`. The rolling channel is explicit: diff --git a/docs/server.md b/docs/server.md index da2fabb..9d1d39b 100644 --- a/docs/server.md +++ b/docs/server.md @@ -64,8 +64,9 @@ and `generated_cpp_sha256`. ## Runtime channel -The Dockerfile defaults to a semver-and-digest-pinned release image. Override it -at build time for a rolling development server: +The Dockerfile defaults to a semver-and-digest-pinned release image: 1.0.0 +(engine 1.0.0, codegen 1.0.0). Override it at build time for a rolling +development server: ```bash docker build -f docker/server.Dockerfile \ @@ -73,8 +74,8 @@ docker build -f docker/server.Dockerfile \ -t pineforge-data-server:latest . ``` -To pin release 1.0.0 (engine 1.0.0, codegen 1.0.0) instead, pass -`--build-arg PINEFORGE_RELEASE_IMAGE=ghcr.io/pineforge-4pass/pineforge-release:1.0.0@sha256:b271873d0e91a9eeeb4595daaded9bdfbdbcd3925d3ec1300a833395e2004c86`. +To build on the previous release, 0.1.12 (engine 0.11.0, codegen 0.9.0), pass +`--build-arg PINEFORGE_RELEASE_IMAGE=ghcr.io/pineforge-4pass/pineforge-release:0.1.12@sha256:312b9d908390b828484617472c749d5815feb75507da87eae2f6902cfe3d47b1`. Do not use the rolling channel for reproducibility-sensitive production runs. The cache key includes component/release identity, so a runtime upgrade cannot From 4820127a243bf2546eec6a4bc43d73cf989b3b49 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 30 Sep 2026 14:27:08 +0800 Subject: [PATCH 5/6] Document codegen 1.0 strategy() defaults; guide every ABI refusal Review follow-ups: - The runtime image policy (and the README note that points to it) says that codegen 1.0 gives a strategy() without initial_capital, default_qty_type or default_qty_value TradingView's Pine v6 defaults: 100000, percent_of_equity and 100, where release 0.1.12 used 1000000, fixed and 1. At 100% of equity the default margin refuses entries that fill above their signal price, so such scripts can trade less on 1.0.0; declaring all three keeps order sizes equal. - A library without pf_abi_version names engine v0.10.1 or earlier and asks for engine 1.x; an ABI above 4 asks for a newer pineforge-data. - The range-end close is the last bar's close rounded to the tick size, without slippage, as pineforge.h states. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 7 ++++--- docs/api/backtesting.md | 16 +++++++++------- docs/backtesting.md | 8 ++++++++ src/pineforge_data/backtest.py | 8 ++++++-- tests/test_backtest.py | 6 +++++- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0d9c5a0..5f4fb8b 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,10 @@ pineforge-backtest \ ``` The first run pulls the digest-pinned `pineforge-release` 1.0.0 image (engine -1.0.0, codegen 1.0.0). To run another release, pass its digest-pinned image to -`--runtime-image`; see the -[runtime image policy](https://pineforge-4pass.github.io/pineforge-data/backtesting/#runtime-image-policy). +1.0.0, codegen 1.0.0). Results can differ between releases, notably for scripts +that omit their capital or order size; the +[runtime image policy](https://pineforge-4pass.github.io/pineforge-data/backtesting/#runtime-image-policy) +explains why and how to pin another release with `--runtime-image`. The JSON report includes trades, performance statistics, the equity curve, data provenance, and exact runtime versions. The strategy is compiled inside an isolated Docker container; the provider and its diff --git a/docs/api/backtesting.md b/docs/api/backtesting.md index 47b29f0..b18e934 100644 --- a/docs/api/backtesting.md +++ b/docs/api/backtesting.md @@ -10,11 +10,13 @@ `PineForgeBacktestRunner` runs a compiled strategy library in-process. It accepts libraries that report PineForge C ABI 4, which engine 1.x builds. It -refuses older libraries before any strategy call, with an `EngineBacktestError` -that names the engine releases behind their ABI: ABI 2 (engine v0.10.2 through -v0.12.3) and ABI 3 (engine v0.13.x). Rebuild such a library with engine 1.x; -pineforge-data 0.2.0 still reads ABI 2. The release container and the FastAPI -server do not use this class. +refuses any other library before any strategy call, with an +`EngineBacktestError` that says what to do. Libraries without +`pf_abi_version` (engine v0.10.1 or earlier), ABI 2 (engine v0.10.2 through +v0.12.3) and ABI 3 (engine v0.13.x) need a rebuild with engine 1.x; +pineforge-data 0.2.0 still reads ABI 2. A newer ABI needs a newer +pineforge-data. The release container and the FastAPI server do not use this +class. A trade's `max_runup` and `max_drawdown` are whole-trade excursions in account currency, net of entry fees, as engine 1.0's `pineforge.h` documents them. @@ -22,8 +24,8 @@ Headers before 1.0 described them as price travel per unit of quantity, but the values did not change: on the README quick start, engines 0.11.0 and 1.0.0 report identical excursions for the 88 trades both report. A trade whose `open_at_end` is true closes a position still open after the final bar, at that -bar's close. Engine 0.11.0 reports no such row, so the README quick start has 88 -trades on it and 89 on engine 1.0.0. +bar's close rounded to the tick size, without slippage. Engine 0.11.0 reports no +such row, so the README quick start has 88 trades on it and 89 on engine 1.0.0. ::: pineforge_data.backtest.PineForgeBacktestRunner diff --git a/docs/backtesting.md b/docs/backtesting.md index da72f1e..1e1555e 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -148,6 +148,14 @@ Results can differ between releases: the README quick start reports 89 trades on 1.0.0 and 88 on 0.1.12. The first 88 are the same trades; 1.0.0 adds the close of the position still open after the last bar, marked `open_at_end`. +Order sizes differ too when a script omits `initial_capital`, +`default_qty_type` or `default_qty_value`. Codegen 1.0 gives them TradingView's +Pine v6 defaults: 100000, `strategy.percent_of_equity` and 100. Release 0.1.12 +used 1000000, `strategy.fixed` and 1. At 100% of equity, the default 100% margin +refuses an entry that fills above its signal price, so such a script can trade +less on 1.0.0. Declare all three settings, as the README strategy does, to size +orders the same way on both releases. + The rolling channel is explicit: ```bash diff --git a/src/pineforge_data/backtest.py b/src/pineforge_data/backtest.py index a88b01d..75c8e40 100644 --- a/src/pineforge_data/backtest.py +++ b/src/pineforge_data/backtest.py @@ -77,7 +77,8 @@ class BacktestReport: Each trade's ``max_runup`` and ``max_drawdown`` are the whole-trade favorable and adverse excursions in account currency, net of entry fees. A trade whose ``open_at_end`` is true closes a position that was still open after the final - bar, at that bar's close; ``summary["total_trades"]`` counts it. + bar, at that bar's close rounded to the tick size, without slippage; + ``summary["total_trades"]`` counts it. """ summary: Mapping[str, JsonValue] @@ -353,13 +354,16 @@ def _check_abi(self) -> None: actual = int(self._library.pf_abi_version()) except AttributeError as exc: raise EngineBacktestError( - "strategy library predates pf_abi_version; rebuild it with the current engine" + "strategy library predates pf_abi_version (engine v0.10.1 or earlier); " + "rebuild it with engine 1.x" ) from exc if actual != EXPECTED_PF_ABI: message = ( f"PineForge ABI mismatch: strategy reports {actual}, expected {EXPECTED_PF_ABI}" ) hint = _REFUSED_ABI_HINTS.get(actual) + if hint is None and actual > EXPECTED_PF_ABI: + hint = "a newer engine built it: upgrade pineforge-data" raise EngineBacktestError(f"{message}; {hint}" if hint else message) def _configure_signatures(self) -> None: diff --git a/tests/test_backtest.py b/tests/test_backtest.py index 5a28c86..83bf007 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -140,8 +140,12 @@ def test_runner_surfaces_engine_error_and_releases_owners() -> None: def test_runner_rejects_abi_mismatch_and_unsorted_bars() -> None: - with pytest.raises(EngineBacktestError, match="ABI mismatch"): + with pytest.raises(EngineBacktestError, match=r"ABI mismatch.*upgrade pineforge-data"): PineForgeBacktestRunner(cast(ctypes.CDLL, FakeBacktestLibrary(abi=99))) + unversioned = FakeBacktestLibrary() + del unversioned.pf_abi_version + with pytest.raises(EngineBacktestError, match=r"predates pf_abi_version.*engine 1\.x"): + PineForgeBacktestRunner(cast(ctypes.CDLL, unversioned)) runner = PineForgeBacktestRunner(cast(ctypes.CDLL, FakeBacktestLibrary())) with pytest.raises(ValueError, match="strictly increasing"): From d983d9bbc3ddfc40b4cf0e6c1bbde65d86318376 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 30 Sep 2026 14:28:59 +0800 Subject: [PATCH 6/6] Harden the Docker in-process check Review follow-ups: - the layout probe also compares each member's scalar kind (C11 _Generic against the ctypes type), so a same-size change of signedness, integer/double or pointer/integer fails, and drops a keys() comparison that could not fail; - the in-process run compares every summary counter the release report exposes (magnifier totals, timeframe seconds and ratio, aggregation); - a timed-out check container is removed through its cidfile, as the runtime does; - the fixture bars come from fixture_values(closes), and the published runtime test also pins the image's engine and codegen versions. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/in_process_release_check.py | 48 +++++++++--- tests/test_docker_integration.py | 120 +++++++++++++++--------------- 2 files changed, 98 insertions(+), 70 deletions(-) diff --git a/tests/in_process_release_check.py b/tests/in_process_release_check.py index 03c0eb5..b226c8d 100644 --- a/tests/in_process_release_check.py +++ b/tests/in_process_release_check.py @@ -6,7 +6,8 @@ the entrypoint does, and prints one JSON document on stdout: - ``layout``: every structure ``pineforge_data`` mirrors, as the C compiler lays - it out from the image's ``pineforge.h`` (``c``) and as ``ctypes`` does; + it out from the image's ``pineforge.h`` (``c``) and as ``ctypes`` does: each + member's name, offset, size and scalar kind; - ``header_abi`` and ``library_abi``: ``PF_ABI_VERSION`` and the compiled library's ``pf_abi_version()``; - ``release``: the image's own report (``run_json.py``) for that library; @@ -65,21 +66,46 @@ ("pf_equity_stats_t", "sharpe_tv"): "sharpe_monthly", ("pf_equity_stats_t", "sortino_tv"): "sortino_monthly", } +# Scalar kinds, so that a same-size change of type (signedness, integer versus +# double, pointer versus integer) fails too. Pointers and nested structures +# are "other" on both sides; their sizes tell them apart. +CTYPES_KINDS: dict[object, str] = { + ctypes.c_double: "f64", + ctypes.c_float: "f32", + ctypes.c_int8: "i8", + ctypes.c_uint8: "u8", + ctypes.c_int16: "i16", + ctypes.c_uint16: "u16", + ctypes.c_int32: "i32", + ctypes.c_uint32: "u32", + ctypes.c_int64: "i64", + ctypes.c_uint64: "u64", +} +C_KIND = ( + '_Generic((x), double: "f64", float: "f32", signed char: "i8", unsigned char: "u8", ' + 'short: "i16", unsigned short: "u16", int: "i32", unsigned int: "u32", ' + 'long: "i64", unsigned long: "u64", long long: "i64", unsigned long long: "u64", ' + 'default: "other")' +) Layout = dict[str, dict[str, object]] -def _members(c_name: str, structure: type[ctypes.Structure]) -> list[tuple[str, str]]: - return [(name, C_MEMBERS.get((c_name, name), name)) for name, *_ in structure._fields_] +def _members(c_name: str, structure: type[ctypes.Structure]) -> list[tuple[str, str, object]]: + return [ + (name, C_MEMBERS.get((c_name, name), name), field_type) + for name, field_type, *_ in structure._fields_ + ] def ctypes_layout() -> Layout: layout: Layout = {} for c_name, structure in MIRRORS: fields = [] - for name, member in _members(c_name, structure): + for name, member, field_type in _members(c_name, structure): descriptor = getattr(structure, name) - fields.append([member, descriptor.offset, descriptor.size]) + kind = CTYPES_KINDS.get(field_type, "other") + fields.append([member, descriptor.offset, descriptor.size, kind]) layout[c_name] = {"size": ctypes.sizeof(structure), "fields": fields} return layout @@ -89,15 +115,17 @@ def c_layout(work: Path) -> tuple[int, Layout]: "#include ", "#include ", "#include ", + f"#define KIND(x) {C_KIND}", "int main(void) {", ' printf("abi %d\\n", PF_ABI_VERSION);', ] for c_name, structure in MIRRORS: lines.append(f' printf("size {c_name} %zu\\n", sizeof({c_name}));') - for _, member in _members(c_name, structure): + for _, member, _ in _members(c_name, structure): lines.append( - f' printf("field {c_name} {member} %zu %zu\\n", ' - f"offsetof({c_name}, {member}), sizeof((({c_name} *)0)->{member}));" + f' printf("field {c_name} {member} %zu %zu %s\\n", ' + f"offsetof({c_name}, {member}), sizeof((({c_name} *)0)->{member}), " + f"KIND((({c_name} *)0)->{member}));" ) lines += [" return 0;", "}"] source = work / "layout.c" @@ -117,10 +145,10 @@ def c_layout(work: Path) -> tuple[int, Layout]: elif kind == "size": layout[values[0]] = {"size": int(values[1]), "fields": []} else: - c_name, member, offset, size = values + c_name, member, offset, size, kind = values fields = layout[c_name]["fields"] assert isinstance(fields, list) - fields.append([member, int(offset), int(size)]) + fields.append([member, int(offset), int(size), kind]) return header_abi, layout diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 345720e..7f64054 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -4,6 +4,7 @@ import os import subprocess import time +from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any, cast @@ -29,10 +30,11 @@ ROOT = Path(__file__).resolve().parents[1] -def fixture_values() -> tuple[str, Instrument, list[Bar]]: +def fixture_values( + closes: Sequence[int] = (10, 11, 12, 13, 12, 11, 10, 9) * 3, +) -> tuple[str, Instrument, list[Bar]]: pine = (ROOT / "tests/fixtures/sma_cross.pine").read_text(encoding="utf-8") instrument = Instrument("TEST/USD", venue="fixture") - closes = [10, 11, 12, 13, 12, 11, 10, 9] * 3 bars = [ Bar( instrument, @@ -65,6 +67,7 @@ def test_local_runtime_uses_published_release_image() -> None: summary = cast(dict[str, object], backtest["summary"]) assert runtime["mode"] == "local-container" assert "pineforge-release:1.0.0@sha256:" in cast(str, runtime["release_image"]) + assert (runtime["engine_version"], runtime["codegen_version"]) == ("1.0.0", "1.0.0") assert summary["bars_processed"] == len(bars) @@ -72,64 +75,54 @@ def test_local_runtime_uses_published_release_image() -> None: def in_process_check(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]: """Compile the SMA fixture in the release image and read it both ways.""" - pine, instrument, _ = fixture_values() # A rising tail opens a long that is still open after the final bar, so the # report ends with a range-end (open_at_end) row. - closes = [10, 11, 12, 13, 12, 11, 10, 9] * 3 + [10, 11, 12, 13] - bars = [ - Bar( - instrument, - 1_700_000_000_000 + index * 60_000, - float(close), - float(close + 1), - float(close - 1), - float(close), - 100.0, - "fixture", - ) - for index, close in enumerate(closes) - ] - workspace = tmp_path_factory.mktemp("in-process") / "in" - write_release_inputs(workspace, pine, bars, instrument) + pine, instrument, bars = fixture_values((10, 11, 12, 13, 12, 11, 10, 9) * 3 + (10, 11, 12, 13)) + workspace = tmp_path_factory.mktemp("in-process") + write_release_inputs(workspace / "in", pine, bars, instrument) image = DockerBacktestRuntime().ensure_image() - completed = subprocess.run( - [ - "docker", - "run", - "--rm", - "--network", - "none", - "--read-only", - "--tmpfs", - "/tmp:rw,exec,nosuid,nodev,size=512m", - "--cap-drop", - "ALL", - "--security-opt", - "no-new-privileges", - "--mount", - f"type=bind,src={workspace},dst=/in,readonly", - "--mount", - f"type=bind,src={ROOT / 'src'},dst=/opt/pineforge-data/src,readonly", - "--mount", - f"type=bind,src={ROOT / 'tests'},dst=/opt/pineforge-data/tests,readonly", - "--env", - "PYTHONPATH=/opt/pineforge-data/src:/opt/pineforge/pycodegen", - "--env", - "PYTHONDONTWRITEBYTECODE=1", - "--entrypoint", - "python3", - image, - "/opt/pineforge-data/tests/in_process_release_check.py", - "--input-tf", - "1", - "--script-tf", - "1", - ], - text=True, - capture_output=True, - check=False, - timeout=600, - ) + cid_path = workspace / "container.cid" + command = [ + "docker", + "run", + "--rm", + "--cidfile", + str(cid_path), + "--network", + "none", + "--read-only", + "--tmpfs", + "/tmp:rw,exec,nosuid,nodev,size=512m", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--mount", + f"type=bind,src={workspace / 'in'},dst=/in,readonly", + "--mount", + f"type=bind,src={ROOT / 'src'},dst=/opt/pineforge-data/src,readonly", + "--mount", + f"type=bind,src={ROOT / 'tests'},dst=/opt/pineforge-data/tests,readonly", + "--env", + "PYTHONPATH=/opt/pineforge-data/src:/opt/pineforge/pycodegen", + "--env", + "PYTHONDONTWRITEBYTECODE=1", + "--entrypoint", + "python3", + image, + "/opt/pineforge-data/tests/in_process_release_check.py", + "--input-tf", + "1", + "--script-tf", + "1", + ] + try: + completed = subprocess.run( + command, text=True, capture_output=True, check=False, timeout=600 + ) + except subprocess.TimeoutExpired: + DockerBacktestRuntime._remove_timed_out_container(cid_path) + raise assert completed.returncode == 0, f"in-process check failed:\n{completed.stderr}" return cast(dict[str, Any], json.loads(completed.stdout)) @@ -137,7 +130,6 @@ def in_process_check(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any] def test_report_structures_match_the_release_header(in_process_check: dict[str, Any]) -> None: layout = in_process_check["layout"] - assert layout["ctypes"].keys() == layout["c"].keys() for name, c_structure in layout["c"].items(): assert layout["ctypes"][name] == c_structure, name assert in_process_check["header_abi"] == in_process_check["library_abi"] == EXPECTED_PF_ABI @@ -175,8 +167,16 @@ def test_in_process_runner_matches_the_release_harness(in_process_check: dict[st summary = in_process["summary"] assert summary["total_trades"] == release["summary"]["total_trades"] == len(expected_trades) assert summary["net_profit"] == release["summary"]["net_pnl"] - assert summary["input_bars_processed"] == release["diagnostics"]["input_bars_processed"] - assert summary["script_bars_processed"] == release["diagnostics"]["script_bars_processed"] + for key in ( + "input_bars_processed", + "script_bars_processed", + "magnifier_sub_bars_total", + "magnifier_sample_ticks_total", + "bar_magnifier_enabled", + ): + assert summary[key] == release["diagnostics"][key], key + for key in ("input_tf_seconds", "script_tf_seconds", "script_tf_ratio", "needs_aggregation"): + assert summary[key] == release["applied_runtime"][key], key def _wait_for_server(container_id: str) -> str: