diff --git a/README.md b/README.md index 17c20a4..47d726b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ historical reproduction. - Explicit margin, leverage, fees, slippage, funding, and liquidation handling. - Stable audit artifacts: metrics, plots, raw reports, trade logs, config JSON, run manifest, and optional QuantStats HTML. +- Nautilus certification bundles with parity CSVs, tolerance profiles, known + differences, and explicit skip/pass status for optional trustee workflows. +- Nautilus package-depth preflight with OHLCV volume caps and synthetic-book + stress tests for spread, queue, participation, partial-fill, and package + rejection assumptions. - Walk-forward and train/test optimization designed to avoid leaking OOS data into parameter selection, plus full-sample robust calibration for final production parameter discovery. @@ -88,6 +93,21 @@ Cython/C++ work. See `benchmarks/phase9_optimization_report.md`, `benchmarks/portfolio_real_parity_report.md` for parity and optimization history. +Latest Phase 14C service-loop optimization benchmark: + +| Workload | Cold / full route | Prepared / minimal route | Speedup | Parity | +|---|---:|---:|---:|---| +| Single-symbol WFO | 1.007s | 0.883s | 1.14x | pass | +| Portfolio WFO | 0.440s | 0.326s | 1.35x | pass | +| Native-event order replay | 0.0096s | 0.0046s | 2.10x | pass | +| Arbitrage package replay | 0.0746s | 0.0663s | 1.13x | pass | +| Native portfolio reports | 0.0410s full | 0.0233s minimal | 1.76x | pass | + +Phase 14C added run-local prepared market-array reuse for WFO/service loops and +`report_level="full" | "standard" | "minimal"` for native portfolio reports. +The default remains `full`; lighter report levels are opt-in for optimizers and +services, and parity tests lock core accounting equality before any speed claim. + Ecosystem positioning: | Tool | Core strength | Runtime model | QuantBT role beside it | @@ -212,6 +232,12 @@ Experimental Nautilus package validation is available for DCA/grid, bracket/OCO, basket, and portfolio workflows by compiling strategy state into explicit order packages. +Package-depth validation is opt-in. `depth_model="ohlcv_volume_cap"` is the +default Level-1 preflight, `depth_model="synthetic_book"` creates deterministic +Level-2 stress books from spread/depth assumptions, and future +`depth_model="l2_replay"` is intentionally gated until real venue snapshots, +incremental updates, and trade prints are provided. + Supported Binance perpetual validation instruments: `BTCUSDT-PERP.BINANCE`, `ETHUSDT-PERP.BINANCE`, `BNBUSDT-PERP.BINANCE`, diff --git a/__init__.py b/__init__.py index fb63002..b724905 100644 --- a/__init__.py +++ b/__init__.py @@ -92,6 +92,8 @@ from .core.execution_depth import ( NautilusExecutionDepthConfig, PackageDepthPreflightResult, + SUPPORTED_DEPTH_MODELS, + l2_replay_available, simulate_nautilus_order_package_depth, ) from .core.structured_orders import ( @@ -194,6 +196,7 @@ from .reporting import ( build_arbitrage_domain_audit, build_native_nautilus_parity_report, + build_nautilus_certification_profile, build_nautilus_depth_execution_report, build_nautilus_depth_parity_summary, build_nautilus_pct_equity_diagnostic, @@ -202,7 +205,9 @@ build_portfolio_nautilus_validation_report, compare_native_arbitrage_results, export_nautilus_report_bundle, + NautilusToleranceProfile, summarize_native_nautilus_parity_report, + write_nautilus_certification_artifacts, ) __version__ = "0.1.0" @@ -237,6 +242,7 @@ "NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES", "build_arbitrage_domain_audit", "build_native_nautilus_parity_report", + "build_nautilus_certification_profile", "build_nautilus_depth_execution_report", "build_nautilus_depth_parity_summary", "build_nautilus_pct_equity_diagnostic", @@ -245,7 +251,9 @@ "build_portfolio_nautilus_validation_report", "compare_native_arbitrage_results", "export_nautilus_report_bundle", + "NautilusToleranceProfile", "summarize_native_nautilus_parity_report", + "write_nautilus_certification_artifacts", "WalkForwardConfig", "WalkForwardEngine", "WalkForwardFold", @@ -343,6 +351,8 @@ "portfolio_capability_matrix", "quantize_signed_quantity", "round_down_to_step", + "SUPPORTED_DEPTH_MODELS", + "l2_replay_available", "simulate_nautilus_order_package_depth", "validate_portfolio_result_contract", # metrics diff --git a/benchmarks/phase15a_nautilus_certification.json b/benchmarks/phase15a_nautilus_certification.json new file mode 100644 index 0000000..e898176 --- /dev/null +++ b/benchmarks/phase15a_nautilus_certification.json @@ -0,0 +1,41 @@ +{ + "diff_workflows": 0, + "failed_workflows": 0, + "include_nautilus": false, + "output_dir": "/root/bobby/pool_alpha/quantbt/benchmarks/phase15a_nautilus_bundles", + "passed_workflows": 0, + "skipped_workflows": 5, + "status": "pass", + "workflows": [ + { + "bundle_dir": null, + "reason": "run with --include-nautilus", + "status": "skipped", + "workflow": "pct_equity_signal" + }, + { + "bundle_dir": null, + "reason": "run with --include-nautilus", + "status": "skipped", + "workflow": "explicit_orders" + }, + { + "bundle_dir": null, + "reason": "run with --include-nautilus", + "status": "skipped", + "workflow": "basket_package" + }, + { + "bundle_dir": null, + "reason": "run with --include-nautilus", + "status": "skipped", + "workflow": "portfolio_package" + }, + { + "bundle_dir": null, + "reason": "run with --include-nautilus", + "status": "skipped", + "workflow": "basis_arbitrage_package" + } + ] +} \ No newline at end of file diff --git a/benchmarks/phase15a_nautilus_certification.md b/benchmarks/phase15a_nautilus_certification.md new file mode 100644 index 0000000..3f9c557 --- /dev/null +++ b/benchmarks/phase15a_nautilus_certification.md @@ -0,0 +1,35 @@ +# Phase 15A Nautilus Certification Bundles + +Status: **pass** + +- Include Nautilus: `False` +- Output directory: `/root/bobby/pool_alpha/quantbt/benchmarks/phase15a_nautilus_bundles` +- Passed workflows: `0` +- Skipped workflows: `5` +- Failed workflows: `0` + +## Workflow Matrix + +| workflow | status | bundle | tolerance status | reason | +| --- | --- | --- | --- | --- | +| `pct_equity_signal` | `skipped` | `` | `` | run with --include-nautilus | +| `explicit_orders` | `skipped` | `` | `` | run with --include-nautilus | +| `basket_package` | `skipped` | `` | `` | run with --include-nautilus | +| `portfolio_package` | `skipped` | `` | `` | run with --include-nautilus | +| `basis_arbitrage_package` | `skipped` | `` | `` | run with --include-nautilus | + +## Required Bundle Files + +- `config.json` +- `run_manifest.json` +- `metrics_summary.json` +- `equity_curve.csv`, `returns.csv`, `account_report.csv` +- `orders_report.csv`, `fills_report.csv`, `positions_report.csv` +- `trade_log.csv`, `fill_log.txt` +- `native_vs_nautilus_parity.csv` +- `tolerance_profile.json` +- `known_differences.md` + +## Interpretation + +A skipped workflow is not a pass claim. It means the optional Nautilus dependency or instrument route was not available in this environment. A pass means the workflow produced a bundle and satisfied the declared tolerance profile. diff --git a/benchmarks/phase15b_synthetic_depth.json b/benchmarks/phase15b_synthetic_depth.json new file mode 100644 index 0000000..43b4ce5 --- /dev/null +++ b/benchmarks/phase15b_synthetic_depth.json @@ -0,0 +1,38 @@ +{ + "phase": "15B", + "status": "pass", + "l2_replay_available": false, + "cases": [ + { + "case": "synthetic_market_vwap", + "depth_model": "synthetic_book", + "status": "filled", + "filled_qty": 2.0, + "fill_price": 100.1, + "levels_consumed": 2, + "accepted_orders": 1, + "rejected_orders": 0 + }, + { + "case": "synthetic_partial_queue", + "depth_model": "synthetic_book", + "status": "partial", + "filled_qty": 1.5, + "fill_price": 100.02333333333333, + "levels_consumed": 2, + "accepted_orders": 1, + "rejected_orders": 0 + }, + { + "case": "ohlcv_all_or_none_baseline", + "depth_model": "ohlcv_volume_cap", + "status": "filled", + "filled_qty": 1.0, + "fill_price": 100.0, + "levels_consumed": 1, + "accepted_orders": 1, + "rejected_orders": 0 + } + ], + "claim_scope": "Level-2 synthetic stress only; not venue L2 replay." +} \ No newline at end of file diff --git a/benchmarks/phase15b_synthetic_depth.md b/benchmarks/phase15b_synthetic_depth.md new file mode 100644 index 0000000..d8c9147 --- /dev/null +++ b/benchmarks/phase15b_synthetic_depth.md @@ -0,0 +1,16 @@ +# Phase 15B Synthetic Depth Evidence + +Status: **pass** + +- L2 replay provider available: `False` +- Claim scope: Level-2 synthetic stress only; not venue L2 replay. + +| case | depth model | status | filled qty | fill price | levels | accepted | rejected | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | +| `synthetic_market_vwap` | `synthetic_book` | `filled` | 2.00000000 | 100.10000000 | 2 | 1 | 0 | +| `synthetic_partial_queue` | `synthetic_book` | `partial` | 1.50000000 | 100.02333333 | 2 | 1 | 0 | +| `ohlcv_all_or_none_baseline` | `ohlcv_volume_cap` | `filled` | 1.00000000 | 100.00000000 | 1 | 1 | 0 | + +## Interpretation + +Synthetic depth proves deterministic queue, participation, spread and level-consumption behavior. It does not certify real exchange queue priority. Real L2 certification remains gated by venue snapshots, incremental updates and trade prints. diff --git a/benchmarks/run_phase15a_nautilus_certification.py b/benchmarks/run_phase15a_nautilus_certification.py new file mode 100644 index 0000000..ec08981 --- /dev/null +++ b/benchmarks/run_phase15a_nautilus_certification.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +""" +Phase 15A Nautilus certification bundle runner. + +This runner is an evidence-layer tool. It does not change engine semantics. +Nautilus workflows are optional because they require the external +`nautilus_trader` dependency and supported test instruments. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + AccountConfig, + ArbExecutionPolicy, + ArbitrageLeg, + BasisArbitrageSpec, + BasketLegSpec, + BasketSpec, + ContractType, + HedgePolicy, + HedgePolicyKind, + NativeEventBackend, + NativeEventConfig, + NautilusToleranceProfile, + OrderIntent, + OrderSide, + OrderType, + PackageExecutionKind, + QuantBTEndpoint, + SizingPolicy, + SizingPolicyKind, + TimeInForce, + export_nautilus_report_bundle, + write_nautilus_certification_artifacts, +) + + +WORKFLOWS = ( + "pct_equity_signal", + "explicit_orders", + "basket_package", + "portfolio_package", + "basis_arbitrage_package", +) + + +def run_certification( + *, + rows: int = 96, + include_nautilus: bool = False, + output_dir: str | Path = PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_bundles", + make_quantstats: bool = False, +) -> Dict: + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + if not include_nautilus: + workflows = [ + { + "workflow": name, + "status": "skipped", + "reason": "run with --include-nautilus", + "bundle_dir": None, + } + for name in WORKFLOWS + ] + return _summary(workflows=workflows, output_dir=output_path, include_nautilus=False) + + availability = _nautilus_available() + if availability is not None: + workflows = [ + { + "workflow": name, + "status": "skipped", + "reason": availability, + "bundle_dir": None, + } + for name in WORKFLOWS + ] + return _summary(workflows=workflows, output_dir=output_path, include_nautilus=True) + + runners: Dict[str, Callable[[int, Path, bool], Dict]] = { + "pct_equity_signal": _run_pct_equity_signal, + "explicit_orders": _run_explicit_orders, + "basket_package": _run_basket_package, + "portfolio_package": _run_portfolio_package, + "basis_arbitrage_package": _run_basis_arbitrage_package, + } + workflows = [] + for name in WORKFLOWS: + try: + workflows.append(runners[name](int(rows), output_path, bool(make_quantstats))) + except ImportError as exc: + workflows.append({"workflow": name, "status": "skipped", "reason": str(exc), "bundle_dir": None}) + except NotImplementedError as exc: + workflows.append({"workflow": name, "status": "skipped", "reason": str(exc), "bundle_dir": None}) + except Exception as exc: # pragma: no cover - only hit with optional external backend drift + workflows.append({"workflow": name, "status": "failed", "reason": f"{type(exc).__name__}: {exc}", "bundle_dir": None}) + return _summary(workflows=workflows, output_dir=output_path, include_nautilus=True) + + +def make_markdown(report: Dict) -> str: + lines = [ + "# Phase 15A Nautilus Certification Bundles", + "", + f"Status: **{report['status']}**", + "", + f"- Include Nautilus: `{report['include_nautilus']}`", + f"- Output directory: `{report['output_dir']}`", + f"- Passed workflows: `{report['passed_workflows']}`", + f"- Skipped workflows: `{report['skipped_workflows']}`", + f"- Failed workflows: `{report['failed_workflows']}`", + "", + "## Workflow Matrix", + "", + "| workflow | status | bundle | tolerance status | reason |", + "| --- | --- | --- | --- | --- |", + ] + for item in report["workflows"]: + lines.append( + "| `{workflow}` | `{status}` | `{bundle}` | `{tol}` | {reason} |".format( + workflow=item["workflow"], + status=item["status"], + bundle=item.get("bundle_dir") or "", + tol=item.get("tolerance_status") or "", + reason=item.get("reason", ""), + ) + ) + lines.extend( + [ + "", + "## Required Bundle Files", + "", + "- `config.json`", + "- `run_manifest.json`", + "- `metrics_summary.json`", + "- `equity_curve.csv`, `returns.csv`, `account_report.csv`", + "- `orders_report.csv`, `fills_report.csv`, `positions_report.csv`", + "- `trade_log.csv`, `fill_log.txt`", + "- `native_vs_nautilus_parity.csv`", + "- `tolerance_profile.json`", + "- `known_differences.md`", + "", + "## Interpretation", + "", + "A skipped workflow is not a pass claim. It means the optional Nautilus dependency or instrument route was not available in this environment. A pass means the workflow produced a bundle and satisfied the declared tolerance profile.", + ] + ) + return "\n".join(lines) + "\n" + + +def _run_pct_equity_signal(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + data = _single_data(rows) + signal = _signal(data.index) + native = QuantBTEndpoint.pct_equity( + initial_capital=20_000.0, + leverage=3.0, + alloc_per_trade=0.4, + fee=0.0004, + slippage=0.0, + use_funding=False, + use_pyramiding=False, + ) + native_result = native.backtest(data=data, signal=signal) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.nautilus_validation( + initial_capital=20_000.0, + leverage=3.0, + alloc_per_trade=0.4, + hedge_type="%_equity", + fee_rate=0.0002, + use_funding=False, + use_pyramiding=False, + nautilus_config=NautilusBackendConfig( + instrument_id="BTCUSDT-PERP.BINANCE", + timeframe="1h", + trade_notional=0.4, + bypass_risk=True, + close_positions_on_stop=False, + ), + ) + nt_result = nt.simulate(data=data, signal=signal, symbols=["BTCUSDT-PERP.BINANCE"]) + return _export_workflow( + workflow="pct_equity_signal", + native_result=native_result, + nautilus_result=nt_result, + output_dir=output_dir, + make_quantstats=make_quantstats, + known_differences=[ + "Nautilus signal validation uses adapter-generated market orders from target signals.", + "Custom slippage and funding support depend on the current Nautilus adapter route.", + ], + tolerance=NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + ) + + +def _run_explicit_orders(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + data = _single_data(rows) + idx = data.index + orders = ( + OrderIntent(idx[5], "BTCUSDT-PERP.BINANCE", OrderSide.BUY, OrderType.MARKET, qty=0.05, tif=TimeInForce.IOC), + OrderIntent(idx[20], "BTCUSDT-PERP.BINANCE", OrderSide.SELL, OrderType.MARKET, qty=0.05, tif=TimeInForce.IOC), + ) + native = QuantBTEndpoint.orders( + backend="native_event", + initial_capital=20_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + ).simulate(data=data, orders=orders, symbols=["BTCUSDT-PERP.BINANCE"]) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.orders( + backend="nautilus", + initial_capital=20_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig( + instrument_id="BTCUSDT-PERP.BINANCE", + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, orders=orders, symbols=["BTCUSDT-PERP.BINANCE"]) + return _export_workflow("explicit_orders", native, nt, output_dir, make_quantstats) + + +def _run_basket_package(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + data = _multi_data(rows, ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE")) + idx = next(iter(data.values())).index + signal = pd.Series(0.0, index=idx) + signal.iloc[5:30] = 1.0 + basket = BasketSpec( + basket_id="PHASE15A_BASKET", + legs=(BasketLegSpec("BTCUSDT-PERP.BINANCE", 1.0), BasketLegSpec("ETHUSDT-PERP.BINANCE", -1.0)), + gross_notional=5_000.0, + freeze_hedge=True, + ) + native = QuantBTEndpoint.basket( + basket=basket, + backend="native_event", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + ).simulate(data=data, signal=signal, symbols=list(data)) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.basket( + basket=basket, + backend="nautilus", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig( + instrument_id="BTCUSDT-PERP.BINANCE", + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, signal=signal, symbols=list(data)) + return _export_workflow("basket_package", native, nt, output_dir, make_quantstats) + + +def _run_portfolio_package(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + symbols = ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE") + data = _multi_data(rows, symbols) + idx = next(iter(data.values())).index + positions = pd.DataFrame( + { + symbols[0]: np.where(np.arange(len(idx)) % 24 < 12, 1.0, 0.0), + symbols[1]: np.where(np.arange(len(idx)) % 24 < 12, -1.0, 0.0), + }, + index=idx, + ) + native = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="native_portfolio", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + hedge_type="signal_notional", + alloc_per_trade={symbols[0]: 2_500.0, symbols[1]: 2_500.0}, + use_funding=False, + ).backtest(data=data, positions=positions, symbols=list(symbols)) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="nautilus", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + hedge_type="signal_notional", + alloc_per_trade={symbols[0]: 2_500.0, symbols[1]: 2_500.0}, + use_funding=False, + metadata={"portfolio_nautilus_equity_tolerance": 5.0, "portfolio_nautilus_position_tolerance": 0.01}, + nautilus_config=NautilusBackendConfig( + instrument_id=symbols[0], + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, positions=positions, symbols=list(symbols)) + return _export_workflow( + "portfolio_package", + native, + nt, + output_dir, + make_quantstats, + known_differences=["Portfolio route submits native transformed target-unit deltas to Nautilus package replay."], + tolerance=NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + ) + + +def _run_basis_arbitrage_package(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + symbols = ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE") + data = _multi_data(rows, symbols) + closes = {symbol: frame["close"] for symbol, frame in data.items()} + idx = next(iter(data.values())).index + signal = pd.Series(0.0, index=idx) + signal.iloc[8:40] = 1.0 + spec = BasisArbitrageSpec( + arb_id="PHASE15A_BASIS", + legs=( + ArbitrageLeg(symbols[0], 1.0, role="perp", contract_type=ContractType.LINEAR, funding_enabled=True), + ArbitrageLeg(symbols[1], -1.0, role="quarterly", contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy( + SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=5_000.0, + reference_symbol=symbols[0], + ), + execution_policy=ArbExecutionPolicy(PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + native = NativeEventBackend( + NativeEventConfig(account=AccountConfig(initial_capital=50_000.0, leverage=3.0), fee_rate=0.0002, use_funding=False) + ).run_basis_arbitrage(idx, spec, signal, closes, funding_rate=0.0) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.arbitrage( + "basis", + spec=spec, + backend="nautilus", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig( + instrument_id=symbols[0], + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, signal=signal, symbols=list(symbols)) + return _export_workflow( + "basis_arbitrage_package", + native, + nt, + output_dir, + make_quantstats, + known_differences=[ + "This smoke uses supported perpetual test instruments as a package proxy, not a real delivery-futures venue model.", + ], + tolerance=NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + ) + + +def _export_workflow( + workflow: str, + native_result, + nautilus_result, + output_dir: Path, + make_quantstats: bool, + known_differences: Optional[List[str]] = None, + tolerance: NautilusToleranceProfile | None = None, +) -> Dict: + bundle_dir = export_nautilus_report_bundle( + result=nautilus_result, + output_dir=output_dir / workflow, + strategy_id=workflow, + config={"certification_workflow": workflow}, + make_quantstats=make_quantstats, + fill_log_limit=200, + ) + artifacts = write_nautilus_certification_artifacts( + native_result=native_result, + nautilus_result=nautilus_result, + report_dir=bundle_dir, + workflow=workflow, + tolerance=tolerance or NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + known_differences=known_differences, + ) + profile = artifacts["tolerance_profile"] + return { + "workflow": workflow, + "status": "pass" if profile["passed"] else "diff", + "bundle_dir": str(bundle_dir), + "tolerance_status": profile["status"], + "checks": profile["checks"], + "artifact_files": artifacts["artifact_files"], + } + + +def _summary(workflows: List[Dict], output_dir: Path, include_nautilus: bool) -> Dict: + failed = [item for item in workflows if item["status"] == "failed"] + passed = [item for item in workflows if item["status"] == "pass"] + skipped = [item for item in workflows if item["status"] == "skipped"] + diff = [item for item in workflows if item["status"] == "diff"] + status = "fail" if failed else "pass" + return { + "status": status, + "include_nautilus": bool(include_nautilus), + "output_dir": str(output_dir), + "workflows": workflows, + "passed_workflows": len(passed), + "skipped_workflows": len(skipped), + "diff_workflows": len(diff), + "failed_workflows": len(failed), + } + + +def _nautilus_available() -> Optional[str]: + try: + from quantbt.adapters.nautilus import NautilusBacktestEngine + + NautilusBacktestEngine.check_available() + return None + except Exception as exc: + return f"{type(exc).__name__}: {exc}" + + +def _single_data(rows: int) -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=int(rows), freq="1h", tz="UTC") + x = np.linspace(0.0, 10.0, len(idx)) + close = 100.0 + np.cumsum(np.sin(x) * 0.08 + np.cos(x / 2.0) * 0.03) + return pd.DataFrame( + {"open": close, "high": close * 1.002, "low": close * 0.998, "close": close, "volume": 1_000.0}, + index=idx, + ) + + +def _multi_data(rows: int, symbols: Tuple[str, ...]) -> Dict[str, pd.DataFrame]: + base = _single_data(rows) + out = {} + for i, symbol in enumerate(symbols): + frame = base.copy() + scale = 1.0 + i * 0.15 + frame[["open", "high", "low", "close"]] = frame[["open", "high", "low", "close"]] * scale + frame["volume"] = frame["volume"] * (1.0 + i) + out[symbol] = frame + return out + + +def _signal(idx: pd.DatetimeIndex) -> pd.Series: + signal = pd.Series(0.0, index=idx) + signal.iloc[5 : max(6, len(idx) // 3)] = 1.0 + signal.iloc[max(8, len(idx) // 2) : max(9, len(idx) * 2 // 3)] = -1.0 + return signal + + +def _json_default(value): + if isinstance(value, (np.bool_,)): + return bool(value) + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating,)): + return float(value) + if isinstance(value, pd.Timestamp): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=96) + parser.add_argument("--include-nautilus", action="store_true") + parser.add_argument("--make-quantstats", action="store_true") + parser.add_argument("--output-dir", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_bundles") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_certification.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_certification.md") + args = parser.parse_args(argv) + report = run_certification( + rows=args.rows, + include_nautilus=args.include_nautilus, + output_dir=args.output_dir, + make_quantstats=args.make_quantstats, + ) + args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=_json_default), encoding="utf-8") + args.md_out.write_text(make_markdown(report), encoding="utf-8") + print(make_markdown(report)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/run_phase15b_synthetic_depth.py b/benchmarks/run_phase15b_synthetic_depth.py new file mode 100644 index 0000000..c8b2f4b --- /dev/null +++ b/benchmarks/run_phase15b_synthetic_depth.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Phase 15B synthetic depth evidence runner. + +This script creates deterministic OHLCV and synthetic-book depth cases. It is +not a venue L2 replay benchmark; it is an audit artifact for package-depth +invariants before optional Nautilus validation. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict, List + +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + NautilusExecutionDepthConfig, + OrderIntent, + OrderSide, + OrderType, + l2_replay_available, + simulate_nautilus_order_package_depth, +) + + +def run_phase15b_synthetic_depth() -> Dict: + data = {"BTCUSDT-PERP.BINANCE": _frame()} + idx = data["BTCUSDT-PERP.BINANCE"].index + cases = [ + ( + "synthetic_market_vwap", + [ + OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=2.0, + ) + ], + NautilusExecutionDepthConfig( + depth_model="synthetic_book", + allow_partial_fills=True, + synthetic_spread_bps=10.0, + synthetic_level_spacing_bps=10.0, + synthetic_levels=3, + synthetic_base_depth_qty=1.0, + ), + ), + ( + "synthetic_partial_queue", + [ + OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=3.0, + ) + ], + NautilusExecutionDepthConfig( + depth_model="synthetic_book", + allow_partial_fills=True, + synthetic_levels=2, + synthetic_base_depth_qty=1.0, + queue_ahead_qty=0.5, + ), + ), + ( + "ohlcv_all_or_none_baseline", + [ + OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + metadata={"package_id": "P1", "package_type": "basket_package"}, + ) + ], + NautilusExecutionDepthConfig(all_or_none_packages=True), + ), + ] + results: List[Dict] = [] + for name, orders, cfg in cases: + preflight = simulate_nautilus_order_package_depth(orders, data, cfg) + row = preflight.order_report.iloc[0].to_dict() + results.append( + { + "case": name, + "depth_model": cfg.depth_model, + "status": str(row.get("status")), + "filled_qty": float(row.get("filled_qty", 0.0)), + "fill_price": float(row.get("fill_price", 0.0)), + "levels_consumed": int(row.get("levels_consumed", 0)), + "accepted_orders": int(preflight.metadata["accepted_orders"]), + "rejected_orders": int(preflight.metadata["rejected_orders"]), + } + ) + return { + "phase": "15B", + "status": "pass" if all(item["status"] in {"filled", "partial"} for item in results) else "review", + "l2_replay_available": bool(l2_replay_available()), + "cases": results, + "claim_scope": "Level-2 synthetic stress only; not venue L2 replay.", + } + + +def make_markdown(report: Dict) -> str: + lines = [ + "# Phase 15B Synthetic Depth Evidence", + "", + f"Status: **{report['status']}**", + "", + f"- L2 replay provider available: `{report['l2_replay_available']}`", + f"- Claim scope: {report['claim_scope']}", + "", + "| case | depth model | status | filled qty | fill price | levels | accepted | rejected |", + "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |", + ] + for item in report["cases"]: + lines.append( + "| `{case}` | `{depth_model}` | `{status}` | {filled_qty:.8f} | {fill_price:.8f} | {levels_consumed} | {accepted_orders} | {rejected_orders} |".format( + **item + ) + ) + lines.extend( + [ + "", + "## Interpretation", + "", + "Synthetic depth proves deterministic queue, participation, spread and level-consumption behavior. It does not certify real exchange queue priority. Real L2 certification remains gated by venue snapshots, incremental updates and trade prints.", + ] + ) + return "\n".join(lines) + "\n" + + +def _frame() -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=4, freq="1h", tz="UTC") + return pd.DataFrame( + { + "open": [100.0, 100.0, 100.0, 100.0], + "high": [101.0, 101.0, 101.0, 101.0], + "low": [99.0, 99.0, 99.0, 99.0], + "close": [100.0, 100.0, 100.0, 100.0], + "volume": [100.0, 100.0, 100.0, 100.0], + }, + index=idx, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--output-json", default=str(PACKAGE_DIR / "benchmarks" / "phase15b_synthetic_depth.json")) + parser.add_argument("--output-md", default=str(PACKAGE_DIR / "benchmarks" / "phase15b_synthetic_depth.md")) + args = parser.parse_args() + report = run_phase15b_synthetic_depth() + json_path = Path(args.output_json) + md_path = Path(args.output_md) + json_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + md_path.write_text(make_markdown(report), encoding="utf-8") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/core/__init__.py b/core/__init__.py index 2bacef5..167f557 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -8,6 +8,8 @@ from .execution_depth import ( NautilusExecutionDepthConfig, PackageDepthPreflightResult, + SUPPORTED_DEPTH_MODELS, + l2_replay_available, simulate_nautilus_order_package_depth, ) from .structured_orders import ( @@ -155,6 +157,8 @@ "build_dca_grid_order_plan", "build_frozen_basket_orders", "round_down_to_step", + "SUPPORTED_DEPTH_MODELS", + "l2_replay_available", "simulate_nautilus_order_package_depth", "validate_datetime", "align_series", diff --git a/core/execution_depth.py b/core/execution_depth.py index 200a428..aca54be 100644 --- a/core/execution_depth.py +++ b/core/execution_depth.py @@ -18,6 +18,9 @@ from .schema import OrderSide, OrderType +SUPPORTED_DEPTH_MODELS = ("ohlcv_volume_cap", "synthetic_book", "l2_replay") + + @dataclass(frozen=True) class NautilusExecutionDepthConfig: """ @@ -34,18 +37,38 @@ class NautilusExecutionDepthConfig: queue_ahead_qty: float = 0.0 latency_bars: int = 0 depth_model: str = "ohlcv_volume_cap" + synthetic_spread_bps: float = 2.0 + synthetic_level_spacing_bps: Optional[float] = None + synthetic_levels: int = 5 + synthetic_base_depth_qty: Optional[float] = None + synthetic_base_depth_notional: Optional[float] = None + synthetic_depth_slope: float = 0.0 activate_oco_after_entry_fill: bool = True cancel_oco_sibling_on_first_exit_fill: bool = True cap_reduce_only_to_position: bool = True metadata: Dict = field(default_factory=dict) def __post_init__(self) -> None: + if self.depth_model not in SUPPORTED_DEPTH_MODELS: + raise ValueError(f"depth_model must be one of {SUPPORTED_DEPTH_MODELS}") if self.max_participation_rate is not None and not 0.0 <= self.max_participation_rate <= 1.0: raise ValueError("max_participation_rate must be in [0, 1]") if self.queue_ahead_qty < 0.0: raise ValueError("queue_ahead_qty must be >= 0") if self.latency_bars < 0: raise ValueError("latency_bars must be >= 0") + if self.synthetic_spread_bps < 0.0: + raise ValueError("synthetic_spread_bps must be >= 0") + if self.synthetic_level_spacing_bps is not None and self.synthetic_level_spacing_bps < 0.0: + raise ValueError("synthetic_level_spacing_bps must be >= 0") + if self.synthetic_levels <= 0: + raise ValueError("synthetic_levels must be > 0") + if self.synthetic_base_depth_qty is not None and self.synthetic_base_depth_qty <= 0.0: + raise ValueError("synthetic_base_depth_qty must be > 0") + if self.synthetic_base_depth_notional is not None and self.synthetic_base_depth_notional <= 0.0: + raise ValueError("synthetic_base_depth_notional must be > 0") + if self.synthetic_depth_slope < -1.0: + raise ValueError("synthetic_depth_slope must be >= -1") @dataclass(frozen=True) @@ -56,6 +79,17 @@ class PackageDepthPreflightResult: metadata: Dict = field(default_factory=dict) +def l2_replay_available(provider: object = None) -> bool: + """ + Return whether a real L2 replay provider is configured. + + QuantBT intentionally does not synthesize Level-3 venue claims. A provider + must expose venue snapshots, incremental book updates, and trade prints. + """ + required = ("snapshots", "updates", "trades") + return provider is not None and all(hasattr(provider, name) for name in required) + + def simulate_nautilus_order_package_depth( orders: Sequence[OrderIntent], data: Dict[str, pd.DataFrame], @@ -70,6 +104,11 @@ def simulate_nautilus_order_package_depth( reduce-only caps, OCO sibling cancellation, and all-or-none package reject. """ cfg = config or NautilusExecutionDepthConfig() + if cfg.depth_model == "l2_replay": + raise NotImplementedError( + "depth_model='l2_replay' requires real venue L2 snapshots, incremental updates, " + "trade prints, and a provider adapter. Use depth_model='synthetic_book' for deterministic stress tests." + ) if not orders: return PackageDepthPreflightResult( orders=tuple(), @@ -149,6 +188,7 @@ def simulate_nautilus_order_package_depth( "allow_partial_fills": bool(cfg.allow_partial_fills), "all_or_none_packages": bool(cfg.all_or_none_packages), "depth_model": str(cfg.depth_model), + "supported_depth_models": SUPPORTED_DEPTH_MODELS, **cfg.metadata, } return PackageDepthPreflightResult( @@ -187,6 +227,16 @@ class _EvaluatedOrder: accepted_order: Optional[OrderIntent] +@dataclass(frozen=True) +class _DepthFill: + fillable: bool + fill_price: float + reason: str + available_qty: float + levels_consumed: int = 0 + participation_cap_qty: float = np.nan + + _ORDER_REPORT_COLUMNS = [ "timestamp", "effective_timestamp", @@ -204,6 +254,13 @@ class _EvaluatedOrder: "oco_group_id", "latency_bars", "available_qty", + "depth_model", + "levels_consumed", + "spread_bps", + "queue_ahead_qty", + "participation_cap_qty", + "requested_notional", + "filled_notional", ] _PACKAGE_REPORT_COLUMNS = ["package_id", "package_type", "timestamp", "orders", "status", "reason"] @@ -235,11 +292,11 @@ def _evaluate_order( return _EvaluatedOrder(row=row, accepted_order=None) bar = frame.loc[ts] - fillable, fill_price, reason = _fillability(order, bar) - if not fillable: - return _reject(base, reason) + depth_fill = _evaluate_depth_fill(order, bar, cfg) + if not depth_fill.fillable: + return _reject(base, depth_fill.reason) - available = _available_qty(order, bar, cfg) + available = depth_fill.available_qty requested = float(order.qty) reduce_only_capped = False if order.reduce_only and cfg.cap_reduce_only_to_position: @@ -263,6 +320,7 @@ def _evaluate_order( "depth_original_qty": requested, "depth_effective_timestamp": ts, "depth_status": status, + "depth_model": cfg.depth_model, } accepted_order = replace(order, timestamp=ts, qty=float(filled_qty), metadata=metadata) @@ -276,10 +334,14 @@ def _evaluate_order( row = { **base, "filled_qty": float(filled_qty), - "fill_price": float(fill_price), + "fill_price": float(depth_fill.fill_price), "status": status, "reject_reason": "", "available_qty": float(available), + "levels_consumed": int(depth_fill.levels_consumed), + "participation_cap_qty": float(depth_fill.participation_cap_qty), + "requested_notional": float(requested * depth_fill.fill_price), + "filled_notional": float(filled_qty * depth_fill.fill_price), } return _EvaluatedOrder(row=row, accepted_order=accepted_order) @@ -316,6 +378,113 @@ def _fillability(order: OrderIntent, bar: pd.Series) -> tuple[bool, float, str]: return False, np.nan, "unsupported_order_type" +def _evaluate_depth_fill(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> _DepthFill: + if cfg.depth_model == "synthetic_book": + return _synthetic_book_fill(order, bar, cfg) + + fillable, fill_price, reason = _fillability(order, bar) + if not fillable: + return _DepthFill(False, fill_price, reason, 0.0) + available = _available_qty(order, bar, cfg) + participation_cap = _participation_cap_qty(bar, cfg) + return _DepthFill( + fillable=True, + fill_price=float(fill_price), + reason="", + available_qty=float(available), + levels_consumed=1, + participation_cap_qty=participation_cap, + ) + + +def _synthetic_book_fill(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> _DepthFill: + eligible, executable_price, reason = _fillability(order, bar) + if not eligible: + return _DepthFill(False, executable_price, reason, 0.0) + + close = float(bar["close"]) + if not np.isfinite(close) or close <= 0.0: + return _DepthFill(False, np.nan, "invalid_close_for_synthetic_book", 0.0) + + levels = _synthetic_book_levels(order, close, cfg) + if order.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): + limit_price = float(order.price) + if order.side is OrderSide.BUY: + levels = tuple((price, qty) for price, qty in levels if price <= limit_price) + else: + levels = tuple((price, qty) for price, qty in levels if price >= limit_price) + + participation_cap = _participation_cap_qty(bar, cfg) + requested = float(order.qty) + target_qty = min(requested, participation_cap) if np.isfinite(participation_cap) else requested + if target_qty <= 0.0: + return _DepthFill(True, executable_price, "", 0.0, participation_cap_qty=participation_cap) + + remaining_queue = float(cfg.queue_ahead_qty) + remaining = target_qty + filled = 0.0 + notional = 0.0 + consumed = 0 + for price, level_qty in levels: + qty_after_queue = float(level_qty) + if remaining_queue > 0.0: + queue_take = min(qty_after_queue, remaining_queue) + qty_after_queue -= queue_take + remaining_queue -= queue_take + if qty_after_queue <= 0.0: + consumed += 1 + continue + take = min(remaining, qty_after_queue) + if take <= 0.0: + break + filled += take + notional += take * float(price) + remaining -= take + consumed += 1 + if remaining <= 1e-15: + break + + if filled <= 0.0: + return _DepthFill(True, executable_price, "", 0.0, levels_consumed=consumed, participation_cap_qty=participation_cap) + return _DepthFill( + fillable=True, + fill_price=float(notional / filled), + reason="", + available_qty=float(filled), + levels_consumed=int(consumed), + participation_cap_qty=participation_cap, + ) + + +def _synthetic_book_levels( + order: OrderIntent, + reference_price: float, + cfg: NautilusExecutionDepthConfig, +) -> Tuple[Tuple[float, float], ...]: + half_spread = reference_price * float(cfg.synthetic_spread_bps) / 20_000.0 + spacing_bps = cfg.synthetic_level_spacing_bps + if spacing_bps is None: + spacing_bps = max(float(cfg.synthetic_spread_bps), 1.0) + spacing = reference_price * float(spacing_bps) / 10_000.0 + + if cfg.synthetic_base_depth_qty is not None: + base_qty = float(cfg.synthetic_base_depth_qty) + elif cfg.synthetic_base_depth_notional is not None: + base_qty = float(cfg.synthetic_base_depth_notional) / reference_price + else: + base_qty = float(order.qty) + + out: list[Tuple[float, float]] = [] + for level in range(int(cfg.synthetic_levels)): + if order.side is OrderSide.BUY: + price = reference_price + half_spread + level * spacing + else: + price = reference_price - half_spread - level * spacing + qty_multiplier = max(0.0, 1.0 + float(cfg.synthetic_depth_slope) * level) + out.append((float(price), float(base_qty * qty_multiplier))) + return tuple(out) + + def _available_qty(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> float: if cfg.max_participation_rate is None: return float(order.qty) @@ -324,6 +493,13 @@ def _available_qty(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDep return min(float(order.qty), capacity) +def _participation_cap_qty(bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> float: + if cfg.max_participation_rate is None: + return np.nan + volume = float(bar.get("volume", 0.0)) + return max(0.0, volume * float(cfg.max_participation_rate)) + + def _effective_timestamp( order: OrderIntent, frames: Dict[str, pd.DataFrame], @@ -381,6 +557,13 @@ def _base_row(order: OrderIntent, effective_timestamp: Optional[pd.Timestamp], c "oco_group_id": order.metadata.get("oco_group_id"), "latency_bars": int(cfg.latency_bars), "available_qty": np.nan, + "depth_model": str(cfg.depth_model), + "levels_consumed": 0, + "spread_bps": float(cfg.synthetic_spread_bps) if cfg.depth_model == "synthetic_book" else np.nan, + "queue_ahead_qty": float(cfg.queue_ahead_qty), + "participation_cap_qty": np.nan, + "requested_notional": np.nan, + "filled_notional": 0.0, } diff --git a/docs/README.md b/docs/README.md index 61aa634..fb06efb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ Use this page as the first stop when deciding which QuantBT document to read. | Understand market/limit/stop fill behavior | [Order fill policies](order_fill_policies.md) | | Build pair trades or baskets | [Pair and basket guide](pair_basket_guide.md) | | Understand Portfolio Engine V3 roadmap | [Portfolio Engine V3](portfolio_engine_v3.md) | -| Use Nautilus as third-party execution validation | [Nautilus backend](nautilus_backend.md) | +| Use Nautilus as third-party execution validation, reports, and depth preflight | [Nautilus backend](nautilus_backend.md) | | Understand WFO parameter selection methodology | [Walk-forward methodology](walkforward_methodology_vi.md) | ## Strategy Route Map diff --git a/docs/endpoint.md b/docs/endpoint.md index 2bd1c89..71dc3a6 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -615,6 +615,51 @@ high/low touch eligibility, latency bars, queue-ahead, volume participation, reduce-only capping, OCO sibling cancellation, and all-or-none package rejection before accepted orders are submitted through Nautilus routes. +Synthetic book depth stress: + +```python +from quantbt import NautilusExecutionDepthConfig, simulate_nautilus_order_package_depth + +preflight = simulate_nautilus_order_package_depth( + orders=plan.orders, + data={"BTCUSDT-PERP.BINANCE": df}, + config=NautilusExecutionDepthConfig( + depth_model="synthetic_book", + allow_partial_fills=True, + max_participation_rate=0.05, + queue_ahead_qty=0.25, + synthetic_spread_bps=2.0, + synthetic_level_spacing_bps=2.0, + synthetic_levels=8, + synthetic_base_depth_notional=50_000, + synthetic_depth_slope=0.05, + ), +) + +preflight.order_report +``` + +Use `ohlcv_volume_cap` for fast Level-1 package checks, `synthetic_book` for +deterministic Level-2 execution stress, and reserve `l2_replay` for future real +venue book replay. `l2_replay` intentionally refuses to run without a provider +containing snapshots, incremental book updates, and trade prints. + +Depth helper surface: + +```python +from quantbt import SUPPORTED_DEPTH_MODELS, l2_replay_available + +SUPPORTED_DEPTH_MODELS +# ("ohlcv_volume_cap", "synthetic_book", "l2_replay") + +if l2_replay_available(provider): + ... +``` + +`l2_replay_available(...)` is a guardrail helper. It only returns true when a +provider exposes `snapshots`, `updates`, and `trades`; otherwise services should +use `ohlcv_volume_cap` or `synthetic_book` and label reports accordingly. + Structured bracket/OCO validation: ```python diff --git a/docs/nautilus_backend.md b/docs/nautilus_backend.md index cd21179..e9290fb 100644 --- a/docs/nautilus_backend.md +++ b/docs/nautilus_backend.md @@ -101,6 +101,35 @@ Report bundle: `cancelled_count`, and `rejected_count` to `run_manifest.json` and `metrics_summary.json`. +Certification bundles: + +```bash +PYTHONPATH=/root/bobby/pool_alpha poetry run python3 \ + quantbt/benchmarks/run_phase15a_nautilus_certification.py \ + --include-nautilus \ + --rows 96 +``` + +Phase 15A exports representative stakeholder bundles for: + +- single-symbol `%_equity` signal validation; +- explicit `OrderIntent` replay; +- basket package replay; +- portfolio matrix package replay; +- basis-arbitrage package replay when the required instrument route is + available. + +Each successful workflow writes the normal Nautilus report bundle plus: + +- `native_vs_nautilus_parity.csv`; +- `tolerance_profile.json`; +- `known_differences.md`; +- `certification_summary.json`. + +If `nautilus-trader` or a required instrument route is missing, the runner +marks the workflow as `skipped`. A skipped row is not a pass claim; it is an +honest dependency/instrument availability statement. + `%_equity` diagnostic: ```python @@ -147,6 +176,16 @@ Execution-depth preflight: - OCO sibling cancellation after the first TP/SL exit fill; - all-or-none package rejection for basket/arbitrage groups. - Existing endpoints do not enable this policy by default. +- Phase 15B adds explicit depth tiers: + - `depth_model="ohlcv_volume_cap"` is the default Level-1 model. It uses + OHLCV touch/volume assumptions and preserves all legacy behavior. + - `depth_model="synthetic_book"` is a Level-2 stress model. It builds a + deterministic book from spread, level spacing, level depth, slope, + participation and queue-ahead assumptions. It is useful for conservative + execution stress tests, but it is not venue L2 replay. + - `depth_model="l2_replay"` is provider-gated and raises until real snapshots, + incremental updates and trade prints are wired. Only this future route can + make true order-book / queue-priority claims. ```python from quantbt import NautilusExecutionDepthConfig, simulate_nautilus_order_package_depth @@ -168,15 +207,57 @@ preflight.package_report preflight.orders # accepted / adjusted orders ``` +Synthetic book stress: + +```python +preflight = simulate_nautilus_order_package_depth( + orders=plan.orders, + data={"BTCUSDT-PERP.BINANCE": df_btc}, + config=NautilusExecutionDepthConfig( + depth_model="synthetic_book", + allow_partial_fills=True, + max_participation_rate=0.05, + queue_ahead_qty=0.25, + synthetic_spread_bps=2.0, + synthetic_level_spacing_bps=2.0, + synthetic_levels=10, + synthetic_base_depth_notional=25_000, + synthetic_depth_slope=0.10, + ), +) + +preflight.order_report[ + [ + "status", + "filled_qty", + "fill_price", + "levels_consumed", + "requested_notional", + "filled_notional", + ] +] +``` + +Interpretation: + +- market buys consume synthetic ask levels and market sells consume synthetic + bid levels; +- limit and stop-limit orders still need high/low touch first, then consume + only synthetic levels that respect the limit price; +- `max_participation_rate` caps execution by bar volume; +- `queue_ahead_qty` removes available quantity before the strategy gets filled; +- `allow_partial_fills=False` rejects orders that cannot be fully filled. + Not yet in the Nautilus adapter: -- full dynamic DCA ladder state management inside Nautilus; -- exchange-native contingent order-list semantics beyond current package - strategy cancellation; preflight can audit OCO assumptions, but Phase 5.4B - still needs deeper Nautilus strategy integration; -- endpoint-wired all-or-none basket package semantics; preflight can already - reject all legs deterministically before submission; -- portfolio-margin replication beyond diagnostics. +- full dynamic DCA ladder state management inside Nautilus is still future + work; current package routes are preflight-mediated; +- exchange-native contingent order-list semantics are still represented by + deterministic package cancellation unless Nautilus exposes stable native + order-list routes for the target venue; +- real L2 replay requires external venue depth data and a provider adapter; +- portfolio-margin replication beyond diagnostics remains venue-specific + future work. DCA/grid, OCO/bracket, basket, and portfolio Nautilus routes are experimental validation paths, not the fast research path. Broad research and optimization diff --git a/reporting/__init__.py b/reporting/__init__.py index e73ef90..a82ed98 100644 --- a/reporting/__init__.py +++ b/reporting/__init__.py @@ -2,6 +2,11 @@ from .arbitrage_audit import build_arbitrage_domain_audit, compare_native_arbitrage_results from .nautilus_bundle import export_nautilus_report_bundle +from .nautilus_certification import ( + NautilusToleranceProfile, + build_nautilus_certification_profile, + write_nautilus_certification_artifacts, +) from .nautilus_diagnostics import build_nautilus_pct_equity_diagnostic from .parity import ( build_native_nautilus_parity_report, @@ -20,11 +25,14 @@ "build_native_nautilus_parity_report", "build_nautilus_depth_execution_report", "build_nautilus_depth_parity_summary", + "build_nautilus_certification_profile", "build_nautilus_pct_equity_diagnostic", "build_portfolio_domain_audit", "build_portfolio_nautilus_position_report", "build_portfolio_nautilus_validation_report", "compare_native_arbitrage_results", "export_nautilus_report_bundle", + "NautilusToleranceProfile", "summarize_native_nautilus_parity_report", + "write_nautilus_certification_artifacts", ] diff --git a/reporting/nautilus_certification.py b/reporting/nautilus_certification.py new file mode 100644 index 0000000..02f6a2b --- /dev/null +++ b/reporting/nautilus_certification.py @@ -0,0 +1,226 @@ +"""Nautilus certification artifact helpers.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import json +from pathlib import Path +from typing import Any, Dict, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.results import BacktestResultV2 +from .parity import build_native_nautilus_parity_report, summarize_native_nautilus_parity_report + + +@dataclass(frozen=True) +class NautilusToleranceProfile: + """Tolerance contract for native-vs-Nautilus certification artifacts.""" + + fill_price_tolerance: float = 1e-9 + fee_tolerance: float = 1e-9 + position_tolerance: float = 1e-9 + equity_tolerance: float = 1e-6 + quantity_tolerance: float = 1e-9 + slippage_tolerance: float = 1e-9 + + +def build_nautilus_certification_profile( + native_result: Optional[BacktestResultV2], + nautilus_result: BacktestResultV2, + *, + tolerance: NautilusToleranceProfile | Dict[str, float] | None = None, + workflow: str = "nautilus", +) -> Dict[str, Any]: + """ + Build a compact tolerance profile for a native-vs-Nautilus run. + + The profile is deliberately report-layer only. It never changes engine + accounting and is suitable for saved stakeholder bundles. + """ + tol = _coerce_tolerance(tolerance) + if native_result is None: + return { + "workflow": workflow, + "status": "reference_missing", + "passed": False, + "reason": "native reference result is required for tolerance certification", + "tolerance": asdict(tol), + "checks": {}, + } + + parity = build_native_nautilus_parity_report(native_result, nautilus_result) + summary = summarize_native_nautilus_parity_report( + parity, + fill_price_tolerance=tol.fill_price_tolerance, + fee_tolerance=tol.fee_tolerance, + position_tolerance=tol.position_tolerance, + equity_tolerance=tol.equity_tolerance, + ) + quantity_diff = _max_abs_quantity_diff(native_result, nautilus_result) + final_equity_diff = _final_equity_diff(native_result, nautilus_result) + max_equity_diff = max(float(summary.get("max_abs_equity_diff", 0.0)), final_equity_diff) + slippage_diff = float(summary.get("max_abs_fill_price_diff", 0.0)) + checks = { + "fill_price_within_tolerance": float(summary["max_abs_fill_price_diff"]) <= tol.fill_price_tolerance, + "fee_within_tolerance": float(summary["max_abs_fee_diff"]) <= tol.fee_tolerance, + "position_within_tolerance": float(summary["max_abs_position_diff"]) <= tol.position_tolerance, + "equity_within_tolerance": max_equity_diff <= tol.equity_tolerance, + "quantity_within_tolerance": quantity_diff <= tol.quantity_tolerance, + "slippage_within_tolerance": slippage_diff <= tol.slippage_tolerance, + } + passed = bool(all(checks.values())) + status = "pass" if passed else _profile_status(checks) + return { + "workflow": workflow, + "status": status, + "passed": passed, + "tolerance": asdict(tol), + "checks": checks, + "summary": summary, + "max_abs_quantity_diff": float(quantity_diff), + "max_abs_final_equity_diff": float(final_equity_diff), + "max_abs_equity_diff_including_final": float(max_equity_diff), + "max_abs_slippage_proxy_diff": float(slippage_diff), + "rows": int(len(parity)), + } + + +def write_nautilus_certification_artifacts( + *, + native_result: Optional[BacktestResultV2], + nautilus_result: BacktestResultV2, + report_dir: str | Path, + workflow: str, + tolerance: NautilusToleranceProfile | Dict[str, float] | None = None, + known_differences: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + """ + Write parity, tolerance, known-difference, and summary files into a bundle. + """ + path = Path(report_dir) + path.mkdir(parents=True, exist_ok=True) + tol = _coerce_tolerance(tolerance) + parity = ( + build_native_nautilus_parity_report(native_result, nautilus_result) + if native_result is not None + else pd.DataFrame() + ) + profile = build_nautilus_certification_profile( + native_result=native_result, + nautilus_result=nautilus_result, + tolerance=tol, + workflow=workflow, + ) + differences = list(known_differences or ()) + parity_path = path / "native_vs_nautilus_parity.csv" + profile_path = path / "tolerance_profile.json" + known_path = path / "known_differences.md" + summary_path = path / "certification_summary.json" + + parity.to_csv(parity_path, index=False) + _write_json(profile_path, profile) + known_path.write_text(_known_differences_markdown(workflow, differences), encoding="utf-8") + summary = { + "workflow": workflow, + "status": profile["status"], + "passed": profile["passed"], + "report_dir": str(path), + "files": { + "native_vs_nautilus_parity": parity_path.name, + "tolerance_profile": profile_path.name, + "known_differences": known_path.name, + }, + "known_differences_count": len(differences), + } + _write_json(summary_path, summary) + return { + **summary, + "tolerance_profile": profile, + "artifact_files": [parity_path.name, profile_path.name, known_path.name, summary_path.name], + } + + +def _coerce_tolerance(value: NautilusToleranceProfile | Dict[str, float] | None) -> NautilusToleranceProfile: + if value is None: + return NautilusToleranceProfile() + if isinstance(value, NautilusToleranceProfile): + return value + return NautilusToleranceProfile(**{key: float(val) for key, val in dict(value).items()}) + + +def _profile_status(checks: Dict[str, bool]) -> str: + if not checks.get("fill_price_within_tolerance", True) or not checks.get("quantity_within_tolerance", True): + return "execution_diff" + if not checks.get("position_within_tolerance", True): + return "position_diff" + if not checks.get("fee_within_tolerance", True) or not checks.get("equity_within_tolerance", True): + return "accounting_diff" + return "diff" + + +def _max_abs_quantity_diff(native_result: BacktestResultV2, nautilus_result: BacktestResultV2) -> float: + native_qty = _fill_quantities(native_result) + nautilus_qty = _fill_quantities(nautilus_result) + n = max(len(native_qty), len(nautilus_qty)) + if n == 0: + return 0.0 + left = np.zeros(n, dtype=float) + right = np.zeros(n, dtype=float) + left[: len(native_qty)] = native_qty + right[: len(nautilus_qty)] = nautilus_qty + return float(np.max(np.abs(left - right))) + + +def _final_equity_diff(native_result: BacktestResultV2, nautilus_result: BacktestResultV2) -> float: + if len(native_result.equity) == 0 or len(nautilus_result.equity) == 0: + return 0.0 + return float(abs(float(native_result.equity.iloc[-1]) - float(nautilus_result.equity.iloc[-1]))) + + +def _fill_quantities(result: BacktestResultV2) -> np.ndarray: + report = _frame((result.metadata or {}).get("fills_report")) + if report.empty: + report = _frame((result.metadata or {}).get("orders_report")) + if not report.empty: + if "status" in report: + report = report[report["status"].astype(str).str.upper().eq("FILLED")] + for col in ("filled_qty", "quantity", "qty"): + if col in report: + return pd.to_numeric(report[col], errors="coerce").fillna(0.0).to_numpy(dtype=float) + fills = getattr(result, "fills", ()) + if fills: + return np.asarray([float(getattr(fill, "qty", 0.0)) for fill in fills], dtype=float) + return np.asarray([], dtype=float) + + +def _frame(value: Any) -> pd.DataFrame: + if isinstance(value, pd.DataFrame): + return value.copy() + if value is None: + return pd.DataFrame() + try: + return pd.DataFrame(value).copy() + except Exception: + return pd.DataFrame() + + +def _known_differences_markdown(workflow: str, differences: Sequence[str]) -> str: + lines = [f"# Known Differences - {workflow}", ""] + if differences: + for item in differences: + lines.append(f"- {item}") + else: + lines.append("- None recorded for this certification run.") + lines.extend( + [ + "", + "These notes describe known adapter or venue-model differences. They do not override tolerance failures.", + ] + ) + return "\n".join(lines) + "\n" + + +def _write_json(path: Path, payload: Dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8") diff --git a/tests/test_phase15a_nautilus_certification.py b/tests/test_phase15a_nautilus_certification.py new file mode 100644 index 0000000..eb5a6c5 --- /dev/null +++ b/tests/test_phase15a_nautilus_certification.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json + +import pandas as pd + +from quantbt import ( + BacktestResultV2, + Fill, + NautilusToleranceProfile, + OrderIntent, + OrderSide, + OrderType, + TimeInForce, + build_nautilus_certification_profile, + write_nautilus_certification_artifacts, +) +from quantbt.benchmarks.run_phase15a_nautilus_certification import make_markdown, run_certification + + +def test_phase15a_runner_skips_cleanly_without_optional_nautilus(tmp_path): + report = run_certification(include_nautilus=False, output_dir=tmp_path, rows=24) + + assert report["status"] == "pass" + assert report["passed_workflows"] == 0 + assert report["skipped_workflows"] == 5 + assert report["failed_workflows"] == 0 + assert {item["workflow"] for item in report["workflows"]} == { + "pct_equity_signal", + "explicit_orders", + "basket_package", + "portfolio_package", + "basis_arbitrage_package", + } + assert all(item["status"] == "skipped" for item in report["workflows"]) + + markdown = make_markdown(report) + assert "Phase 15A Nautilus Certification Bundles" in markdown + assert "A skipped workflow is not a pass claim" in markdown + + +def test_phase15a_tolerance_profile_detects_injected_execution_and_accounting_mismatch(tmp_path): + native = _synthetic_result(fill_price=100.0, qty=1.0, fee=0.10, final_equity=10_050.0) + nautilus = _synthetic_result(fill_price=101.0, qty=1.25, fee=0.25, final_equity=10_020.0) + + profile = build_nautilus_certification_profile( + native, + nautilus, + workflow="mismatch_smoke", + tolerance=NautilusToleranceProfile( + fill_price_tolerance=0.01, + fee_tolerance=0.01, + position_tolerance=0.01, + equity_tolerance=1.0, + quantity_tolerance=0.01, + slippage_tolerance=0.01, + ), + ) + + assert profile["passed"] is False + assert profile["status"] == "execution_diff" + assert profile["checks"]["fill_price_within_tolerance"] is False + assert profile["checks"]["fee_within_tolerance"] is False + assert profile["checks"]["equity_within_tolerance"] is False + assert profile["checks"]["quantity_within_tolerance"] is False + + artifacts = write_nautilus_certification_artifacts( + native_result=native, + nautilus_result=nautilus, + report_dir=tmp_path, + workflow="mismatch_smoke", + tolerance=NautilusToleranceProfile(fill_price_tolerance=0.01, quantity_tolerance=0.01), + known_differences=["Injected mismatch for test coverage."], + ) + + assert artifacts["status"] == "execution_diff" + assert (tmp_path / "native_vs_nautilus_parity.csv").exists() + assert (tmp_path / "tolerance_profile.json").exists() + assert (tmp_path / "known_differences.md").exists() + assert (tmp_path / "certification_summary.json").exists() + payload = json.loads((tmp_path / "tolerance_profile.json").read_text(encoding="utf-8")) + assert payload["workflow"] == "mismatch_smoke" + assert payload["passed"] is False + + +def test_phase15a_tolerance_profile_passes_identical_synthetic_results(tmp_path): + native = _synthetic_result(fill_price=100.0, qty=1.0, fee=0.10, final_equity=10_050.0) + nautilus = _synthetic_result(fill_price=100.0, qty=1.0, fee=0.10, final_equity=10_050.0) + + artifacts = write_nautilus_certification_artifacts( + native_result=native, + nautilus_result=nautilus, + report_dir=tmp_path, + workflow="identical_smoke", + ) + + assert artifacts["status"] == "pass" + assert artifacts["passed"] is True + profile = json.loads((tmp_path / "tolerance_profile.json").read_text(encoding="utf-8")) + assert all(profile["checks"].values()) + + +def _synthetic_result(fill_price: float, qty: float, fee: float, final_equity: float) -> BacktestResultV2: + idx = pd.date_range("2024-01-01", periods=3, freq="1h", tz="UTC") + equity = pd.Series([10_000.0, 10_000.0, final_equity], index=idx) + positions = pd.DataFrame({"Position_BTCUSDT-PERP.BINANCE": [0.0, qty, qty]}, index=idx) + closes = pd.DataFrame({"Close_BTCUSDT-PERP.BINANCE": [99.0, fill_price, fill_price + 1.0]}, index=idx) + order = OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=qty, + tif=TimeInForce.IOC, + ) + fill = Fill( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + qty=qty, + price=fill_price, + fee=fee, + ) + fills_report = pd.DataFrame( + { + "instrument_id": ["BTCUSDT-PERP.BINANCE"], + "side": ["BUY"], + "filled_qty": [qty], + "avg_px": [fill_price], + "commissions": [fee], + "ts_last": [idx[1]], + "status": ["FILLED"], + } + ) + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=positions, + closes=closes, + symbols=["BTCUSDT-PERP.BINANCE"], + initial_capital=10_000.0, + leverage=2.0, + orders=(order,), + fills=(fill,), + metadata={ + "backend": "nautilus", + "orders_report": fills_report.copy(), + "fills_report": fills_report.copy(), + "orders_count": 1, + "fills_count": 1, + }, + ) diff --git a/tests/test_phase15b_synthetic_depth.py b/tests/test_phase15b_synthetic_depth.py new file mode 100644 index 0000000..fa5b6a5 --- /dev/null +++ b/tests/test_phase15b_synthetic_depth.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import ( + NautilusExecutionDepthConfig, + OrderIntent, + OrderSide, + OrderType, + l2_replay_available, + simulate_nautilus_order_package_depth, +) + + +def _idx(): + return pd.date_range("2024-01-01", periods=3, freq="1h", tz="UTC") + + +def _frame(): + idx = _idx() + return pd.DataFrame( + { + "open": [100.0, 100.0, 100.0], + "high": [101.0, 101.0, 101.0], + "low": [99.0, 99.0, 99.0], + "close": [100.0, 100.0, 100.0], + "volume": [100.0, 100.0, 100.0], + }, + index=idx, + ) + + +def _order(qty: float, side: OrderSide = OrderSide.BUY, order_type: OrderType = OrderType.MARKET, **kwargs): + return OrderIntent( + timestamp=_idx()[1], + symbol="BTCUSDT-PERP.BINANCE", + side=side, + order_type=order_type, + qty=qty, + **kwargs, + ) + + +def test_phase15b_synthetic_market_order_consumes_book_levels_with_vwap(): + order = _order(2.0) + result = simulate_nautilus_order_package_depth( + [order], + {"BTCUSDT-PERP.BINANCE": _frame()}, + NautilusExecutionDepthConfig( + depth_model="synthetic_book", + synthetic_spread_bps=10.0, + synthetic_level_spacing_bps=10.0, + synthetic_levels=3, + synthetic_base_depth_qty=1.0, + allow_partial_fills=True, + ), + ) + + row = result.order_report.iloc[0] + assert row["status"] == "filled" + assert row["levels_consumed"] == 2 + assert row["filled_qty"] == 2.0 + assert row["fill_price"] == pytest.approx((100.05 + 100.15) / 2.0) + assert row["depth_model"] == "synthetic_book" + + +def test_phase15b_synthetic_partial_fill_respects_book_depth_and_queue(): + order = _order(3.0) + result = simulate_nautilus_order_package_depth( + [order], + {"BTCUSDT-PERP.BINANCE": _frame()}, + NautilusExecutionDepthConfig( + depth_model="synthetic_book", + synthetic_levels=2, + synthetic_base_depth_qty=1.0, + queue_ahead_qty=0.5, + allow_partial_fills=True, + ), + ) + + row = result.order_report.iloc[0] + assert row["status"] == "partial" + assert row["available_qty"] == pytest.approx(1.5) + assert result.orders[0].qty == pytest.approx(1.5) + assert result.orders[0].metadata["depth_model"] == "synthetic_book" + + +def test_phase15b_synthetic_all_or_none_rejects_when_partial_not_allowed(): + order = _order(3.0) + result = simulate_nautilus_order_package_depth( + [order], + {"BTCUSDT-PERP.BINANCE": _frame()}, + NautilusExecutionDepthConfig( + depth_model="synthetic_book", + synthetic_levels=2, + synthetic_base_depth_qty=1.0, + allow_partial_fills=False, + ), + ) + + assert len(result.orders) == 0 + assert result.order_report.iloc[0]["status"] == "rejected" + assert result.order_report.iloc[0]["reject_reason"] == "insufficient_queue_capacity" + + +def test_phase15b_synthetic_limit_order_respects_high_low_touch_and_limit_price(): + untouched = _order(1.0, order_type=OrderType.LIMIT, price=98.0) + touched = _order(1.0, order_type=OrderType.LIMIT, price=100.2) + cfg = NautilusExecutionDepthConfig( + depth_model="synthetic_book", + synthetic_spread_bps=10.0, + synthetic_level_spacing_bps=10.0, + synthetic_levels=3, + synthetic_base_depth_qty=1.0, + ) + + miss = simulate_nautilus_order_package_depth([untouched], {"BTCUSDT-PERP.BINANCE": _frame()}, cfg) + hit = simulate_nautilus_order_package_depth([touched], {"BTCUSDT-PERP.BINANCE": _frame()}, cfg) + + assert miss.order_report.iloc[0]["reject_reason"] == "limit_not_touched" + assert hit.order_report.iloc[0]["status"] == "filled" + assert hit.order_report.iloc[0]["fill_price"] <= 100.2 + + +def test_phase15b_l2_replay_is_explicitly_provider_gated(): + if not l2_replay_available(): + pytest.skip("L2 replay requires a real provider with snapshots, updates and trades") + raise AssertionError("unexpected test provider available") + + +def test_phase15b_l2_replay_model_refuses_without_provider(): + with pytest.raises(NotImplementedError, match="real venue L2"): + simulate_nautilus_order_package_depth( + [_order(1.0)], + {"BTCUSDT-PERP.BINANCE": _frame()}, + NautilusExecutionDepthConfig(depth_model="l2_replay"), + ) diff --git a/tests/test_phase5_4_endpoint_depth.py b/tests/test_phase5_4_endpoint_depth.py index 5480fce..938e29e 100644 --- a/tests/test_phase5_4_endpoint_depth.py +++ b/tests/test_phase5_4_endpoint_depth.py @@ -167,3 +167,41 @@ def test_phase5_4_endpoint_depth_annotates_basket_package_reports(monkeypatch): assert result.metadata["order_count_after_depth"] == 4 assert result.metadata["nautilus_depth_package_report"]["status"].tolist() == ["accepted", "accepted"] assert all(order.metadata["package_type"] == "basket_package" for order in captured["orders"]) + + +def test_phase15b_endpoint_depth_accepts_synthetic_book_model(monkeypatch): + captured = _install_fake_nautilus(monkeypatch) + df = _df() + idx = df.index + + endpoint = QuantBTEndpoint.nautilus_bracket_orders( + spec=BracketOrderSpec( + symbol="ETHUSDT-PERP.BINANCE", + entry_timestamp=idx[1], + exit_timestamp=idx[3], + side=OrderSide.BUY, + qty=1.5, + take_profit_price=110.0, + stop_loss_price=95.0, + ), + initial_capital=10_000.0, + use_funding=False, + nautilus_depth_config=NautilusExecutionDepthConfig( + depth_model="synthetic_book", + allow_partial_fills=True, + synthetic_base_depth_qty=1.0, + synthetic_levels=2, + ), + ) + + result = endpoint.simulate(data=df) + + assert captured["runs"] == 1 + assert result.metadata["nautilus_depth_enabled"] is True + assert result.metadata["nautilus_depth_metadata"]["depth_model"] == "synthetic_book" + assert result.metadata["nautilus_depth_order_report"]["depth_model"].tolist() == [ + "synthetic_book", + "synthetic_book", + "synthetic_book", + ] + assert result.metadata["nautilus_depth_order_report"].iloc[0]["levels_consumed"] >= 1 diff --git a/upgrade/implement.md b/upgrade/implement.md index 7e2f147..891f291 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -2636,8 +2636,63 @@ Acceptance: - Injected mismatch tests prove parity reports catch fill-price/equity/quantity differences. +Status: + +- Implemented `reporting/nautilus_certification.py`: + - `NautilusToleranceProfile`; + - `build_nautilus_certification_profile(...)`; + - `write_nautilus_certification_artifacts(...)`. +- Added public exports through `quantbt.reporting` and top-level `quantbt`. +- Implemented `benchmarks/run_phase15a_nautilus_certification.py`. +- Supported certification workflow matrix: + - `pct_equity_signal`; + - `explicit_orders`; + - `basket_package`; + - `portfolio_package`; + - `basis_arbitrage_package`. +- Successful workflows export the normal Nautilus bundle plus: + - `native_vs_nautilus_parity.csv`; + - `tolerance_profile.json`; + - `known_differences.md`; + - `certification_summary.json`. +- Default runner behavior is dependency-safe: + - without `--include-nautilus`, all workflows are marked `skipped`; + - with `--include-nautilus`, missing `nautilus-trader` or unsupported + instrument routes skip cleanly instead of pretending to pass. +- Added `tests/test_phase15a_nautilus_certification.py`: + - clean skip behavior; + - markdown readability; + - identical synthetic results pass tolerance; + - injected fill-price, fee, equity, and quantity mismatches fail tolerance. +- Updated documentation: + - `docs/nautilus_backend.md`; + - `README.md`. + +Latest local artifact: + +- `benchmarks/phase15a_nautilus_certification.json`; +- `benchmarks/phase15a_nautilus_certification.md`. + +Current local run status without optional Nautilus execution: + +- status: `pass`; +- workflows skipped: `5`; +- failed workflows: `0`. + +Safety notes: + +- Phase 15A is an evidence/reporting layer. It does not change native or + Nautilus execution, sizing, fee, margin, liquidation, or funding logic. +- A skipped workflow is not a pass claim. It only records that optional + dependency/instrument execution was not requested or available. + ### Phase 15B - Nautilus Depth, Synthetic Book, And Specialized Arbitrage Plan +Status: completed for Level-2 synthetic depth stress and documentation +guardrails. Real venue L2 replay, exchange-native OCO lists, in-strategy +dynamic DCA state machines, and specialized cross-exchange / triangular / +options-vol engines remain future production work. + Purpose: Define and implement the next depth layer carefully, without claiming exchange @@ -2668,9 +2723,13 @@ Nautilus depth scope: - map to exchange-native order-list semantics if Nautilus route is stable; - otherwise keep package-strategy cancellation and document the difference. - Depth model abstraction: - - `OHLCVDepthModel`; - - `SyntheticBookDepthModel`; - - `L2ReplayDepthModel` future adapter. + - `OHLCVDepthModel` maps to `depth_model="ohlcv_volume_cap"` and remains the + default; + - `SyntheticBookDepthModel` maps to `depth_model="synthetic_book"` and now + supports deterministic spread, level spacing, level depth, depth slope, + participation cap, queue-ahead, partial-fill, and limit-price filtering; + - `L2ReplayDepthModel` maps to `depth_model="l2_replay"` and intentionally + refuses to run without real venue depth provider data. Specialized arbitrage scope: @@ -2692,11 +2751,28 @@ Specialized arbitrage scope: Acceptance: - Synthetic-book tests prove depth model invariants without requiring private - venue data. -- Real L2 tests skip clearly when no L2 provider is configured. + venue data: `tests/test_phase15b_synthetic_depth.py`. +- Endpoint package routing accepts the synthetic depth config: + `tests/test_phase5_4_endpoint_depth.py::test_phase15b_endpoint_depth_accepts_synthetic_book_model`. +- Real L2 tests skip clearly when no L2 provider is configured via + `l2_replay_available(...)`. - Schema-only arbitrage specs remain rejected until their specialized engine has accounting audit, parity tests, and docs. +Latest local artifacts: + +- `benchmarks/phase15b_synthetic_depth.json`; +- `benchmarks/phase15b_synthetic_depth.md`; +- `benchmarks/run_phase15b_synthetic_depth.py`. + +Safety notes: + +- Phase 15B does not change default endpoint behavior because + `depth_model="ohlcv_volume_cap"` remains the default. +- Synthetic depth is an execution stress model, not an exchange L2 replay. +- `l2_replay` raises explicitly until venue snapshots, incremental updates, + trade prints, and timestamp/latency assumptions are provided. + --- ## Backend Selection Guide