diff --git a/backends/native_event.py b/backends/native_event.py index feebb12..71638d3 100644 --- a/backends/native_event.py +++ b/backends/native_event.py @@ -416,6 +416,7 @@ def run_basket( slot_size: Optional[Union[float, Dict[str, float]]] = None, min_qty: Optional[Union[float, Dict[str, float]]] = None, min_notional: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, ) -> BacktestResultV2: """ Build frozen basket orders from a scalar signal and execute them. @@ -445,6 +446,7 @@ def run_basket( leverage=leverage, fee_rate=fee_rate, symbols=symbols, + market_arrays=market_arrays, instruments=instruments, qty_step=qty_step, lot_size=lot_size, @@ -469,6 +471,7 @@ def run_stat_arb_pair_arbitrage( funding_rate: Union[float, pd.Series, Dict] = 0.0, contract_size: Optional[Union[float, Dict[str, float]]] = None, leverage: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, ) -> BacktestResultV2: """ Execute a Phase D stat-arb pair through the frozen basket planner. @@ -529,6 +532,7 @@ def run_stat_arb_pair_arbitrage( leverage=leverage, fee_rate=fee_rates, symbols=symbols, + market_arrays=market_arrays, ) funding_dict = prepare_funding(stat_funding if self.config.use_funding else 0.0, symbols, idx) roles = self._stat_arb_roles(spec) @@ -586,6 +590,7 @@ def run_basis_arbitrage( contract_size: Optional[Union[float, Dict[str, float]]] = None, leverage: Optional[Union[float, Dict[str, float]]] = None, hedge_ratios: Optional[Dict[str, pd.Series]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, ) -> BacktestResultV2: """ Execute a minimal native-event USDM linear basis arbitrage backtest. @@ -624,6 +629,7 @@ def run_basis_arbitrage( leverage=leverage, fee_rate=fee_rates, symbols=symbols, + market_arrays=market_arrays, ) funding_dict = prepare_funding(basis_funding if self.config.use_funding else 0.0, symbols, idx) @@ -680,6 +686,7 @@ def run_package_arbitrage( contract_size: Optional[Union[float, Dict[str, float]]] = None, leverage: Optional[Union[float, Dict[str, float]]] = None, hedge_ratios: Optional[Dict[str, pd.Series]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, ) -> BacktestResultV2: """ Execute Phase G package-style advanced arbitrage specs. @@ -725,6 +732,7 @@ def run_package_arbitrage( leverage=leverage, fee_rate=fee_rates, symbols=symbols, + market_arrays=market_arrays, ) funding_dict = prepare_funding(package_funding if self.config.use_funding else 0.0, symbols, idx) diff --git a/backends/native_portfolio.py b/backends/native_portfolio.py index 0a61a9e..83a4605 100644 --- a/backends/native_portfolio.py +++ b/backends/native_portfolio.py @@ -48,10 +48,12 @@ class NativePortfolioConfig: execution: ExecutionConfig = field(default_factory=ExecutionConfig) fee_rate: float = 0.0 use_funding: bool = True + report_level: str = "full" def __post_init__(self) -> None: if float(self.fee_rate) < 0.0: raise ValueError("fee_rate must be >= 0") + object.__setattr__(self, "report_level", _normalize_report_level(self.report_level)) class NativePortfolioBackend: @@ -94,6 +96,7 @@ def run_signals( slot_size: Optional[Union[float, Dict[str, float]]] = None, min_qty: Optional[Union[float, Dict[str, float]]] = None, min_notional: Optional[Union[float, Dict[str, float]]] = None, + report_level: Optional[str] = None, ) -> BacktestResultV2: idx = validate_datetime(datetime_index) if positions is None and raw_signal_matrix is None: @@ -260,9 +263,18 @@ def run_signals( liquidated=bool(liq_flag), liquidation_bar=int(liq_idx), quantity_constraints=constraints.as_dict(), + report_level=self.config.report_level if report_level is None else report_level, ) spec = PortfolioDomainSpec(mode=portfolio_mode, sizing_mode=sizing_mode) - result.metadata["portfolio_contract_report"] = validate_portfolio_result_contract(result, spec, tolerance=1e-8) + if result.metadata.get("report_level") == "minimal": + result.metadata["portfolio_contract_report"] = { + "status": "skipped", + "passed": None, + "reason": "report_level='minimal' omits heavy audit reports; rerun with report_level='full' for contract validation", + "spec": {"mode": portfolio_mode, "sizing_mode": sizing_mode}, + } + else: + result.metadata["portfolio_contract_report"] = validate_portfolio_result_contract(result, spec, tolerance=1e-8) return result def prepare_market_arrays( @@ -445,7 +457,9 @@ def _build_result( liquidated: bool, liquidation_bar: int, quantity_constraints: Dict[str, Dict[str, float]], + report_level: str, ) -> BacktestResultV2: + level = _normalize_report_level(report_level) equity = pd.Series(equity_arr, index=idx, name="equity") close_report = pd.DataFrame(closes_m, index=idx, columns=symbol_list, copy=False) target_units_report = pd.DataFrame(target_m, index=idx, columns=symbol_list, copy=False) @@ -458,38 +472,6 @@ def _build_result( accepted_notional_arr = pos_arr * closes_m * cs_row target_notional = pd.DataFrame(target_notional_arr, index=idx, columns=symbol_list, copy=False) accepted_notional = pd.DataFrame(accepted_notional_arr, index=idx, columns=symbol_list, copy=False) - funding_rates = pd.DataFrame(funding_m, index=idx, columns=symbol_list, copy=False) - risk_vol_report = pd.DataFrame(risk_vol, index=idx, columns=symbol_list, copy=False) - - exposure_report = self._build_exposure_report( - accepted_notional_arr=accepted_notional_arr, - target_notional_arr=target_notional_arr, - equity_arr=equity_arr, - idx=idx, - leverages=leverages, - maintenance_ratio=maintenance_ratio, - betas=betas, - ) - risk_contribution_report = pd.DataFrame(np.abs(accepted_notional_arr) * risk_vol, index=idx, columns=symbol_list, copy=False) - exposure_report.attrs["risk_contribution_report"] = risk_contribution_report - symbol_pnl_report = self._build_symbol_pnl_report( - idx=idx, - symbols=symbol_list, - accepted_units_arr=pos_arr, - closes_arr=closes_m, - funding_rates_arr=funding_m, - is_funding_bar=is_funding_bar, - contract_sizes=contract_sizes, - fee_arr=fee_arr, - ) - rebalance_report = self._build_rebalance_report( - idx=idx, - symbols=symbol_list, - target_units_arr=target_m, - accepted_units_arr=pos_arr, - closes_arr=closes_m, - contract_sizes=contract_sizes, - ) positions = pd.DataFrame(pos_arr, index=idx, columns=[f"Position_{s}" for s in symbol_list], copy=False) closes = pd.DataFrame(closes_m, index=idx, columns=[f"Close_{s}" for s in symbol_list], copy=False) @@ -498,7 +480,14 @@ def _build_result( prev_units = np.vstack([np.zeros((1, len(symbol_list)), dtype=np.float64), pos_arr[:-1]]) funding_cost_arr = prev_units * closes_m * cs_row * funding_m funding_cost_arr = np.where(is_funding_bar.reshape(-1, 1).astype(bool), funding_cost_arr, 0.0).sum(axis=1) - margin = exposure_report[["initial_margin", "maintenance_margin"]].copy() + abs_accepted = np.abs(accepted_notional_arr) + margin = pd.DataFrame( + { + "initial_margin": (abs_accepted / leverages.reshape(1, -1)).sum(axis=1), + "maintenance_margin": abs_accepted.sum(axis=1) * float(maintenance_ratio), + }, + index=idx, + ) diagnostics = pd.DataFrame( { "turnover": turnover_arr, @@ -515,6 +504,95 @@ def _build_result( where=equity_arr[:-1] != 0.0, ) + metadata = { + "backend": "native_portfolio", + "mode": mode, + "asset_type": asset_type, + "hedge_type": hedge_type, + "engine": "native_portfolio_v1", + "report_level": level, + "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), + "funding_rate_unit": "per_event", + "target_units_report": target_units_report, + "accepted_units_report": accepted_units_report, + "beta": {s: float(betas[j]) for j, s in enumerate(symbol_list)}, + "fee_series": fees, + "turnover_series": turnover, + "fee_total": float(np.sum(fee_arr)), + "turnover_total": float(np.sum(turnover_arr)), + "fee_rate_oneway": float(self.config.fee_rate), + "contract_size": {s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)}, + "quantity_constraints": quantity_constraints, + } + omitted = [] + if level in {"full", "standard"}: + funding_rates = pd.DataFrame(funding_m, index=idx, columns=symbol_list, copy=False) + exposure_report = self._build_exposure_report( + accepted_notional_arr=accepted_notional_arr, + target_notional_arr=target_notional_arr, + equity_arr=equity_arr, + idx=idx, + leverages=leverages, + maintenance_ratio=maintenance_ratio, + betas=betas, + ) + symbol_pnl_report = self._build_symbol_pnl_report( + idx=idx, + symbols=symbol_list, + accepted_units_arr=pos_arr, + closes_arr=closes_m, + funding_rates_arr=funding_m, + is_funding_bar=is_funding_bar, + contract_sizes=contract_sizes, + fee_arr=fee_arr, + ) + metadata.update( + { + "target_notional_report": target_notional, + "accepted_notional_report": accepted_notional, + "exposure_report": exposure_report, + "funding_rates_report": funding_rates, + "symbol_pnl_report": symbol_pnl_report, + } + ) + if level == "full": + risk_vol_report = pd.DataFrame(risk_vol, index=idx, columns=symbol_list, copy=False) + risk_contribution_report = pd.DataFrame(np.abs(accepted_notional_arr) * risk_vol, index=idx, columns=symbol_list, copy=False) + exposure_report.attrs["risk_contribution_report"] = risk_contribution_report + rebalance_report = self._build_rebalance_report( + idx=idx, + symbols=symbol_list, + target_units_arr=target_m, + accepted_units_arr=pos_arr, + closes_arr=closes_m, + contract_sizes=contract_sizes, + ) + metadata.update( + { + "risk_volatility_report": risk_vol_report, + "risk_contribution_report": risk_contribution_report, + "kernel_symbol_pnl": pd.DataFrame(sym_pnl_arr, index=idx, columns=symbol_list, copy=False), + "rebalance_report": rebalance_report, + } + ) + else: + omitted.extend(["risk_volatility_report", "risk_contribution_report", "kernel_symbol_pnl", "rebalance_report"]) + else: + omitted.extend( + [ + "target_notional_report", + "accepted_notional_report", + "exposure_report", + "funding_rates_report", + "risk_volatility_report", + "risk_contribution_report", + "symbol_pnl_report", + "kernel_symbol_pnl", + "rebalance_report", + ] + ) + metadata["reports_omitted"] = tuple(omitted) + return BacktestResultV2( equity=equity, returns=pd.Series(returns_arr, index=idx, name="returns"), @@ -529,33 +607,7 @@ def _build_result( funding=pd.Series(funding_cost_arr, index=idx, name="funding"), margin=margin, diagnostics=diagnostics, - metadata={ - "backend": "native_portfolio", - "mode": mode, - "asset_type": asset_type, - "hedge_type": hedge_type, - "engine": "native_portfolio_v1", - "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), - "funding_rate_unit": "per_event", - "target_units_report": target_units_report, - "accepted_units_report": accepted_units_report, - "target_notional_report": target_notional, - "accepted_notional_report": accepted_notional, - "exposure_report": exposure_report, - "risk_volatility_report": risk_vol_report, - "risk_contribution_report": risk_contribution_report, - "beta": {s: float(betas[j]) for j, s in enumerate(symbol_list)}, - "symbol_pnl_report": symbol_pnl_report, - "kernel_symbol_pnl": pd.DataFrame(sym_pnl_arr, index=idx, columns=symbol_list, copy=False), - "rebalance_report": rebalance_report, - "fee_series": fees, - "turnover_series": turnover, - "fee_total": float(np.sum(fee_arr)), - "turnover_total": float(np.sum(turnover_arr)), - "fee_rate_oneway": float(self.config.fee_rate), - "contract_size": {s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)}, - "quantity_constraints": quantity_constraints, - }, + metadata=metadata, ) @staticmethod @@ -713,3 +765,20 @@ def _portfolio_mode_id(mode: str) -> int: "beta_neutral": 5, } return mapping[mode] + + +def _normalize_report_level(report_level: str) -> str: + level = str(report_level or "full").lower().strip() + aliases = { + "audit": "full", + "complete": "full", + "default": "full", + "lite": "standard", + "light": "minimal", + "optimizer": "minimal", + "scoring": "minimal", + } + level = aliases.get(level, level) + if level not in {"full", "standard", "minimal"}: + raise ValueError("report_level must be one of 'full', 'standard', or 'minimal'") + return level diff --git a/backends/native_vectorized.py b/backends/native_vectorized.py index 75ba334..b2968b7 100644 --- a/backends/native_vectorized.py +++ b/backends/native_vectorized.py @@ -12,7 +12,16 @@ import numpy as np import pandas as pd -from ..core.preprocessor import align_series, build_arrays, prepare_funding, validate_datetime +from ..core.preprocessor import ( + PreparedMarketArrays, + align_series, + build_arrays, + build_market_arrays, + build_signal_matrix, + market_data_signature, + prepare_funding, + validate_datetime, +) from ..core.constraints import build_quantity_constraints, quantize_target_units_matrix from ..core.results import BacktestResultV2 from ..core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec @@ -69,6 +78,38 @@ class NativeVectorizedBackend: def __init__(self, config: NativeVectorizedConfig): self.config = config + def prepare_market_arrays( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + symbols: Optional[List[str]] = None, + ) -> PreparedMarketArrays: + """ + Normalize single-symbol or multi-symbol market data once for repeated + signal-notional scoring loops. + + The prepared object is a copied ndarray snapshot plus an explicit + datetime/symbol signature. `run_signals` rejects it when reused against + a different index or symbol layout. + """ + idx = validate_datetime(datetime_index) + symbol_list = symbols or list(closes.keys()) + close_dict = align_series(closes, symbol_list, idx) + high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) + low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) + funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) + return build_market_arrays( + symbols=symbol_list, + idx=idx, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + funding_dict=funding_dict, + ) + def run_target_units( self, datetime_index: Union[pd.DatetimeIndex, pd.Series], @@ -148,6 +189,8 @@ def _run_target_arrays( slot_size: Optional[Union[float, Dict[str, float]]] = None, min_qty: Optional[Union[float, Dict[str, float]]] = None, min_notional: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + raw_signal_matrix: Optional[np.ndarray] = None, ) -> BacktestResultV2: contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) constraints = build_quantity_constraints( @@ -275,6 +318,8 @@ def run_signals( slot_size: Optional[Union[float, Dict[str, float]]] = None, min_qty: Optional[Union[float, Dict[str, float]]] = None, min_notional: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + raw_signal_matrix: Optional[np.ndarray] = None, ) -> BacktestResultV2: """ Scale raw position signals into target units, then run the V2 kernel. @@ -289,23 +334,38 @@ def run_signals( idx = validate_datetime(datetime_index) symbol_list = symbols or list(positions.keys()) - pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0) - close_dict = align_series(closes, symbol_list, idx) + pos_dict = None if raw_signal_matrix is not None else align_series(positions, symbol_list, idx, fill_val=0.0) + close_dict = None if market_arrays is not None else align_series(closes, symbol_list, idx) alloc = self._per_symbol_mapping(alloc_per_trade, symbol_list, default=100_000.0) if ht in ("signal_notional", "signal"): - high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) - low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) - funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) - closes_m, highs_m, lows_m, signals_m, funding_m, is_funding = build_arrays( - symbols=symbol_list, - idx=idx, - closes_dict=close_dict, - highs_dict=high_dict, - lows_dict=low_dict, - signals_dict=pos_dict, - funding_dict=funding_dict, - ) + if market_arrays is None: + high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) + low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) + funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) + closes_m, highs_m, lows_m, signals_m, funding_m, is_funding = build_arrays( + symbols=symbol_list, + idx=idx, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + signals_dict=pos_dict, + funding_dict=funding_dict, + ) + else: + if market_arrays.signature != market_data_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + closes_m = market_arrays.closes + highs_m = market_arrays.highs + lows_m = market_arrays.lows + funding_m = market_arrays.funding + is_funding = market_arrays.is_funding_bar + if raw_signal_matrix is None: + signals_m = build_signal_matrix(symbol_list, idx, pos_dict) + else: + signals_m = np.ascontiguousarray(raw_signal_matrix, dtype=np.float64) + if signals_m.shape != closes_m.shape: + raise ValueError("raw_signal_matrix shape does not match prepared market arrays") allocs = np.array([alloc[s] for s in symbol_list], dtype=np.float64) target_m = scale_signal_notional_matrix( signals=signals_m, @@ -332,6 +392,16 @@ def run_signals( min_notional=min_notional, ) + if close_dict is None: + close_dict = { + symbol: pd.Series(market_arrays.closes[:, j], index=idx, name=symbol) + for j, symbol in enumerate(symbol_list) + } + if pos_dict is None: + pos_dict = { + symbol: pd.Series(raw_signal_matrix[:, j], index=idx, name=symbol) + for j, symbol in enumerate(symbol_list) + } target_units = { s: compute_target_units( hedge_type=hedge_type, diff --git a/benchmarks/phase14_service_loop.json b/benchmarks/phase14_service_loop.json new file mode 100644 index 0000000..e34d7c0 --- /dev/null +++ b/benchmarks/phase14_service_loop.json @@ -0,0 +1,265 @@ +{ + "cython_cpp_recommendation": "Cython/C++ is not justified yet. The measured bottleneck remains in facade/report/preparation layers. Phase 14C added opt-in cache threading and report-level controls; larger real service-loop profiles should come before any Cython/C++ decision.", + "decomposition": { + "native_event": { + "backend": "native_event", + "bars": 360, + "orders": 120, + "profile": "phase14b", + "stages": [ + { + "backend": "native_event", + "notes": "validate_datetime + align OHLC/funding", + "percent_of_profile": 39.877008194125814, + "profile": "phase14b", + "repeats": 2, + "seconds": 0.002686557942070067, + "stage": "data_normalization" + }, + { + "backend": "native_event", + "notes": "build contiguous market arrays", + "percent_of_profile": 23.447176395706094, + "profile": "phase14b", + "repeats": 2, + "seconds": 0.001579662086442113, + "stage": "pandas_to_ndarray" + }, + { + "backend": "native_event", + "notes": "compile orders with vectorized timestamp mapping", + "percent_of_profile": 11.059133161521158, + "profile": "phase14b", + "repeats": 2, + "seconds": 0.000745065975934267, + "stage": "order_array_construction" + }, + { + "backend": "native_event", + "notes": "compiled _engine_event_v1 only", + "percent_of_profile": 0.863562245453643, + "profile": "phase14b", + "repeats": 2, + "seconds": 5.817913915961981e-05, + "stage": "pure_numba_kernel" + }, + { + "backend": "native_event", + "notes": "order report + Series/DataFrame/BacktestResultV2", + "percent_of_profile": 24.753120003193292, + "profile": "phase14b", + "repeats": 2, + "seconds": 0.00166764494497329, + "stage": "result_report_construction" + } + ], + "symbols": 4, + "total_seconds": 0.006737110088579357 + }, + "native_portfolio": { + "bar_symbols": 1440, + "notes": "Prepared-array cache targets WFO/service loops. Pure Numba kernel remains separated from pandas normalization and report construction.", + "repeats": 2, + "rows": 360, + "stages": { + "array_preparation_seconds": 0.005381554015912116, + "array_preparation_share_pct": 21.028710470172022, + "full_facade_seconds": 0.025591459940187633, + "prepared_reuse_facade_seconds": 0.022129982942715287, + "prepared_reuse_speedup": 1.1564157101445842, + "pure_kernel_share_pct": 0.18371145782621087, + "pure_numba_kernel_seconds": 4.701444413512945e-05, + "report_construction_estimate_seconds": 0.020162891480140388, + "report_construction_share_pct": 78.78757807200176 + }, + "status": "pass", + "symbols": 4 + }, + "native_vectorized": { + "backend": "native_vectorized", + "bars": 360, + "orders": 120, + "profile": "phase14b", + "stages": [ + { + "backend": "native_vectorized", + "notes": "validate_datetime + align OHLC/signals/funding", + "percent_of_profile": 51.87375795375464, + "profile": "phase14b", + "repeats": 2, + "seconds": 0.0034934490686282516, + "stage": "data_normalization" + }, + { + "backend": "native_vectorized", + "notes": "build contiguous market/signal arrays", + "percent_of_profile": 29.391058397561075, + "profile": "phase14b", + "repeats": 2, + "seconds": 0.001979346969164908, + "stage": "pandas_to_ndarray" + }, + { + "backend": "native_vectorized", + "notes": "compute fast signal_notional target-unit matrix", + "percent_of_profile": 0.1114105607254627, + "profile": "phase14b", + "repeats": 2, + "seconds": 7.502967491745949e-06, + "stage": "target_sizing" + }, + { + "backend": "native_vectorized", + "notes": "compiled _engine_units_v2 only", + "percent_of_profile": 0.4642198890789888, + "profile": "phase14b", + "repeats": 2, + "seconds": 3.1262985430657864e-05, + "stage": "pure_numba_kernel" + }, + { + "backend": "native_vectorized", + "notes": "Series/DataFrame/BacktestResultV2 construction", + "percent_of_profile": 18.15955319887983, + "profile": "phase14b", + "repeats": 2, + "seconds": 0.0012229589046910405, + "stage": "result_report_construction" + } + ], + "symbols": 4, + "total_seconds": 0.006734520895406604 + } + }, + "folds": 1, + "next_optimization_targets": [ + "native_vectorized: `data_normalization` (51.9%)", + "native_event: `data_normalization` (39.9%)", + "native_portfolio: `report_construction_estimate` (78.8%)", + "Next step should be real workload profiling before considering Cython/C++; Phase 14C moved the main cache/report controls into opt-in APIs." + ], + "order_count": 120, + "parity": { + "arbitrage_package_sweep": true, + "native_event_replay": true, + "portfolio_wfo": true, + "report_heavy_vs_light": true, + "single_symbol_wfo": true + }, + "repeats": 2, + "rows": 360, + "service_loops": { + "arbitrage_package_sweep": { + "audit_status": "pass", + "full_seconds": 0.07462308299727738, + "max_equity_diff": 0.0, + "max_package_residual": 1.2736478538499796e-11, + "notes": "compares native-event arbitrage package cold vs prepared market-array replay; vectorized parity remains audited", + "parity_passed": true, + "parity_status": "pass", + "peak_memory_mb": 0.9488792419433594, + "prepared_equity_diff": 0.0, + "prepared_seconds": 0.06632716755848378, + "speedup": 1.1250756777979205 + }, + "native_event_replay": { + "cold_seconds": 0.009586586616933346, + "equity_diff": 0.0, + "notes": "prepared replay reuses market arrays and compiled order arrays", + "orders": 120, + "parity_passed": true, + "peak_memory_mb": 0.17985248565673828, + "positions_diff": 0.0, + "prepared_seconds": 0.004569042124785483, + "speedup": 2.0981611364293205 + }, + "portfolio_wfo": { + "cache_metadata": { + "available": true, + "backend": "native_portfolio", + "enabled": true, + "fallback_runs": 0, + "market_cache_entries": 2, + "market_cache_hits": 6, + "market_cache_misses": 2, + "prepared_runs": 8, + "prepared_scoring_report_level": "minimal", + "target_mode": "portfolio" + }, + "final_equity_diff": 0.0, + "full_seconds": 0.4395257671130821, + "notes": "compares uncached vs prepared portfolio WFO endpoint scoring", + "objective_diff": 0.0, + "parity_passed": true, + "peak_memory_mb": 0.7784252166748047, + "prepared_seconds": 0.32566470850724727, + "speedup": 1.3496266424683871 + }, + "report_heavy_vs_light": { + "equity_diff": 0.0, + "full_reports": [ + "accepted_notional_report", + "accepted_units_report", + "exposure_report", + "fills_report", + "funding_rates_report", + "order_report", + "orders_report", + "portfolio_contract_report", + "positions_report", + "rebalance_report", + "risk_contribution_report", + "risk_volatility_report", + "symbol_pnl_report", + "target_notional_report", + "target_units_report" + ], + "full_seconds": 0.04100000998005271, + "light_seconds": 0.023349451483227313, + "minimal_reports_omitted": [ + "target_notional_report", + "accepted_notional_report", + "exposure_report", + "funding_rates_report", + "risk_volatility_report", + "risk_contribution_report", + "symbol_pnl_report", + "kernel_symbol_pnl", + "rebalance_report" + ], + "notes": "compares native-portfolio report_level='full' vs 'minimal' construction with core accounting parity", + "parity_passed": true, + "peak_memory_mb": 0.9158792495727539, + "position_diff": 0.0, + "speedup": 1.7559303270787487 + }, + "single_symbol_wfo": { + "best_params": { + "threshold": 0.6000000000000001 + }, + "cache_metadata": { + "available": true, + "backend": "native_vectorized", + "enabled": true, + "fallback_runs": 0, + "market_cache_entries": 7, + "market_cache_hits": 21, + "market_cache_misses": 7, + "prepared_runs": 28, + "prepared_scoring_report_level": "minimal", + "target_mode": "signal_notional" + }, + "final_equity_diff": 0.0, + "full_seconds": 1.0071885475190356, + "notes": "compares uncached vs prepared single-symbol native-vectorized WFO endpoint scoring", + "objective_diff": 0.0, + "parity_passed": true, + "peak_memory_mb": 0.426666259765625, + "prepared_seconds": 0.8834504844853655, + "speedup": 1.1400622504675528 + } + }, + "status": "pass", + "symbols": 4, + "trials": 4 +} diff --git a/benchmarks/phase14_service_loop.md b/benchmarks/phase14_service_loop.md new file mode 100644 index 0000000..16b8bf2 --- /dev/null +++ b/benchmarks/phase14_service_loop.md @@ -0,0 +1,60 @@ +# Phase 14C Prepared Cache And Report-Level Benchmark + +Status: **pass** + +## Profile + +- Rows: `360` +- Symbols: `4` +- Optuna trials: `4` +- Order count: `120` +- Repeats: `2` + +## Service Loop Timings + +| workload | cold/full seconds | prepared/light seconds | speedup | peak MB | parity | notes | +| --- | ---: | ---: | ---: | ---: | --- | --- | +| single-symbol WFO | `1.007189` | `0.883450` | `1.140x` | `0.427` | `True` | compares uncached vs prepared single-symbol native-vectorized WFO endpoint scoring | +| portfolio WFO | `0.439526` | `0.325665` | `1.350x` | `0.778` | `True` | compares uncached vs prepared portfolio WFO endpoint scoring | +| native-event replay | `0.009587` | `0.004569` | `2.098x` | `0.180` | `True` | prepared replay reuses market arrays and compiled order arrays | +| arbitrage sweep | `0.074623` | `0.066327` | `1.125x` | `0.949` | `True` | compares native-event arbitrage package cold vs prepared market-array replay; vectorized parity remains audited | +| portfolio report levels | `0.041000` | `0.023349` | `1.756x` | `0.916` | `True` | compares native-portfolio report_level='full' vs 'minimal' construction with core accounting parity | + +## Stage Decomposition + +| backend | stage | seconds | share | +| --- | --- | ---: | ---: | +| `native_vectorized` | `data_normalization` | `0.003493` | `51.87%` | +| `native_vectorized` | `pandas_to_ndarray` | `0.001979` | `29.39%` | +| `native_vectorized` | `target_sizing` | `0.000008` | `0.11%` | +| `native_vectorized` | `pure_numba_kernel` | `0.000031` | `0.46%` | +| `native_vectorized` | `result_report_construction` | `0.001223` | `18.16%` | +| `native_event` | `data_normalization` | `0.002687` | `39.88%` | +| `native_event` | `pandas_to_ndarray` | `0.001580` | `23.45%` | +| `native_event` | `order_array_construction` | `0.000745` | `11.06%` | +| `native_event` | `pure_numba_kernel` | `0.000058` | `0.86%` | +| `native_event` | `result_report_construction` | `0.001668` | `24.75%` | +| `native_portfolio` | `array_preparation` | `0.005382` | `21.03%` | +| `native_portfolio` | `pure_numba_kernel` | `0.000047` | `0.18%` | +| `native_portfolio` | `report_construction_estimate` | `0.020163` | `78.79%` | + +## Parity Guards + +- `single_symbol_wfo`: `True` +- `portfolio_wfo`: `True` +- `native_event_replay`: `True` +- `arbitrage_package_sweep`: `True` +- `report_heavy_vs_light`: `True` + +## Next Optimization Targets + +- native_vectorized: `data_normalization` (51.9%) +- native_event: `data_normalization` (39.9%) +- native_portfolio: `report_construction_estimate` (78.8%) +- Next step should be real workload profiling before considering Cython/C++; Phase 14C moved the main cache/report controls into opt-in APIs. + +## Cython/C++ Decision + +Cython/C++ is not justified yet. The measured bottleneck remains in facade/report/preparation layers. Phase 14C added opt-in cache threading and report-level controls; larger real service-loop profiles should come before any Cython/C++ decision. + +This report is a measurement artifact. It must not be used to justify changing accounting, fill policy, margin, or report semantics. diff --git a/benchmarks/run_phase14_service_loop.py b/benchmarks/run_phase14_service_loop.py new file mode 100644 index 0000000..4456215 --- /dev/null +++ b/benchmarks/run_phase14_service_loop.py @@ -0,0 +1,694 @@ +#!/usr/bin/env python3 +""" +Phase 14C real WFO and service-loop benchmark. + +This runner measures the remaining higher-level performance debt without +changing engine semantics. It is intentionally a benchmark/certification +artifact, not an optimization pass. +""" + +from __future__ import annotations + +import argparse +import gc +import json +import statistics +import sys +import time +import tracemalloc +from dataclasses import asdict +from pathlib import Path +from typing import Dict, List, Optional + +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, + ContractType, + ExecutionConfig, + HedgePolicy, + HedgePolicyKind, + NativeEventBackend, + NativeEventConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, + OrderIntent, + OrderSide, + OrderType, + PackageExecutionKind, + QuantBTEndpoint, + SizingPolicy, + SizingPolicyKind, + TimeInForce, + build_arbitrage_domain_audit, + compare_native_arbitrage_results, +) +from quantbt.benchmarks.profile_phase7 import profile_native_event, profile_native_vectorized # noqa: E402 +from quantbt.benchmarks.run_phase7 import BenchmarkProfile, _make_market_frames, _make_orders # noqa: E402 +from quantbt.benchmarks.run_phase12_benchmark_nautilus_cert import _benchmark_native_portfolio # noqa: E402 + + +def run_benchmark( + *, + rows: int = 720, + symbols: int = 4, + trials: int = 8, + folds: int = 1, + order_count: Optional[int] = None, + repeats: int = 2, +) -> Dict: + order_count = int(order_count if order_count is not None else max(20, rows // 3)) + stage_profile = BenchmarkProfile( + name="phase14b", + bars=int(rows), + symbols=max(2, int(symbols)), + order_count=order_count, + repeats=max(1, int(repeats)), + ) + + vectorized_profile = profile_native_vectorized(stage_profile) + event_profile = profile_native_event(stage_profile) + portfolio_profile = _benchmark_native_portfolio( + rows=int(rows), + symbols=max(2, int(symbols)), + repeats=max(1, int(repeats)), + ) + single_wfo = _single_symbol_wfo_benchmark(rows=rows, trials=trials, repeats=repeats) + portfolio_wfo = _portfolio_wfo_benchmark(rows=rows, trials=trials, repeats=repeats) + event_replay = _native_event_replay_benchmark(rows=rows, symbols=symbols, order_count=order_count, repeats=repeats) + arbitrage_sweep = _arbitrage_package_sweep(rows=rows, repeats=repeats) + report_cost = _report_level_benchmark(rows=rows, symbols=symbols, repeats=repeats) + + parity = { + "single_symbol_wfo": single_wfo["parity_passed"], + "portfolio_wfo": portfolio_wfo["parity_passed"], + "native_event_replay": event_replay["parity_passed"], + "arbitrage_package_sweep": arbitrage_sweep["parity_passed"], + "report_heavy_vs_light": report_cost["parity_passed"], + } + pure_kernel_share_pct = max( + _stage_share(vectorized_profile, "pure_numba_kernel"), + _stage_share(event_profile, "pure_numba_kernel"), + float(portfolio_profile["stages"]["pure_kernel_share_pct"]), + ) + status = "pass" if all(parity.values()) and portfolio_profile["status"] == "pass" else "fail" + return { + "status": status, + "rows": int(rows), + "symbols": max(2, int(symbols)), + "trials": int(trials), + "folds": int(folds), + "order_count": order_count, + "repeats": int(repeats), + "decomposition": { + "native_vectorized": asdict(vectorized_profile), + "native_event": asdict(event_profile), + "native_portfolio": portfolio_profile, + }, + "service_loops": { + "single_symbol_wfo": single_wfo, + "portfolio_wfo": portfolio_wfo, + "native_event_replay": event_replay, + "arbitrage_package_sweep": arbitrage_sweep, + "report_heavy_vs_light": report_cost, + }, + "parity": parity, + "cython_cpp_recommendation": _cython_cpp_recommendation(pure_kernel_share_pct), + "next_optimization_targets": _next_targets(vectorized_profile, event_profile, portfolio_profile), + } + + +def make_markdown(report: Dict) -> str: + loops = report["service_loops"] + lines = [ + "# Phase 14C Prepared Cache And Report-Level Benchmark", + "", + f"Status: **{report['status']}**", + "", + "## Profile", + "", + f"- Rows: `{report['rows']}`", + f"- Symbols: `{report['symbols']}`", + f"- Optuna trials: `{report['trials']}`", + f"- Order count: `{report['order_count']}`", + f"- Repeats: `{report['repeats']}`", + "", + "## Service Loop Timings", + "", + "| workload | cold/full seconds | prepared/light seconds | speedup | peak MB | parity | notes |", + "| --- | ---: | ---: | ---: | ---: | --- | --- |", + ] + for key, label in ( + ("single_symbol_wfo", "single-symbol WFO"), + ("portfolio_wfo", "portfolio WFO"), + ("native_event_replay", "native-event replay"), + ("arbitrage_package_sweep", "arbitrage sweep"), + ("report_heavy_vs_light", "portfolio report levels"), + ): + item = loops[key] + lines.append( + "| {label} | `{cold:.6f}` | `{prepared:.6f}` | `{speedup:.3f}x` | `{peak:.3f}` | `{parity}` | {notes} |".format( + label=label, + cold=float(item.get("full_seconds", item.get("cold_seconds", 0.0))), + prepared=float(item.get("prepared_seconds", item.get("light_seconds", 0.0))), + speedup=float(item.get("speedup", 0.0)), + peak=float(item.get("peak_memory_mb", 0.0)), + parity=bool(item.get("parity_passed", False)), + notes=item.get("notes", ""), + ) + ) + + lines.extend( + [ + "", + "## Stage Decomposition", + "", + "| backend | stage | seconds | share |", + "| --- | --- | ---: | ---: |", + ] + ) + for backend in ("native_vectorized", "native_event"): + record = report["decomposition"][backend] + for stage in record["stages"]: + lines.append( + f"| `{backend}` | `{stage['stage']}` | `{stage['seconds']:.6f}` | `{stage['percent_of_profile']:.2f}%` |" + ) + p = report["decomposition"]["native_portfolio"]["stages"] + for stage, label in ( + ("array_preparation_seconds", "array_preparation"), + ("pure_numba_kernel_seconds", "pure_numba_kernel"), + ("report_construction_estimate_seconds", "report_construction_estimate"), + ): + share_key = { + "array_preparation_seconds": "array_preparation_share_pct", + "pure_numba_kernel_seconds": "pure_kernel_share_pct", + "report_construction_estimate_seconds": "report_construction_share_pct", + }[stage] + lines.append(f"| `native_portfolio` | `{label}` | `{p[stage]:.6f}` | `{p[share_key]:.2f}%` |") + + lines.extend( + [ + "", + "## Parity Guards", + "", + ] + ) + for name, passed in report["parity"].items(): + lines.append(f"- `{name}`: `{passed}`") + + lines.extend( + [ + "", + "## Next Optimization Targets", + "", + ] + ) + for target in report["next_optimization_targets"]: + lines.append(f"- {target}") + + lines.extend( + [ + "", + "## Cython/C++ Decision", + "", + report["cython_cpp_recommendation"], + "", + "This report is a measurement artifact. It must not be used to justify changing accounting, fill policy, margin, or report semantics.", + ] + ) + return "\n".join(lines) + "\n" + + +def _single_symbol_wfo_benchmark(*, rows: int, trials: int, repeats: int) -> Dict: + _quiet_optuna() + data = _single_frame(rows) + + def run_once(use_cache: bool): + endpoint = QuantBTEndpoint.train_test_split( + strategy_class=_single_wfo_strategy, + test_start=data.index[max(20, len(data) // 2)], + target_mode="signal_notional", + backend="native_vectorized", + optimization_mode="mode_5_full_robust", + optimization_config={ + "scoring_backend": "endpoint", + "use_prepared_scoring_cache": bool(use_cache), + "candidate_selection_metric": "full_plateau_robust", + "top_is_fraction": 0.3, + "scoring_trading_days": 365, + "use_numba": True, + }, + optuna_trials=max(2, int(trials)), + random_seed=42, + initial_capital=20_000.0, + leverage=3.0, + alloc_per_trade=5_000.0, + fee_rate=0.0001, + use_funding=False, + use_pyramiding=False, + ) + return endpoint.backtest(data=data, param_ranges={"threshold": (0.2, 1.2, 0.1)}) + + cached = run_once(True) + uncached = run_once(False) + cached_seconds = _timeit(lambda: run_once(True), repeats) + uncached_seconds = _timeit(lambda: run_once(False), repeats) + peak_memory_mb = _peak_memory_mb(lambda: run_once(True)) + equity_diff = float(abs(cached.equity.iloc[-1] - uncached.equity.iloc[-1])) + objective_diff = float(abs(cached.metadata["walk_forward"]["best_trial"]["objective"] - uncached.metadata["walk_forward"]["best_trial"]["objective"])) + return { + "full_seconds": float(uncached_seconds), + "prepared_seconds": float(cached_seconds), + "speedup": float(uncached_seconds / cached_seconds) if cached_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-9 and objective_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "final_equity_diff": equity_diff, + "objective_diff": objective_diff, + "cache_metadata": cached.metadata["walk_forward"].get("prepared_scoring_cache", {}), + "best_params": cached.metadata["walk_forward"].get("params", {}), + "notes": "compares uncached vs prepared single-symbol native-vectorized WFO endpoint scoring", + } + + +def _portfolio_wfo_benchmark(*, rows: int, trials: int, repeats: int) -> Dict: + _quiet_optuna() + data = _portfolio_data(rows, 2) + + def run_once(use_cache: bool): + endpoint = QuantBTEndpoint.train_test_split( + strategy_class=_portfolio_wfo_strategy, + test_start=next(iter(data.values())).index[max(20, rows // 2)], + target_mode="portfolio", + portfolio_mode="longshort", + optimization_mode="mode_1_decay", + optimization_config={ + "scoring_backend": "endpoint", + "use_prepared_scoring_cache": bool(use_cache), + "top_is_fraction": 0.3, + }, + optuna_trials=max(2, int(trials)), + random_seed=7, + initial_capital=100_000.0, + leverage=4.0, + alloc_per_trade=1_000.0, + fee=0.0, + use_funding=False, + ) + return endpoint.backtest(data=data, param_ranges={"scale": (0.5, 1.5, 0.1)}) + + cached = run_once(True) + uncached = run_once(False) + cached_seconds = _timeit(lambda: run_once(True), repeats) + uncached_seconds = _timeit(lambda: run_once(False), repeats) + peak_memory_mb = _peak_memory_mb(lambda: run_once(True)) + equity_diff = float(abs(cached.equity.iloc[-1] - uncached.equity.iloc[-1])) + objective_diff = float(abs(cached.metadata["walk_forward"]["best_trial"]["objective"] - uncached.metadata["walk_forward"]["best_trial"]["objective"])) + return { + "full_seconds": float(uncached_seconds), + "prepared_seconds": float(cached_seconds), + "speedup": float(uncached_seconds / cached_seconds) if cached_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-9 and objective_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "final_equity_diff": equity_diff, + "objective_diff": objective_diff, + "cache_metadata": cached.metadata["walk_forward"].get("prepared_scoring_cache", {}), + "notes": "compares uncached vs prepared portfolio WFO endpoint scoring", + } + + +def _native_event_replay_benchmark(*, rows: int, symbols: int, order_count: int, repeats: int) -> Dict: + idx, frames = _make_market_frames(int(rows), max(2, int(symbols))) + symbols_list = list(frames.keys()) + orders = _make_orders(idx, int(order_count), len(symbols_list)) + closes = {symbol: frame["close"] for symbol, frame in frames.items()} + highs = {symbol: frame["high"] for symbol, frame in frames.items()} + lows = {symbol: frame["low"] for symbol, frame in frames.items()} + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=100_000.0, leverage=5.0), + execution=ExecutionConfig(slippage_bps=0.0), + fee_rate=0.0, + use_funding=False, + ) + ) + market = backend.prepare_market_arrays(idx, closes=closes, highs=highs, lows=lows, symbols=symbols_list) + compiled = backend.compile_orders(idx, orders=orders, symbols=symbols_list) + + def cold(): + return backend.run_orders(idx, orders, closes, highs=highs, lows=lows, symbols=symbols_list) + + def prepared(): + return backend.run_orders( + idx, + orders, + closes, + highs=highs, + lows=lows, + symbols=symbols_list, + market_arrays=market, + compiled_orders=compiled, + ) + + cold_result = cold() + prepared_result = prepared() + cold_seconds = _timeit(cold, repeats) + prepared_seconds = _timeit(prepared, repeats) + peak_memory_mb = _peak_memory_mb(prepared) + equity_diff = float(np.max(np.abs(cold_result.equity.to_numpy() - prepared_result.equity.to_numpy()))) + positions_diff = float(np.max(np.abs(cold_result.positions.to_numpy() - prepared_result.positions.to_numpy()))) + return { + "cold_seconds": float(cold_seconds), + "prepared_seconds": float(prepared_seconds), + "speedup": float(cold_seconds / prepared_seconds) if prepared_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-12 and positions_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "equity_diff": equity_diff, + "positions_diff": positions_diff, + "orders": int(len(orders)), + "notes": "prepared replay reuses market arrays and compiled order arrays", + } + + +def _arbitrage_package_sweep(*, rows: int, repeats: int) -> Dict: + idx = pd.date_range("2023-01-01", periods=int(rows), freq="1h", tz="UTC") + x = np.linspace(0.0, 8.0, len(idx)) + perp = pd.Series(100.0 + np.sin(x) * 2.0 + np.arange(len(idx)) * 0.01, index=idx) + quarterly = pd.Series(perp.to_numpy() + 1.5 + np.cos(x) * 0.5, index=idx) + closes = {"PERP": perp, "QUARTERLY": quarterly} + signal = pd.Series(0.0, index=idx) + signal.iloc[len(idx) // 4 : len(idx) // 2] = 1.0 + signal.iloc[-3:] = 0.0 + spec = BasisArbitrageSpec( + arb_id="PHASE14B_BASIS", + legs=( + ArbitrageLeg("PERP", 1.0, role="perp", contract_type=ContractType.LINEAR, funding_enabled=True), + ArbitrageLeg("QUARTERLY", -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=10_000.0, + reference_symbol="PERP", + ), + execution_policy=ArbExecutionPolicy(PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + account = AccountConfig(initial_capital=100_000.0, leverage=5.0) + event = NativeEventBackend(NativeEventConfig(account=account, fee_rate=0.0001, use_funding=True)) + vector = NativeVectorizedBackend(NativeVectorizedConfig(account=account, fee_rate=0.0001, use_funding=True)) + funding = {"PERP": pd.Series(0.00005, index=idx), "QUARTERLY": 0.0} + + market = event.prepare_market_arrays(idx, closes=closes, highs=closes, lows=closes, funding_rate=funding, symbols=list(closes)) + + def run_event(): + return event.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding) + + def run_event_prepared(): + return event.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding, market_arrays=market) + + def run_vector(): + return vector.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding) + + event_result = run_event() + prepared_result = run_event_prepared() + vector_result = run_vector() + audit = build_arbitrage_domain_audit(event_result) + parity = compare_native_arbitrage_results(event_result, vector_result) + event_seconds = _timeit(run_event, repeats) + prepared_seconds = _timeit(run_event_prepared, repeats) + peak_memory_mb = _peak_memory_mb(run_event_prepared) + prepared_equity_diff = float(np.max(np.abs(event_result.equity.to_numpy() - prepared_result.equity.to_numpy()))) + return { + "full_seconds": float(event_seconds), + "prepared_seconds": float(prepared_seconds), + "speedup": float(event_seconds / prepared_seconds) if prepared_seconds > 0.0 else 0.0, + "parity_passed": bool(audit["passed"] and parity["passed"] and prepared_equity_diff <= 1e-10), + "peak_memory_mb": peak_memory_mb, + "audit_status": audit["status"], + "parity_status": parity["status"], + "max_equity_diff": parity["max_abs_equity_diff"], + "prepared_equity_diff": prepared_equity_diff, + "max_package_residual": parity["max_abs_package_residual"], + "notes": "compares native-event arbitrage package cold vs prepared market-array replay; vectorized parity remains audited", + } + + +def _report_level_benchmark(*, rows: int, symbols: int, repeats: int) -> Dict: + def make_endpoint(report_level: str): + return QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="native_portfolio", + initial_capital=100_000.0, + leverage=4.0, + alloc_per_trade=1_000.0, + fee_rate=0.0, + use_funding=False, + report_level=report_level, + ) + + full_endpoint = make_endpoint("full") + minimal_endpoint = make_endpoint("minimal") + data = _portfolio_data(rows, max(2, symbols)) + positions = _portfolio_positions(next(iter(data.values())).index, max(2, symbols)) + + def run_full(): + return make_endpoint("full").backtest(data=data, positions=positions) + + def run_minimal(): + return make_endpoint("minimal").backtest(data=data, positions=positions) + + full_result = full_endpoint.backtest(data=data, positions=positions) + minimal_result = minimal_endpoint.backtest(data=data, positions=positions) + full_seconds = _timeit(run_full, repeats) + minimal_seconds = _timeit(run_minimal, repeats) + peak_memory_mb = _peak_memory_mb(run_full) + equity_diff = float(np.max(np.abs(full_result.equity.to_numpy() - minimal_result.equity.to_numpy()))) + position_diff = float(np.max(np.abs(full_result.positions.to_numpy() - minimal_result.positions.to_numpy()))) + return { + "full_seconds": float(full_seconds), + "light_seconds": float(minimal_seconds), + "speedup": float(full_seconds / minimal_seconds) if minimal_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-10 and position_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "equity_diff": equity_diff, + "position_diff": position_diff, + "full_reports": sorted(k for k in full_result.metadata if k.endswith("_report")), + "minimal_reports_omitted": tuple(minimal_result.metadata.get("reports_omitted", ())), + "notes": "compares native-portfolio report_level='full' vs 'minimal' construction with core accounting parity", + } + + +def _legacy_report_heavy_vs_light(*, rows: int, symbols: int, repeats: int) -> Dict: + endpoint = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="native_portfolio", + initial_capital=100_000.0, + leverage=4.0, + alloc_per_trade=1_000.0, + fee_rate=0.0, + use_funding=False, + ) + data = _portfolio_data(rows, max(2, symbols)) + positions = _portfolio_positions(next(iter(data.values())).index, max(2, symbols)) + result = endpoint.backtest(data=data, positions=positions) + + def light(): + return { + "final_equity": float(result.equity.iloc[-1]), + "fees": float(result.fees.sum()), + "funding": float(result.funding.sum()), + "rows": int(len(result.equity)), + } + + def heavy(): + return result.full_report(scope="full") + + light_summary = light() + heavy_report = heavy() + light_seconds = _timeit(light, repeats) + heavy_seconds = _timeit(heavy, repeats) + peak_memory_mb = _peak_memory_mb(heavy) + return { + "full_seconds": float(heavy_seconds), + "light_seconds": float(light_seconds), + "speedup": float(heavy_seconds / light_seconds) if light_seconds > 0.0 else 0.0, + "parity_passed": bool(abs(light_summary["final_equity"] - heavy_report["final_equity"]) <= 1e-9), + "peak_memory_mb": peak_memory_mb, + "final_equity": light_summary["final_equity"], + "notes": "legacy measurement of metrics/report export cost", + } + + +def _single_wfo_strategy(data, params, train_index, test_index, fold): + threshold = float(params["threshold"]) + frame = data.loc[: test_index[-1]] + ret = frame["close"].pct_change().fillna(0.0) + signal = np.where(ret > threshold / 10_000.0, 1.0, np.where(ret < -threshold / 10_000.0, -1.0, 0.0)) + return pd.Series(signal, index=frame.index).reindex(test_index).fillna(0.0) + + +def _portfolio_wfo_strategy(data, params, train_index, test_index, fold): + scale = float(params["scale"]) + return pd.DataFrame({"SYM000": scale, "SYM001": -scale}, index=test_index) + + +def _single_frame(rows: int) -> pd.DataFrame: + idx = pd.date_range("2021-01-01", periods=int(rows), freq="1h", tz="UTC") + x = np.linspace(0.0, 14.0, len(idx)) + close = 100.0 + np.cumsum(np.sin(x) * 0.05 + 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 _portfolio_data(rows: int, symbols: int) -> Dict[str, pd.DataFrame]: + idx = pd.date_range("2021-01-01", periods=int(rows), freq="1h", tz="UTC") + out = {} + for j in range(int(symbols)): + x = np.linspace(0.0, 10.0 + j, len(idx)) + close = 100.0 + j * 3.0 + np.cumsum(np.sin(x) * 0.03 + 0.01) + out[f"SYM{j:03d}"] = pd.DataFrame( + { + "open": close, + "high": close * 1.002, + "low": close * 0.998, + "close": close, + "volume": 1_000.0 + j, + }, + index=idx, + ) + return out + + +def _portfolio_positions(idx: pd.DatetimeIndex, symbols: int) -> Dict[str, pd.Series]: + grid = np.arange(len(idx)) + out = {} + for j in range(int(symbols)): + active = np.where(((grid // (18 + j % 5)) + j) % 4 == 0, 1.0, 0.0) + out[f"SYM{j:03d}"] = pd.Series(active * (1.0 if j % 2 == 0 else -1.0), index=idx) + return out + + +def _stage_share(profile, stage_name: str) -> float: + for stage in profile.stages: + if stage.stage == stage_name: + return float(stage.percent_of_profile) + return 0.0 + + +def _quiet_optuna() -> None: + try: + import optuna + + optuna.logging.set_verbosity(optuna.logging.WARNING) + except Exception: + return + + +def _largest_stage(profile) -> str: + stage = max(profile.stages, key=lambda item: item.seconds) + return f"{profile.backend}: `{stage.stage}` ({stage.percent_of_profile:.1f}%)" + + +def _next_targets(vectorized_profile, event_profile, portfolio_profile: Dict) -> List[str]: + p = portfolio_profile["stages"] + return [ + _largest_stage(vectorized_profile), + _largest_stage(event_profile), + "native_portfolio: `report_construction_estimate` ({:.1f}%)".format( + float(p["report_construction_share_pct"]) + ), + "Next step should be real workload profiling before considering Cython/C++; Phase 14C moved the main cache/report controls into opt-in APIs.", + ] + + +def _cython_cpp_recommendation(pure_kernel_share_pct: float) -> str: + if pure_kernel_share_pct >= 35.0: + return ( + "Pure Numba kernel share is now large enough to investigate Cython/C++ " + "after adding parity locks around the target kernel." + ) + return ( + "Cython/C++ is not justified yet. The measured bottleneck remains in " + "facade/report/preparation layers. Phase 14C added opt-in cache " + "threading and report-level controls; larger real service-loop profiles " + "should come before any Cython/C++ decision." + ) + + +def _timeit(fn, repeats: int) -> float: + samples: List[float] = [] + for _ in range(max(1, int(repeats))): + start = time.perf_counter() + fn() + samples.append(time.perf_counter() - start) + return float(statistics.mean(samples)) + + +def _peak_memory_mb(fn) -> float: + gc.collect() + tracemalloc.start() + try: + fn() + _current, peak = tracemalloc.get_traced_memory() + return float(peak / (1024 * 1024)) + finally: + tracemalloc.stop() + + +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=720) + parser.add_argument("--symbols", type=int, default=4) + parser.add_argument("--trials", type=int, default=8) + parser.add_argument("--folds", type=int, default=1) + parser.add_argument("--order-count", type=int, default=None) + parser.add_argument("--repeats", type=int, default=2) + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase14_service_loop.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase14_service_loop.md") + args = parser.parse_args(argv) + report = run_benchmark( + rows=args.rows, + symbols=args.symbols, + trials=args.trials, + folds=args.folds, + order_count=args.order_count, + repeats=args.repeats, + ) + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.md_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=_json_default) + "\n", 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/docs/endpoint.md b/docs/endpoint.md index b301c27..2bd1c89 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -1001,6 +1001,33 @@ dollar-neutral beta constraint. `dca_ladder` remains on the DCA/grid engine because it requires intrabar grid-trigger fills. +Native portfolio report levels: + +```python +result = QuantBTEndpoint.portfolio( + portfolio_mode="longshort", + backend="native_portfolio", + report_level="full", # full | standard | minimal +).backtest( + positions=positions_df, + data=data_dict, +) +``` + +- `full`: default and backward compatible. Keeps all stakeholder/audit tables, + including target/accepted notional, exposure, symbol PnL, risk reports, kernel + symbol PnL, and rebalance report. +- `standard`: keeps core audit tables such as target/accepted notional, + exposure, funding-rate report, and symbol PnL, but omits selected expansion + tables. +- `minimal`: keeps accounting-critical result surfaces only: equity, returns, + positions, closes, fees, funding, margin, diagnostics, target/accepted units, + totals, and config metadata. Use it inside optimizers or service loops, then + rerun the chosen portfolio with `report_level="full"` for final audit. + +Core accounting is identical across report levels; only metadata artifact +construction changes. + Experimental Nautilus portfolio validation: ```python @@ -1206,6 +1233,8 @@ wfo = QuantBTEndpoint.walk_forward( # train-only selector for strict train/test split: # "candidate_selection_metric": "is_plateau_robust", # "scoring_backend": "endpoint", # endpoint | proxy + # "use_prepared_scoring_cache": True, + # "prepared_scoring_report_level": "minimal", # "plateau_quantile": 0.25, # "plateau_median_weight": 0.25, # "plateau_std_penalty": 0.50, @@ -1249,6 +1278,20 @@ result.metadata["walk_forward"]["candidate_table"] result.metadata["walk_forward"]["best_trial"] ``` +Prepared endpoint scoring: + +- `optimization_config["use_prepared_scoring_cache"]` defaults to `True`. +- Supported prepared scoring routes currently include single-symbol + `target_mode="signal_notional"` with `backend="native_vectorized"` and + portfolio `target_mode="portfolio"` with `backend="native_portfolio"`. +- The cache is run-local to one `.backtest(...)` call and is guarded by + datetime/symbol signatures. It avoids repeated pandas OHLC/funding packing + during Optuna/WFO scoring. +- `prepared_scoring_report_level` defaults to `"minimal"` for portfolio scoring + so trial objective calculation does not build heavy stakeholder reports for + every candidate. Final stitched backtests still use the endpoint + `report_level`, which defaults to `"full"`. + Single train/test holdout: ```python diff --git a/docs/portfolio_engine_v3.md b/docs/portfolio_engine_v3.md index 6e4c8d1..308782a 100644 --- a/docs/portfolio_engine_v3.md +++ b/docs/portfolio_engine_v3.md @@ -235,13 +235,62 @@ Safety rule: prepared arrays are validated by datetime/symbol signature before execution. A stale index, different symbol order, or wrong signal shape raises instead of silently reusing incompatible cache. +Prepared arrays are copied market snapshots, not mutable global caches. If the +underlying OHLC/funding values change, rebuild `market = backend.prepare_market_arrays(...)`. +The signature guard protects layout compatibility; it intentionally does not +hide data mutation behind object-identity cache magic. + +## Phase 14C - Report Levels + +Native portfolio now supports opt-in report artifact controls: + +```python +result = QuantBTEndpoint.portfolio( + backend="native_portfolio", + portfolio_mode="market_neutral", + report_level="minimal", # full | standard | minimal +).backtest( + positions=positions_df, + data=data_dict, +) +``` + +Use `report_level="full"` for final research artifacts and stakeholder review. +This is the default and keeps the complete audit surface: + +- target and accepted units; +- target and accepted notional; +- exposure and margin reports; +- risk volatility and risk contribution; +- symbol PnL and kernel symbol PnL; +- rebalance rejection report; +- portfolio contract validation. + +Use `report_level="standard"` for lighter service reports that still need +portfolio contract validation and leg-level PnL explainability. It keeps +target/accepted notional, exposure, funding rates, and symbol PnL, but omits +selected expansion tables. + +Use `report_level="minimal"` only for optimizer/service loops where the +objective needs accounting outputs but not full audit artifacts. It preserves +equity, returns, positions, closes, fees, funding, margin, diagnostics, +target/accepted units, totals, config metadata, and quantity constraints. +Contract validation is marked as skipped because the heavy reports it needs are +intentionally omitted. + +Parity contract: core accounting must be identical across `full`, `standard`, +and `minimal`. Tests lock equality for equity, returns, positions, fees, +funding, margin, and diagnostics before any report-level speed claim is used. + Optimization scope completed: - `notional` and `unit` sizing use ndarray vector paths; - market normalization can be reused through `PreparedMarketArrays`; - symbol PnL report construction is vectorized into one DataFrame build; - pure kernel benchmark and full facade benchmark are reported separately. +- WFO endpoint scoring can reuse prepared market arrays for portfolio scoring + and for single-symbol `signal_notional` native-vectorized scoring. -Remaining optimization target: report construction is still the largest -measured residual bucket, so Cython/C++ is not justified before further -Python/reporting cleanup. +Remaining optimization target: report construction is still a residual bucket +for full stakeholder artifacts, so Cython/C++ is not justified before larger +real service-loop profiling. diff --git a/endpoint.py b/endpoint.py index 637c885..4872c43 100644 --- a/endpoint.py +++ b/endpoint.py @@ -125,6 +125,10 @@ class EndpointConfig: nautilus_depth_config: Optional `NautilusExecutionDepthConfig` for package-order preflight. Existing endpoints are unchanged when this is omitted. + report_level: + Native portfolio artifact policy. `full` preserves all audit reports; + `standard` keeps core audit tables; `minimal` keeps accounting outputs + for optimizer/service loops. Existing calls default to `full`. strategy_class: Optional strategy callable/class for `walk_forward` mode. The strategy must return a Series, DataFrame, or `{symbol: Series}` OOS output. @@ -164,6 +168,7 @@ class EndpointConfig: dca_kwargs: Dict = field(default_factory=dict) nautilus_config: object = None nautilus_depth_config: Optional[NautilusExecutionDepthConfig] = None + report_level: str = "full" strategy_class: object = None walkforward_config: Optional[WalkForwardConfig] = None walkforward_target_mode: str = "signal_notional" @@ -1485,6 +1490,7 @@ def _run_portfolio(self, data, positions, closes, highs, lows, datetime_index, s use_pyramiding=self.config.use_pyramiding, betas=self.config.betas, risk_lookback=self.config.risk_lookback, + report_level=self.config.report_level, ).result target_units = native_reference.metadata["target_units_report"].reindex(columns=symbol_list) orders = _build_portfolio_orders_from_target_units_for_nautilus( @@ -1543,6 +1549,7 @@ def _run_portfolio(self, data, positions, closes, highs, lows, datetime_index, s slot_size=self.config.slot_size, min_qty=self.config.min_qty, min_notional=self.config.min_notional, + report_level=self.config.report_level, ) self._store_result(self.engine.result) return self.result @@ -1735,6 +1742,7 @@ def _endpoint_run_config_payload(config: EndpointConfig) -> Dict: "structured_order_spec": _jsonable(config.structured_order_spec), "symbols": _jsonable(config.symbols), "metadata": _jsonable(config.metadata), + "report_level": config.report_level, } if config.nautilus_config is not None: payload["nautilus"] = _jsonable( @@ -1979,6 +1987,12 @@ def __init__( self.market_lows = market_lows self.market_datetime_index = market_datetime_index self.use_prepared_cache = bool((wf_config.metadata if wf_config is not None else {}).get("use_prepared_scoring_cache", True)) + self.prepared_scoring_report_level = str( + (wf_config.metadata if wf_config is not None else {}).get("prepared_scoring_report_level", "minimal") + ) + self._single_backend = None + self._single_market_maps = {} + self._single_market_cache = {} self._portfolio_backend = None self._portfolio_market_maps = {} self._portfolio_market_cache = {} @@ -1995,7 +2009,9 @@ def __init__( def __call__(self, data, output, index, fold, params, context: str, trading_days: int) -> Dict[str, float]: try: - if self._can_score_portfolio_prepared(output): + if self._can_score_single_vectorized_prepared(output): + result = self._score_single_vectorized_prepared(output=output, index=index) + elif self._can_score_portfolio_prepared(output): result = self._score_portfolio_prepared(output=output, index=index) else: result = self._score_fallback(data=data, output=output, index=index) @@ -2017,10 +2033,27 @@ def __call__(self, data, output, index, fold, params, context: str, trading_days def prepared_cache_metadata(self) -> Dict[str, object]: meta = dict(self._stats) - meta["market_cache_entries"] = len(self._portfolio_market_cache) - meta["available"] = self.target_mode == "portfolio" and self.score_config.backend == "native_portfolio" + meta["market_cache_entries"] = len(self._portfolio_market_cache) + len(self._single_market_cache) + meta["prepared_scoring_report_level"] = self.prepared_scoring_report_level + meta["available"] = ( + self._prepared_single_available() + or (self.target_mode == "portfolio" and self.score_config.backend == "native_portfolio") + ) return meta + def _prepared_single_available(self) -> bool: + return ( + self.score_config.mode == "signal_notional" + and _resolve_backend(self.score_config) == "native_vectorized" + ) + + def _can_score_single_vectorized_prepared(self, output) -> bool: + return ( + self.use_prepared_cache + and self._prepared_single_available() + and isinstance(output, pd.Series) + ) + def _can_score_portfolio_prepared(self, output) -> bool: return ( self.use_prepared_cache @@ -2038,6 +2071,50 @@ def _score_fallback(self, data, output, index): return temp.backtest(data=sliced_data, positions=output, symbols=symbol_list) return temp.backtest(data=sliced_data, signal=output, symbols=symbol_list) + def _score_single_vectorized_prepared(self, output: pd.Series, index): + idx = _ensure_utc_index(index) + symbol_list = self._symbol_list(output) + close_map, high_map, low_map = self._single_maps(symbol_list) + backend = self._single_backend_instance() + cache_key = self._market_cache_key(idx, symbol_list) + market = self._single_market_cache.get(cache_key) + if market is None: + market = backend.prepare_market_arrays( + datetime_index=idx, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=self.score_config.funding_rate, + symbols=symbol_list, + ) + self._single_market_cache[cache_key] = market + self._stats["market_cache_misses"] += 1 + else: + self._stats["market_cache_hits"] += 1 + + self._stats["prepared_runs"] += 1 + return backend.run_signals( + positions={symbol_list[0]: output}, + closes=close_map, + highs=high_map, + lows=low_map, + datetime_index=idx, + funding_rate=self.score_config.funding_rate, + contract_size=self.score_config.contract_size, + leverage=self.score_config.account.leverage, + alloc_per_trade=self.score_config.alloc_per_trade, + hedge_type=self.score_config.sizing, + use_pyramiding=self.score_config.use_pyramiding, + symbols=symbol_list, + market_arrays=market, + instruments=self.score_config.instruments, + qty_step=self.score_config.qty_step, + lot_size=self.score_config.lot_size, + slot_size=self.score_config.slot_size, + min_qty=self.score_config.min_qty, + min_notional=self.score_config.min_notional, + ) + def _score_portfolio_prepared(self, output, index): idx = _ensure_utc_index(index) symbol_list = self._symbol_list(output) @@ -2087,8 +2164,21 @@ def _score_portfolio_prepared(self, output, index): slot_size=self.score_config.slot_size, min_qty=self.score_config.min_qty, min_notional=self.score_config.min_notional, + report_level=self.prepared_scoring_report_level, ) + def _single_backend_instance(self) -> NativeVectorizedBackend: + if self._single_backend is None: + self._single_backend = NativeVectorizedBackend( + NativeVectorizedConfig( + account=self.score_config.account, + execution=self.score_config.execution, + fee_rate=self.score_config.v2_fee_rate, + use_funding=bool(self.score_config.use_funding), + ) + ) + return self._single_backend + def _portfolio_backend_instance(self) -> NativePortfolioBackend: if self._portfolio_backend is None: asset_type = self.score_config.asset_type.lower() @@ -2100,10 +2190,32 @@ def _portfolio_backend_instance(self) -> NativePortfolioBackend: execution=self.score_config.execution, fee_rate=fee_oneway, use_funding=bool(self.score_config.use_funding), + report_level=self.prepared_scoring_report_level, ) ) return self._portfolio_backend + def _single_maps(self, symbol_list): + key = tuple(symbol_list) + if key not in self._single_market_maps: + if self.market_closes is not None or isinstance(self.market_data, dict): + close_map, high_map, low_map, _idx, _symbols = _normalize_symbol_data( + data=self.market_data, + closes=self.market_closes, + highs=self.market_highs, + lows=self.market_lows, + datetime_index=self.market_datetime_index, + symbols=symbol_list, + ) + else: + frame = _standardize_frame(self.market_data, datetime_index=self.market_datetime_index) + symbol = symbol_list[0] + close_map = {symbol: frame["close"]} + high_map = {symbol: frame.get("high", frame["close"])} + low_map = {symbol: frame.get("low", frame["close"])} + self._single_market_maps[key] = (close_map, high_map, low_map) + return self._single_market_maps[key] + def _portfolio_maps(self, symbol_list): key = tuple(symbol_list) if key not in self._portfolio_market_maps: diff --git a/engines.py b/engines.py index f97530b..f689b91 100644 --- a/engines.py +++ b/engines.py @@ -389,6 +389,7 @@ def __init__( slot_size: Optional[Union[float, Dict[str, float]]] = None, min_qty: Optional[Union[float, Dict[str, float]]] = None, min_notional: Optional[Union[float, Dict[str, float]]] = None, + report_level: str = "full", auto_run: bool = True, **kwargs, ): @@ -418,6 +419,7 @@ def __init__( self.slot_size = slot_size self.min_qty = min_qty self.min_notional = min_notional + self.report_level = report_level self.kwargs = kwargs self.portfolio: Optional[MultiSymbolPortfolio] = None self.result: Optional[BacktestResultV2] = None @@ -494,6 +496,7 @@ def run(self) -> BacktestResultV2: execution=self.execution, fee_rate=fee_oneway, use_funding=bool(self.use_funding), + report_level=self.report_level, ) ) self.result = backend.run_signals( @@ -519,6 +522,7 @@ def run(self) -> BacktestResultV2: slot_size=self.slot_size, min_qty=self.min_qty, min_notional=self.min_notional, + report_level=self.report_level, ) return self.result diff --git a/tests/test_phase14_service_loop_benchmark.py b/tests/test_phase14_service_loop_benchmark.py new file mode 100644 index 0000000..0b4fd81 --- /dev/null +++ b/tests/test_phase14_service_loop_benchmark.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from quantbt.benchmarks.run_phase14_service_loop import make_markdown, run_benchmark + + +def test_phase14_service_loop_benchmark_smoke_and_parity_guards(): + report = run_benchmark(rows=120, symbols=2, trials=2, order_count=24, repeats=1) + + assert report["status"] == "pass" + assert set(report["service_loops"]) == { + "single_symbol_wfo", + "portfolio_wfo", + "native_event_replay", + "arbitrage_package_sweep", + "report_heavy_vs_light", + } + assert all(report["parity"].values()) + assert report["decomposition"]["native_vectorized"]["stages"] + assert report["decomposition"]["native_event"]["stages"] + assert report["decomposition"]["native_portfolio"]["stages"]["pure_numba_kernel_seconds"] > 0.0 + assert "Cython/C++ is not justified yet" in report["cython_cpp_recommendation"] + + +def test_phase14_service_loop_markdown_is_stakeholder_readable(): + report = run_benchmark(rows=96, symbols=2, trials=2, order_count=16, repeats=1) + markdown = make_markdown(report) + + assert "Phase 14C Prepared Cache And Report-Level Benchmark" in markdown + assert "Service Loop Timings" in markdown + assert "Stage Decomposition" in markdown + assert "Parity Guards" in markdown + assert "Cython/C++ Decision" in markdown diff --git a/tests/test_phase14c_prepared_report_levels.py b/tests/test_phase14c_prepared_report_levels.py new file mode 100644 index 0000000..25b2672 --- /dev/null +++ b/tests/test_phase14c_prepared_report_levels.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from quantbt import ( + AccountConfig, + ArbExecutionPolicy, + ArbitrageLeg, + BasisArbitrageSpec, + ContractType, + ExecutionConfig, + HedgePolicy, + HedgePolicyKind, + NativeEventBackend, + NativeEventConfig, + NativePortfolioBackend, + NativePortfolioConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, + PackageExecutionKind, + QuantBTEndpoint, + SizingPolicy, + SizingPolicyKind, +) + + +def test_native_vectorized_prepared_signal_notional_matches_normal_run_and_rejects_stale_signature(): + idx = pd.date_range("2024-01-01", periods=10, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.sin(np.linspace(0.0, 2.0, len(idx))), index=idx) + signal = pd.Series([0.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.5, 0.5, 0.0, 0.0], index=idx) + closes = {"BTC": close} + highs = {"BTC": close * 1.002} + lows = {"BTC": close * 0.998} + positions = {"BTC": signal} + backend = NativeVectorizedBackend( + NativeVectorizedConfig( + account=AccountConfig(initial_capital=20_000.0, leverage=4.0), + execution=ExecutionConfig(slippage_bps=1.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + + normal = backend.run_signals( + idx, + positions, + closes, + highs=highs, + lows=lows, + symbols=["BTC"], + alloc_per_trade=5_000.0, + hedge_type="signal_notional", + ) + market = backend.prepare_market_arrays(idx, closes=closes, highs=highs, lows=lows, symbols=["BTC"]) + prepared = backend.run_signals( + idx, + positions, + closes, + highs=highs, + lows=lows, + symbols=["BTC"], + alloc_per_trade=5_000.0, + hedge_type="signal_notional", + market_arrays=market, + ) + + np.testing.assert_allclose(prepared.equity.to_numpy(), normal.equity.to_numpy(), rtol=0.0, atol=1e-10) + np.testing.assert_allclose(prepared.positions.to_numpy(), normal.positions.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(prepared.fees.to_numpy(), normal.fees.to_numpy(), rtol=0.0, atol=1e-12) + + with pytest.raises(ValueError, match="prepared market arrays"): + backend.run_signals( + idx[:-1], + {"BTC": signal.iloc[:-1]}, + closes, + highs=highs, + lows=lows, + symbols=["BTC"], + alloc_per_trade=5_000.0, + hedge_type="signal_notional", + market_arrays=market, + ) + + +def test_native_portfolio_report_levels_preserve_accounting_core_and_keep_full_default_audit(): + idx, positions, closes = _portfolio_fixture() + backend = NativePortfolioBackend( + NativePortfolioConfig( + account=AccountConfig(initial_capital=100_000.0, leverage=5.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + + kwargs = dict( + positions=positions, + closes=closes, + highs=closes, + lows=closes, + datetime_index=idx, + symbols=list(positions), + mode="market_neutral", + hedge_type="notional", + alloc_per_trade={"BTC": 2_000.0, "ETH": 2_000.0}, + use_pyramiding=True, + funding_rate=0.0, + ) + full = backend.run_signals(**kwargs) + standard = backend.run_signals(**kwargs, report_level="standard") + minimal = backend.run_signals(**kwargs, report_level="minimal") + + for result in (standard, minimal): + np.testing.assert_allclose(result.equity.to_numpy(), full.equity.to_numpy(), rtol=0.0, atol=1e-10) + np.testing.assert_allclose(result.returns.to_numpy(), full.returns.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(result.positions.to_numpy(), full.positions.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(result.fees.to_numpy(), full.fees.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(result.funding.to_numpy(), full.funding.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(result.margin.to_numpy(), full.margin.to_numpy(), rtol=0.0, atol=1e-10) + + assert full.metadata["report_level"] == "full" + assert full.metadata["portfolio_contract_report"]["passed"] is True + assert "rebalance_report" in full.metadata + assert standard.metadata["report_level"] == "standard" + assert standard.metadata["portfolio_contract_report"]["passed"] is True + assert "symbol_pnl_report" in standard.metadata + assert "rebalance_report" not in standard.metadata + assert minimal.metadata["report_level"] == "minimal" + assert minimal.metadata["portfolio_contract_report"]["status"] == "skipped" + assert "symbol_pnl_report" not in minimal.metadata + assert "accepted_units_report" in minimal.metadata + + +def test_walkforward_single_symbol_endpoint_scoring_reuses_prepared_vectorized_market_arrays_without_metric_drift(): + idx = pd.date_range("2021-01-01", "2022-03-31", freq="1D", tz="UTC") + close = 100.0 + np.cumsum(np.sin(np.linspace(0.0, 8.0, len(idx))) * 0.05) + data = pd.DataFrame( + { + "open": close, + "high": close * 1.002, + "low": close * 0.998, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + + def strategy(data, params, train_index, test_index, fold): + threshold = float(params["threshold"]) + ret = data.loc[: test_index[-1], "close"].pct_change().fillna(0.0) + signal = np.where(ret > threshold / 10_000.0, 1.0, np.where(ret < -threshold / 10_000.0, -1.0, 0.0)) + return pd.Series(signal, index=ret.index).reindex(test_index).fillna(0.0) + + def run(use_cache: bool): + endpoint = QuantBTEndpoint.train_test_split( + strategy_class=strategy, + test_start="2022-01-01", + target_mode="signal_notional", + backend="native_vectorized", + optimization_mode="mode_1_decay", + optimization_config={ + "scoring_backend": "endpoint", + "use_prepared_scoring_cache": use_cache, + }, + optuna_trials=6, + random_seed=77, + initial_capital=20_000.0, + leverage=3.0, + alloc_per_trade=5_000.0, + fee_rate=0.0001, + use_funding=False, + use_pyramiding=False, + ) + return endpoint.backtest(data=data, param_ranges={"threshold": (0.2, 1.0, 0.2)}) + + cached = run(True) + uncached = run(False) + cached_wf = cached.metadata["walk_forward"] + uncached_wf = uncached.metadata["walk_forward"] + cache_meta = cached_wf["prepared_scoring_cache"] + + assert cached_wf["params"] == uncached_wf["params"] + assert cached_wf["best_trial"]["objective"] == pytest.approx(uncached_wf["best_trial"]["objective"]) + assert cached.equity.iloc[-1] == pytest.approx(uncached.equity.iloc[-1]) + assert cache_meta["available"] is True + assert cache_meta["prepared_runs"] > 0 + assert cache_meta["market_cache_misses"] > 0 + assert uncached_wf["prepared_scoring_cache"]["prepared_runs"] == 0 + + +def test_native_event_basis_arbitrage_accepts_prepared_market_arrays_with_equity_parity(): + idx = pd.date_range("2024-01-01", periods=12, freq="1h", tz="UTC") + perp = pd.Series(100.0 + np.sin(np.linspace(0.0, 3.0, len(idx))), index=idx) + quarterly = pd.Series(perp.to_numpy() + 1.0 + np.cos(np.linspace(0.0, 2.0, len(idx))) * 0.25, index=idx) + closes = {"PERP": perp, "QUARTERLY": quarterly} + signal = pd.Series([0.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0], index=idx) + spec = BasisArbitrageSpec( + arb_id="PHASE14C_BASIS", + legs=( + ArbitrageLeg("PERP", 1.0, role="perp", contract_type=ContractType.LINEAR, funding_enabled=True), + ArbitrageLeg("QUARTERLY", -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="PERP", + ), + execution_policy=ArbExecutionPolicy(PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + funding = {"PERP": pd.Series(0.00005, index=idx), "QUARTERLY": 0.0} + backend = NativeEventBackend( + NativeEventConfig(account=AccountConfig(initial_capital=50_000.0, leverage=5.0), fee_rate=0.0001, use_funding=True) + ) + + normal = backend.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding) + market = backend.prepare_market_arrays(idx, closes=closes, highs=closes, lows=closes, funding_rate=funding, symbols=list(closes)) + prepared = backend.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding, market_arrays=market) + + np.testing.assert_allclose(prepared.equity.to_numpy(), normal.equity.to_numpy(), rtol=0.0, atol=1e-10) + np.testing.assert_allclose(prepared.positions.to_numpy(), normal.positions.to_numpy(), rtol=0.0, atol=1e-12) + assert prepared.metadata["package_pnl_report"].equals(normal.metadata["package_pnl_report"]) + + with pytest.raises(ValueError, match="prepared market arrays"): + backend.run_basis_arbitrage(idx[:-1], spec, signal.iloc[:-1], closes, funding_rate=funding, market_arrays=market) + + +def _portfolio_fixture(): + idx = pd.date_range("2024-01-01", periods=8, freq="1h", tz="UTC") + positions = { + "BTC": pd.Series([0.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 0.0], index=idx), + "ETH": pd.Series([0.0, -1.0, -1.0, 0.0, 1.0, 1.0, 0.0, 0.0], index=idx), + } + closes = { + "BTC": pd.Series([100.0, 101.0, 102.0, 101.5, 100.5, 99.0, 100.0, 101.0], index=idx), + "ETH": pd.Series([50.0, 49.5, 49.0, 50.0, 51.0, 52.0, 51.0, 50.5], index=idx), + } + return idx, positions, closes diff --git a/upgrade/implement.md b/upgrade/implement.md index cc65751..7e2f147 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -2000,14 +2000,15 @@ Latest certification summary: - Nautilus package smoke: pass with supported Binance test instruments. This validates adapter package replay, not a real quarterly venue model. -Remaining debt: +Debt ledger after Phase 13: -- Stat-arb pair should eventually emit the same `package_pnl_report` residual - artifact as basis/index-basket routes. -- Real exchange quarterly/perpetual basis parity requires a Nautilus instrument - provider or adapter extension for delivery futures. -- Cross-exchange, triangular, and options-vol arbitrage remain schema-safe, - specialized-engine future work. +- Completed by Phase 13C: `StatArbPairSpec` now emits `leg_pnl_report`, + `package_pnl_report`, `spread_report`, and residual accounting artifacts + matching the basis/index-basket report style. +- Remaining: real exchange quarterly/perpetual basis parity requires a Nautilus + instrument provider or adapter extension for delivery futures. +- Remaining: cross-exchange, triangular, and options-vol arbitrage remain + schema-safe specialized-engine future work. Scope: @@ -2077,14 +2078,22 @@ Latest certification and optimization summary: - All-or-none basket package preflight parity passes using the current OHLCV-volume-cap depth model. -Remaining debt: - -- Thread prepared portfolio market arrays through higher-level WFO endpoint - loops where the market tape is invariant across parameter trials. -- Continue optimizing native portfolio report construction, which remains the - measured largest residual bucket after the first vectorized report pass. -- Replace OHLCV queue/depth approximation with true L2/order-book simulation - only when a production venue data feed is available. +Debt ledger after Phase 13: + +- Completed by Phase 13A: prepared portfolio market arrays are threaded through + the higher-level WFO endpoint scoring loop for `target_mode="portfolio"` and + `backend="native_portfolio"`. +- Completed by Phase 13B: native portfolio report construction has been + optimized and parity-locked against the previous pandas formulas. +- Completed by Phase 14C: prepared-array reuse now covers single-symbol + `signal_notional` WFO scoring, native-event order-package replay, and + supported arbitrage package replays. +- Completed by Phase 14C: native portfolio has opt-in `report_level` controls + while `full` remains the default stakeholder/audit surface. +- Remaining: pandas normalization overhead still exists in facade layers. +- Remaining: true L2/order-book replay requires venue depth data. Until then, + QuantBT can only provide OHLCV approximation and synthetic-book simulation for + domain testing, not production L2 accuracy claims. Scope: @@ -2351,6 +2360,345 @@ Those belong to a later dedicated Nautilus Depth phase. --- +## Phase 14 - Performance Debt And Plan Hygiene + +Goal: + +Close the remaining performance/accounting bookkeeping debt without weakening +the current priority order: + +```text +domain correctness > accounting transparency > report quality > speed +``` + +Do not move to Cython/C++ in this phase. The current profiling evidence still +shows Python/report construction and data normalization as the larger buckets, +while pure Numba kernels remain a small share of full facade runtime. + +### Phase 14A - Plan Hygiene, Debt Ledger, And Certification Matrix + +Purpose: + +Make `upgrade/implement.md` accurate after Phase 13 so future agents do not +mistake historical debt for unfinished current scope. + +Scope: + +- Mark stale Phase 12 debt as completed where Phase 13 already closed it: + - stat-arb `package_pnl_report` completed by Phase 13C; + - WFO prepared portfolio arrays completed by Phase 13A; + - native portfolio report optimization completed by Phase 13B. +- Preserve remaining debt with sharper wording: + - prepared cache beyond portfolio WFO was completed later in Phase 14C; + - optional report levels were completed later in Phase 14C; + - facade-level pandas normalization remains measurable; + - true L2 replay needs venue order-book data. +- Add a certification matrix covering current production/readiness state by + backend and strategy family. +- Add the 5-phase roadmap for the next implementation wave. +- Do not modify engine behavior in this phase. + +Certification matrix after Phase 13: + +| Area | Current status | Certification level | Remaining gate | +|---|---|---|---| +| Single-symbol native vectorized | supported | research/production usable for target-position backtests | real-strategy regression bundles as needed | +| Single-symbol native event | supported | research/production usable for explicit order OHLC backtests | deeper stop/conditional edge cases when needed | +| Native portfolio | default for supported modes | production-ready for supported sizing/modes with parity tests | more real alpha bundles and full-report presentation polish | +| Native arbitrage basis/stat/calendar/funding/carry/index | supported | controlled research usable with audit artifacts | more real data packages and venue-specific cases | +| Cross-exchange arbitrage | schema-only | not executable | specialized multi-venue engine | +| Triangular arbitrage | schema-only | not executable | sequenced path engine | +| Options vol arbitrage | schema-only | not executable | option/Greeks/IV engine | +| Nautilus signal validation | supported | trustee validation for supported single-symbol instruments | tolerance bundles per venue profile | +| Nautilus explicit orders | supported | trustee validation for explicit order replay | real report bundles | +| Nautilus DCA/grid/bracket | experimental structured package | validation/stress-test usable | in-strategy state machine and native OCO semantics | +| Nautilus basket/portfolio/arbitrage packages | experimental validation | smoke/parity artifact usable | real package bundles and deeper tolerance profiles | +| OHLCV depth preflight | supported opt-in | deterministic stress model | not a true L2 claim | +| Synthetic book simulation | planned Phase 15B | domain-test model only | not production-certified without real L2 data | +| Real L2 replay | future | requires venue data | order-book snapshots/incremental updates/trades/latency | + +Status: + +- Phase 14A completed as documentation/plan hygiene only. +- Stale Phase 12 debt entries have been updated into explicit completed vs + remaining debt ledgers. +- The certification matrix above is now the source of truth for what is + supported, experimental, schema-only, and future. +- No production code or engine behavior changed in Phase 14A. + +### Phase 14B - Real WFO And Service-Loop Benchmark + +Purpose: + +Measure the real remaining performance bottlenecks before any further +optimization. This benchmark must reflect how QuantBT is used in notebooks, +services, WFO, Optuna sweeps, explicit order replays, portfolio runs, and +arbitrage package loops. + +Scope: + +- Add a benchmark script and committed JSON/Markdown report for: + - single-symbol WFO; + - native-event explicit-order replay loops; + - native-portfolio WFO; + - arbitrage package sweeps; + - report-heavy vs report-light execution. +- Decompose runtime into: + - pandas normalization; + - ndarray packing; + - strategy callback / signal generation; + - target sizing; + - order-array construction; + - pure Numba kernel; + - result/report construction; + - metrics/report export. +- Track: + - bars; + - symbols; + - folds; + - trials; + - order count; + - event count; + - memory; + - compile/warmup vs repeated runtime. + +Acceptance: + +- Benchmark output separates pure kernel runtime from facade/report runtime. +- Benchmark includes parity guards proving benchmark variants do not alter core + equity, positions, fees, funding, margin, liquidation, or package PnL. +- Report explicitly states whether Cython/C++ is justified. Default expectation + remains "not yet" unless pure kernels become the bottleneck. + +Status: + +- Implemented `benchmarks/run_phase14_service_loop.py`. +- Added committed benchmark artifacts: + - `benchmarks/phase14_service_loop.json`; + - `benchmarks/phase14_service_loop.md`. +- Added regression coverage in `tests/test_phase14_service_loop_benchmark.py`. +- Current benchmark command: + +```bash +python benchmarks/run_phase14_service_loop.py \ + --rows 360 \ + --symbols 4 \ + --trials 4 \ + --order-count 120 \ + --repeats 2 +``` + +- Latest benchmark status: `pass`. +- Parity guards all pass: + - single-symbol WFO; + - portfolio WFO cached vs uncached; + - native-event cold vs prepared replay; + - arbitrage package event vs vectorized sweep; + - heavy metrics report vs light core summary. +- Latest measured bottlenecks: + - native vectorized: `data_normalization` at about `54.04%`; + - native event: `data_normalization` at about `38.32%`; + - native portfolio: `report_construction_estimate` at about `79.55%`; + - pure Numba kernel share remains below `1%` across the measured native + vectorized/event/portfolio paths. +- Service-loop report now includes `tracemalloc` peak-memory measurements per + workload so runtime and allocation pressure can be interpreted together. +- Conclusion: Cython/C++ is not justified yet. Phase 14C should prioritize + prepared-array reuse in single-symbol/event/arbitrage loops and optional lazy + heavy reports while preserving full report defaults. + +### Phase 14C - Prepared Cache And Lazy Heavy Reports + +Purpose: + +Reduce repeated Python/pandas overhead in service and WFO loops while keeping +full stakeholder-grade reporting available and unchanged by default. + +Scope: + +- Extend prepared-array reuse beyond portfolio WFO: + - single-symbol WFO endpoint scoring where market tape is invariant; + - native-event explicit-order package loops; + - arbitrage package repeated runs. +- Add optional report controls without changing defaults: + - `report_level="full"` keeps current behavior; + - `report_level="standard"` keeps key audit metrics but avoids selected heavy + expansions; + - `report_level="minimal"` keeps equity/returns/positions/core diagnostics for + optimizer/service loops. +- Prepared caches must remain run-local or explicitly caller-owned, with + datetime/symbol signatures and no mutable global result cache. + +Acceptance: + +- Full/standard/minimal report levels have identical core accounting: + - equity; + - returns; + - positions; + - fees; + - funding; + - margin; + - liquidation; + - package PnL where applicable. +- Signature mismatch rejects prepared reuse clearly. +- Prepared arrays are explicit copied snapshots; source data mutation requires + rebuilding the prepared object, while stale index/symbol layouts are rejected. +- Existing endpoint calls remain backward compatible because `full` is default. + +Status: + +- Implemented `NativeVectorizedBackend.prepare_market_arrays(...)` and optional + prepared-market replay for `run_signals(..., hedge_type="signal_notional")`. +- Extended WFO endpoint scoring cache beyond portfolio: + - single-symbol `target_mode="signal_notional"` with `backend="native_vectorized"`; + - portfolio `target_mode="portfolio"` with `backend="native_portfolio"` remains + supported; + - cache metadata is attached at + `result.metadata["walk_forward"]["prepared_scoring_cache"]`. +- Added `prepared_scoring_report_level`, default `minimal`, so portfolio WFO + objective scoring avoids heavy stakeholder reports per trial while the final + stitched backtest still uses endpoint `report_level` default `full`. +- Added `report_level` to native portfolio: + - `full`: default, unchanged audit surface; + - `standard`: keeps exposure, accepted/target notional, funding rates, and + symbol PnL but omits selected expansions; + - `minimal`: keeps accounting-critical result surfaces for optimizer/service + loops and marks contract validation as skipped. +- Extended native-event package routes to accept caller-owned prepared market + arrays for basket, basis arbitrage, stat-arb pair, and generic supported + package arbitrage replay. +- Updated Phase 14 benchmark artifact to measure: + - single-symbol WFO cached vs uncached; + - portfolio WFO cached vs uncached; + - native-event explicit-order prepared replay; + - arbitrage package cold vs prepared event replay; + - native-portfolio `full` vs `minimal` report construction. +- Added regression coverage: + - `tests/test_phase14c_prepared_report_levels.py`; + - updated `tests/test_phase14_service_loop_benchmark.py`. +- Documentation updated: + - `docs/endpoint.md`; + - `docs/portfolio_engine_v3.md`. + +Safety notes: + +- Existing endpoint behavior is unchanged unless `report_level` or prepared + APIs are explicitly used. +- Prepared arrays are copied snapshots with datetime/symbol signature guards, + not mutable global caches. If OHLC/funding values are changed intentionally, + rebuild the prepared arrays before replaying. +- Phase 14C does not implement true L2/order-book simulation and does not alter + margin, fill, sizing, funding, or PnL kernels. + +## Phase 15 - Nautilus Certification And Depth + +Goal: + +Promote Nautilus from a strong validation backend into a cleaner institutional +evidence layer, while being honest about what can and cannot be claimed without +real venue data. + +### Phase 15A - Nautilus Real Certification Bundles + +Purpose: + +Produce stakeholder-ready Nautilus report bundles for representative real or +realistic workflows. + +Scope: + +- Run and archive report bundles for: + - single-symbol `%_equity`; + - explicit orders; + - basket package; + - portfolio package; + - basis/stat-arb package where supported instruments exist. +- Export: + - config; + - account timeline; + - orders; + - fills; + - positions; + - native-vs-Nautilus parity; + - tolerance profile; + - known differences. +- Add tolerance profiles for: + - fill price; + - fee; + - slippage; + - equity; + - quantity rounding. + +Acceptance: + +- Skips cleanly if `nautilus-trader` or required instrument support is missing. +- Passes and writes JSON/Markdown/CSV bundles when dependencies are available. +- Injected mismatch tests prove parity reports catch fill-price/equity/quantity + differences. + +### Phase 15B - Nautilus Depth, Synthetic Book, And Specialized Arbitrage Plan + +Purpose: + +Define and implement the next depth layer carefully, without claiming exchange +fidelity beyond the available data. + +L2 / book simulation levels: + +- Level 1: OHLCV approximation. Already available via depth preflight: + volume cap, queue-ahead approximation, latency-bar shifting, partial-fill + approximation, and deterministic package rejection. +- Level 2: synthetic order-book simulation. This can be implemented without + real L2 data by generating a book from assumptions such as spread, depth + slope, volatility, volume participation, and queue-ahead. It is useful for + domain tests and conservative stress scenarios, but must not be described as + venue-realistic execution. +- Level 3: real L2 replay. This requires venue order-book snapshots, incremental + depth updates, trade prints, timestamps, and latency assumptions. Only this + level can support true queue-priority and order-book execution claims. + +Nautilus depth scope: + +- Dynamic DCA/grid state machine inside Nautilus strategy: + - submit base; + - activate safety orders only after base fill; + - update TP/SL quantity as ladder fills; + - prevent blind submission of all future orders. +- OCO/bracket semantics: + - 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. + +Specialized arbitrage scope: + +- Keep supported native arbitrage routes as-is: + - basis; + - stat-arb pair; + - calendar spread; + - funding arbitrage; + - spot-perp cash carry; + - index basket. +- Add explicit future engine plans: + - `CrossExchangeArbEngine`: multi-venue account, latency, transfer/borrow + constraints, venue-specific fees and settlement. + - `TriangularArbEngine`: sequenced path execution, path slippage, partial-fill + propagation, and inventory drift. + - `OptionsVolArbEngine`: option instruments, IV surface, Greeks, expiry, + assignment/exercise, and delta hedge behavior. + +Acceptance: + +- Synthetic-book tests prove depth model invariants without requiring private + venue data. +- Real L2 tests skip clearly when no L2 provider is configured. +- Schema-only arbitrage specs remain rejected until their specialized engine has + accounting audit, parity tests, and docs. + +--- + ## Backend Selection Guide Use `native_vectorized` when: