diff --git a/.gitignore b/.gitignore index 83e7bd6..e98db2f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ ENV/ Thumbs.db upgrade/ -benchmarks/ \ No newline at end of file +benchmarks/ +.local_arbitrage_sandboxes/ diff --git a/backends/native_portfolio.py b/backends/native_portfolio.py index ce52878..6e8d3af 100644 --- a/backends/native_portfolio.py +++ b/backends/native_portfolio.py @@ -26,12 +26,19 @@ normalize_portfolio_sizing_mode, validate_portfolio_result_contract, ) -from ..core.preprocessor import align_series, build_market_arrays, build_signal_matrix, prepare_funding, validate_datetime +from ..core.preprocessor import ( + PreparedMarketArrays, + align_series, + build_market_arrays, + build_signal_matrix, + market_data_signature, + prepare_funding, + validate_datetime, +) from ..core.results import BacktestResultV2 from ..core.schema import AccountConfig from ..core.schema import ExecutionConfig from ..sizing.fast import scale_signal_notional_matrix -from ..sizing.modes import compute_target_units @dataclass(frozen=True) @@ -60,7 +67,7 @@ def __init__(self, config: NativePortfolioConfig): def run_signals( self, - positions: Dict[str, pd.Series], + positions: Optional[Dict[str, pd.Series]], closes: Dict[str, pd.Series], datetime_index: Union[pd.DatetimeIndex, pd.Series], *, @@ -78,11 +85,18 @@ def run_signals( asset_type: str = "crypto", betas: Optional[Union[float, Dict[str, float]]] = None, risk_lookback: int = 60, + market_arrays: Optional[PreparedMarketArrays] = None, + raw_signal_matrix: Optional[np.ndarray] = None, ) -> BacktestResultV2: idx = validate_datetime(datetime_index) - symbol_list = list(symbols) if symbols is not None else list(positions.keys()) - if set(symbol_list) != set(positions.keys()) or set(symbol_list) != set(closes.keys()): - raise ValueError("symbols, positions, and closes must contain the same keys") + if positions is None and raw_signal_matrix is None: + raise ValueError("positions or raw_signal_matrix is required") + position_keys = set(positions.keys()) if positions is not None else set() + symbol_list = list(symbols) if symbols is not None else list(positions.keys() if positions is not None else closes.keys()) + if positions is not None and set(symbol_list) != position_keys: + raise ValueError("symbols and positions must contain the same keys") + if market_arrays is None and set(symbol_list) != set(closes.keys()): + raise ValueError("symbols and closes must contain the same keys") portfolio_mode = normalize_portfolio_mode(mode) sizing_mode = normalize_portfolio_sizing_mode(hedge_type) @@ -91,13 +105,27 @@ def run_signals( f"native_portfolio does not yet support equity-dependent sizing mode {hedge_type!r}" ) - 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) - pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0) - funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) - market = build_market_arrays(symbol_list, idx, close_dict, high_dict, low_dict, funding_dict) - raw_signals = build_signal_matrix(symbol_list, idx, pos_dict) + if market_arrays is None: + market = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + symbols=symbol_list, + ) + elif market_arrays.signature != market_data_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + else: + market = market_arrays + + if raw_signal_matrix is None: + pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0) + raw_signals = build_signal_matrix(symbol_list, idx, pos_dict) + else: + raw_signals = np.ascontiguousarray(raw_signal_matrix, dtype=np.float64) + if raw_signals.shape != market.closes.shape: + raise ValueError("raw_signal_matrix shape does not match prepared market arrays") cs_arr = self._per_symbol_array(contract_size, symbol_list, default=1.0) lev_arr = self._per_symbol_array( @@ -151,9 +179,6 @@ def run_signals( sizing_mode=sizing_mode, raw_signals=raw_signals, closes=market.closes, - idx=idx, - symbols=symbol_list, - close_dict=close_dict, alloc_arr=alloc_arr, contract_sizes=cs_arr, use_pyramiding=use_pyramiding, @@ -219,15 +244,52 @@ def run_signals( result.metadata["portfolio_contract_report"] = validate_portfolio_result_contract(result, spec, tolerance=1e-8) return result + 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, Dict[str, float], pd.Series, None] = 0.0, + symbols: Optional[Sequence[str]] = None, + ) -> PreparedMarketArrays: + """ + Normalize portfolio market data once for WFO/service loops. + + The returned object is immutable ndarray-backed market state with a + datetime/symbol signature. `run_signals` rejects stale reuse against a + different index or symbol order, avoiding identity-cache bugs. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) if symbols is not None else 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(symbol_list, idx, close_dict, high_dict, low_dict, funding_dict) + + @staticmethod + def prepare_signal_matrix( + positions: Dict[str, pd.Series], + datetime_index: Union[pd.DatetimeIndex, pd.Series], + symbols: Sequence[str], + ) -> np.ndarray: + """ + Normalize a portfolio signal matrix once when replaying prepared data. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) + if set(symbol_list) != set(positions.keys()): + raise ValueError("symbols and positions must contain the same keys") + pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0) + return build_signal_matrix(symbol_list, idx, pos_dict) + @staticmethod def _scale_target_units( *, sizing_mode: str, raw_signals: np.ndarray, closes: np.ndarray, - idx: pd.DatetimeIndex, - symbols: List[str], - close_dict: Dict[str, pd.Series], alloc_arr: np.ndarray, contract_sizes: np.ndarray, use_pyramiding: bool, @@ -235,10 +297,29 @@ def _scale_target_units( if sizing_mode in ("signal_notional", "signal"): return scale_signal_notional_matrix(raw_signals, closes, alloc_arr, use_pyramiding=use_pyramiding) + sig = raw_signals if use_pyramiding else np.sign(raw_signals) + denom = closes * contract_sizes.reshape(1, -1) + + if sizing_mode == "notional": + notionals = sig * alloc_arr.reshape(1, -1) + return np.ascontiguousarray( + np.divide(notionals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0), + dtype=np.float64, + ) + + if sizing_mode == "unit": + first_denom = denom[0:1, :] + scale = np.divide( + alloc_arr.reshape(1, -1), + first_denom, + out=np.zeros((1, raw_signals.shape[1]), dtype=np.float64), + where=first_denom != 0.0, + ) + return np.ascontiguousarray(sig * scale, dtype=np.float64) + if sizing_mode == "target_units": return np.ascontiguousarray(raw_signals, dtype=np.float64) - denom = closes * contract_sizes.reshape(1, -1) if sizing_mode == "target_notional": return np.ascontiguousarray( np.divide(raw_signals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0), @@ -246,24 +327,13 @@ def _scale_target_units( ) if sizing_mode == "fixed_notional": - sig = raw_signals if use_pyramiding else np.sign(raw_signals) notionals = sig * alloc_arr.reshape(1, -1) return np.ascontiguousarray( np.divide(notionals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0), dtype=np.float64, ) - out = np.zeros_like(raw_signals, dtype=np.float64) - for j, symbol in enumerate(symbols): - signal = pd.Series(raw_signals[:, j], index=idx) - out[:, j] = compute_target_units( - hedge_type=sizing_mode, - signal=signal, - close=close_dict[symbol], - alloc=float(alloc_arr[j]), - use_pyramiding=use_pyramiding, - ).to_numpy(dtype=np.float64) - return np.ascontiguousarray(out, dtype=np.float64) + raise NotImplementedError(f"native_portfolio sizing mode {sizing_mode!r} is not vectorized") @staticmethod def _apply_mode( @@ -355,36 +425,38 @@ def _build_result( liquidation_bar: int, ) -> BacktestResultV2: equity = pd.Series(equity_arr, index=idx, name="equity") - close_report = pd.DataFrame({s: closes_m[:, j] for j, s in enumerate(symbol_list)}, index=idx) - target_units_report = pd.DataFrame({s: target_m[:, j] for j, s in enumerate(symbol_list)}, index=idx) - accepted_units_report = pd.DataFrame({s: pos_arr[:, j] for j, s in enumerate(symbol_list)}, index=idx) + 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) + accepted_units_report = pd.DataFrame(pos_arr, index=idx, columns=symbol_list, copy=False) cs = pd.Series({s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)}) lev = pd.Series({s: float(leverages[j]) for j, s in enumerate(symbol_list)}) beta_s = pd.Series({s: float(betas[j]) for j, s in enumerate(symbol_list)}) - target_notional = target_units_report.mul(close_report, axis=0).mul(cs, axis=1) - accepted_notional = accepted_units_report.mul(close_report, axis=0).mul(cs, axis=1) - funding_rates = pd.DataFrame({s: funding_m[:, j] for j, s in enumerate(symbol_list)}, index=idx) - risk_vol_report = pd.DataFrame({s: risk_vol[:, j] for j, s in enumerate(symbol_list)}, index=idx) + cs_row = contract_sizes.reshape(1, -1) + target_notional_arr = target_m * closes_m * cs_row + 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=accepted_notional, - target_notional=target_notional, + accepted_notional_arr=accepted_notional_arr, + target_notional_arr=target_notional_arr, equity=equity, - leverages=lev, + leverages=leverages, maintenance_ratio=maintenance_ratio, - betas=beta_s, + betas=betas, ) - risk_contribution_report = accepted_notional.abs().mul(risk_vol_report, axis=0) + 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=accepted_units_report, - closes=close_report, - funding_rates=funding_rates, - is_funding_bar=pd.Series(is_funding_bar, index=idx), - contract_sizes=cs, - fee_rate=float(self.config.fee_rate), + 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( @@ -394,8 +466,8 @@ def _build_result( contract_sizes=cs, ) - positions = pd.DataFrame({f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbol_list)}, index=idx) - closes = pd.DataFrame({f"Close_{s}": closes_m[:, j] for j, s in enumerate(symbol_list)}, index=idx) + 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) fees = pd.Series(fee_arr, index=idx, name="fees") turnover = pd.Series(turnover_arr, index=idx, name="turnover") funding_cost = symbol_pnl_report.groupby("timestamp", sort=False)["funding_cost"].sum().reindex(idx, fill_value=0.0) @@ -439,7 +511,7 @@ def _build_result( "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({s: sym_pnl_arr[:, j] for j, s in enumerate(symbol_list)}, index=idx), + "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, @@ -455,85 +527,83 @@ def _build_symbol_pnl_report( *, idx: pd.DatetimeIndex, symbols: List[str], - accepted_units: pd.DataFrame, - closes: pd.DataFrame, - funding_rates: pd.DataFrame, - is_funding_bar: pd.Series, - contract_sizes: pd.Series, - fee_rate: float, + accepted_units_arr: np.ndarray, + closes_arr: np.ndarray, + funding_rates_arr: np.ndarray, + is_funding_bar: np.ndarray, + contract_sizes: np.ndarray, fee_arr: np.ndarray, ) -> pd.DataFrame: - frames = [] - funding_mask = is_funding_bar.astype(bool) - raw_trade_notional = {} - total_trade_notional = pd.Series(0.0, index=idx) - for symbol in symbols: - units = accepted_units[symbol].astype(float) - close = closes[symbol].astype(float) - cs = float(contract_sizes[symbol]) - trade_notional = units.diff().fillna(units).abs() * close * cs - raw_trade_notional[symbol] = trade_notional - total_trade_notional = total_trade_notional.add(trade_notional, fill_value=0.0) - fee_series = pd.Series(fee_arr, index=idx, dtype=float) - for symbol in symbols: - units = accepted_units[symbol].astype(float) - close = closes[symbol].astype(float) - prev_units = units.shift(1).fillna(0.0) - prev_close = close.shift(1).fillna(close) - cs = float(contract_sizes[symbol]) - mark_pnl = prev_units * (close - prev_close) * cs - funding_cost = prev_units * close * cs * funding_rates[symbol].astype(float) - funding_cost = funding_cost.where(funding_mask, 0.0) - share = raw_trade_notional[symbol].divide(total_trade_notional.replace(0.0, np.nan)).fillna(0.0) - fee = fee_series * share - total_pnl = mark_pnl - funding_cost - fee - frames.append( - pd.DataFrame( - { - "timestamp": idx, - "symbol": symbol, - "position_units": units.to_numpy(dtype=float), - "close": close.to_numpy(dtype=float), - "mark_pnl": mark_pnl.to_numpy(dtype=float), - "funding_cost": funding_cost.to_numpy(dtype=float), - "funding_pnl": (-funding_cost).to_numpy(dtype=float), - "fee": fee.to_numpy(dtype=float), - "fee_pnl": (-fee).to_numpy(dtype=float), - "total_pnl": total_pnl.to_numpy(dtype=float), - } - ) - ) - return pd.concat(frames, ignore_index=True, copy=False) if frames else pd.DataFrame() + n_bars, n_syms = accepted_units_arr.shape + if n_bars == 0 or n_syms == 0: + return pd.DataFrame() + prev_units = np.vstack([np.zeros((1, n_syms), dtype=np.float64), accepted_units_arr[:-1]]) + prev_close = np.vstack([closes_arr[0:1], closes_arr[:-1]]) + cs = contract_sizes.reshape(1, -1) + mark_pnl = prev_units * (closes_arr - prev_close) * cs + funding_cost = prev_units * closes_arr * cs * funding_rates_arr + funding_cost = np.where(is_funding_bar.reshape(-1, 1).astype(bool), funding_cost, 0.0) + trade_delta = np.abs(accepted_units_arr - prev_units) + trade_notional = trade_delta * closes_arr * cs + total_trade_notional = trade_notional.sum(axis=1, keepdims=True) + share = np.divide( + trade_notional, + total_trade_notional, + out=np.zeros_like(trade_notional), + where=total_trade_notional != 0.0, + ) + fee = fee_arr.reshape(-1, 1) * share + total_pnl = mark_pnl - funding_cost - fee + + return pd.DataFrame( + { + "timestamp": np.tile(np.asarray(idx, dtype=object), n_syms), + "symbol": np.repeat(np.asarray(symbols, dtype=object), n_bars), + "position_units": accepted_units_arr.T.reshape(-1), + "close": closes_arr.T.reshape(-1), + "mark_pnl": mark_pnl.T.reshape(-1), + "funding_cost": funding_cost.T.reshape(-1), + "funding_pnl": (-funding_cost).T.reshape(-1), + "fee": fee.T.reshape(-1), + "fee_pnl": (-fee).T.reshape(-1), + "total_pnl": total_pnl.T.reshape(-1), + } + ) @staticmethod def _build_exposure_report( *, - accepted_notional: pd.DataFrame, - target_notional: pd.DataFrame, + accepted_notional_arr: np.ndarray, + target_notional_arr: np.ndarray, equity: pd.Series, - leverages: pd.Series, + leverages: np.ndarray, maintenance_ratio: float, - betas: pd.Series, + betas: np.ndarray, ) -> pd.DataFrame: - abs_accepted = accepted_notional.abs() - initial_margin = abs_accepted.div(leverages, axis=1).sum(axis=1) - maintenance_margin = abs_accepted.sum(axis=1) * float(maintenance_ratio) - beta_exposure = accepted_notional.mul(betas, axis=1).sum(axis=1) - target_beta_exposure = target_notional.mul(betas, axis=1).sum(axis=1) + abs_accepted = np.abs(accepted_notional_arr) + equity_arr = equity.to_numpy(dtype=np.float64) + gross = abs_accepted.sum(axis=1) + net = accepted_notional_arr.sum(axis=1) + initial_margin = (abs_accepted / leverages.reshape(1, -1)).sum(axis=1) + maintenance_margin = gross * float(maintenance_ratio) + beta_exposure = (accepted_notional_arr * betas.reshape(1, -1)).sum(axis=1) + target_gross = np.abs(target_notional_arr).sum(axis=1) + target_beta_exposure = (target_notional_arr * betas.reshape(1, -1)).sum(axis=1) + mean_leverage = float(np.mean(leverages)) out = pd.DataFrame( { - "long_notional": accepted_notional.clip(lower=0.0).sum(axis=1), - "short_notional": accepted_notional.clip(upper=0.0).abs().sum(axis=1), - "gross_notional": abs_accepted.sum(axis=1), - "net_notional": accepted_notional.sum(axis=1), + "long_notional": np.where(accepted_notional_arr > 0.0, accepted_notional_arr, 0.0).sum(axis=1), + "short_notional": np.where(accepted_notional_arr < 0.0, -accepted_notional_arr, 0.0).sum(axis=1), + "gross_notional": gross, + "net_notional": net, "beta_exposure_notional": beta_exposure, - "target_gross_notional": target_notional.abs().sum(axis=1), + "target_gross_notional": target_gross, "target_beta_exposure_notional": target_beta_exposure, "initial_margin": initial_margin, "maintenance_margin": maintenance_margin, - "equity": equity, - "available_equity_after_im": equity - initial_margin, - "buying_power": equity * float(np.mean(leverages.to_numpy(dtype=float))), + "equity": equity_arr, + "available_equity_after_im": equity_arr - initial_margin, + "buying_power": equity_arr * mean_leverage, }, index=equity.index, ) diff --git a/benchmarks/phase12_arbitrage_cert.json b/benchmarks/phase12_arbitrage_cert.json new file mode 100644 index 0000000..7038537 --- /dev/null +++ b/benchmarks/phase12_arbitrage_cert.json @@ -0,0 +1,154 @@ +{ + "status": "pass", + "sandbox_path": "/root/bobby/pool_alpha/quantbt/.local_arbitrage_sandboxes/binance_basis_arb", + "basis": { + "event_final_equity": 91362.95065353338, + "vectorized_final_equity": 91362.95065353338, + "order_count": 828, + "fill_count": 828, + "fee_total": 3429.5969336746557, + "funding_total": 17.731103806709445, + "audit": { + "status": "pass", + "passed": true, + "tolerance": 1e-09, + "engine": "event_v1_basis_arbitrage", + "backend": "native_event", + "arb_id": "PHASE12_BTC_PERP_QUARTERLY", + "arb_type": "basis", + "missing_reports": [], + "checks": { + "has_required_reports": true, + "package_pnl_residual_ok": true, + "leg_pnl_reconciles_to_package": true, + "fees_reconcile": true, + "target_symbols_match": true, + "final_target_flat": true, + "final_position_flat": true + }, + "max_abs_package_pnl_residual": 2.6261659513693303e-11, + "max_abs_leg_vs_package_pnl_diff": 0.0, + "max_abs_fee_residual": 4.547473508864641e-13, + "final_gross_target_units": 0.0, + "final_gross_position_units": 8.881784197001252e-16, + "target_symbols": [ + "perpetual", + "quarterly" + ], + "result_symbols": [ + "perpetual", + "quarterly" + ], + "order_count": 828, + "fill_count": 828, + "rejection_count": 0 + }, + "parity": { + "status": "pass", + "passed": true, + "tolerance": 1e-09, + "event_engine": "event_v1_basis_arbitrage", + "vectorized_engine": "units_v2_basis_arbitrage", + "checks": { + "equity_matches": true, + "positions_match": true, + "target_units_match": true, + "package_residuals_ok": true + }, + "max_abs_equity_diff": 1.4551915228366852e-11, + "max_abs_position_diff": 4.440892098500626e-16, + "max_abs_target_unit_diff": 0.0, + "max_abs_package_residual": 2.6261659513693303e-11 + } + }, + "stat_pair": { + "event_final_equity": 98806.77550546748, + "vectorized_final_equity": 98806.77550546748, + "accounting_parity_passed": true, + "parity": { + "status": "fail", + "passed": false, + "tolerance": 1e-09, + "event_engine": "event_v1_stat_arb_pair", + "vectorized_engine": "units_v2_stat_arb_pair", + "checks": { + "equity_matches": true, + "positions_match": true, + "target_units_match": true, + "package_residuals_ok": false + }, + "max_abs_equity_diff": 0.0, + "max_abs_position_diff": 0.0, + "max_abs_target_unit_diff": 0.0, + "max_abs_package_residual": null + } + }, + "index_basket": { + "status": "pass", + "passed": true, + "parity": { + "status": "pass", + "passed": true, + "tolerance": 1e-09, + "event_engine": "event_v1_index_basket", + "vectorized_engine": "units_v2_index_basket", + "checks": { + "equity_matches": true, + "positions_match": true, + "target_units_match": true, + "package_residuals_ok": true + }, + "max_abs_equity_diff": 0.0, + "max_abs_position_diff": 0.0, + "max_abs_target_unit_diff": 0.0, + "max_abs_package_residual": 1.1126211063583469e-11 + } + }, + "schema_only": { + "status": "pass", + "passed": true, + "probes": { + "cross_exchange": { + "native_event": { + "rejected": true, + "error": "NotImplementedError", + "message": "CrossExchangeArbSpec requires a specialized Phase G+ engine" + }, + "native_vectorized": { + "rejected": true, + "error": "NotImplementedError", + "message": "CrossExchangeArbSpec requires a specialized Phase G+ engine" + } + }, + "triangular": { + "native_event": { + "rejected": true, + "error": "NotImplementedError", + "message": "TriangularArbSpec requires a specialized Phase G+ engine" + }, + "native_vectorized": { + "rejected": true, + "error": "NotImplementedError", + "message": "TriangularArbSpec requires a specialized Phase G+ engine" + } + }, + "options_vol": { + "native_event": { + "rejected": true, + "error": "NotImplementedError", + "message": "OptionsVolArbSpec requires a specialized Phase G+ engine" + }, + "native_vectorized": { + "rejected": true, + "error": "NotImplementedError", + "message": "OptionsVolArbSpec requires a specialized Phase G+ engine" + } + } + } + }, + "nautilus": { + "status": "pass", + "orders": 828, + "fills": 828 + } +} diff --git a/benchmarks/phase12_arbitrage_cert.md b/benchmarks/phase12_arbitrage_cert.md new file mode 100644 index 0000000..668ce25 --- /dev/null +++ b/benchmarks/phase12_arbitrage_cert.md @@ -0,0 +1,23 @@ +# Phase 12A Arbitrage Production Certification + +Status: **pass** +Sandbox path: `/root/bobby/pool_alpha/quantbt/.local_arbitrage_sandboxes/binance_basis_arb` + +## Basis Perp-Quarterly + +- Event final equity: `91362.950654` +- Vectorized final equity: `91362.950654` +- Max equity diff: `1.4551915228366852e-11` +- Audit status: `pass` +- Orders: `828` +- Fills: `828` +- Fees: `3429.596934` +- Funding: `17.731104` + +## Other Certification Checks + +- Stat pair accounting parity: `True` +- Stat pair package-residual report: `False` +- Index basket package smoke: `pass` +- Schema-only guardrails: `pass` +- Nautilus package parity: `pass` diff --git a/benchmarks/phase12_benchmark_nautilus_cert.json b/benchmarks/phase12_benchmark_nautilus_cert.json new file mode 100644 index 0000000..83e93ef --- /dev/null +++ b/benchmarks/phase12_benchmark_nautilus_cert.json @@ -0,0 +1,52 @@ +{ + "status": "pass", + "benchmark_followup": { + "status": "pass", + "rows": 2000, + "symbols": 6, + "bar_symbols": 12000, + "repeats": 3, + "stages": { + "full_facade_seconds": 0.08143977168947458, + "prepared_reuse_facade_seconds": 0.07379568073277672, + "array_preparation_seconds": 0.010897138544047872, + "pure_numba_kernel_seconds": 0.00021187355741858482, + "report_construction_estimate_seconds": 0.07033075958800812, + "prepared_reuse_speedup": 1.1035845307041487, + "array_preparation_share_pct": 13.380610379898986, + "pure_kernel_share_pct": 0.26015981261151755, + "report_construction_share_pct": 86.35922980748948 + }, + "notes": "Prepared-array cache targets WFO/service loops. Pure Numba kernel remains separated from pandas normalization and report construction." + }, + "all_or_none_basket": { + "status": "pass", + "input_orders": 2, + "accepted_orders": 0, + "rejected_orders": 2, + "package_status": [ + "rejected" + ], + "depth_model": "ohlcv_volume_cap" + }, + "nautilus_portfolio": { + "status": "pass", + "validation_status": "pass", + "checks": { + "input_mode_is_portfolio_matrix": true, + "target_units_match": true, + "order_count_matches_target_transitions": true, + "fills_do_not_exceed_orders": true, + "positions_within_tolerance": true, + "final_equity_within_tolerance": true + }, + "equity_tolerance": 1.0, + "position_tolerance": 0.005, + "expected_order_count": 14, + "nautilus_orders": 14, + "nautilus_fills": 14, + "final_equity_diff": 0.00784839857078623, + "max_abs_position_diff": 0.004 + }, + "cython_cpp_recommendation": "Cython/C++ is not justified yet; optimize cached array preparation and report construction first." +} diff --git a/benchmarks/phase12_benchmark_nautilus_cert.md b/benchmarks/phase12_benchmark_nautilus_cert.md new file mode 100644 index 0000000..ee5fa6e --- /dev/null +++ b/benchmarks/phase12_benchmark_nautilus_cert.md @@ -0,0 +1,37 @@ +# Phase 12B Benchmark And Nautilus Portfolio Certification + +Status: **pass** + +## Benchmark Follow-Up + +- Bars: `2000` +- Symbols: `6` +- Repeats: `3` +- Full facade seconds: `0.081440` +- Prepared reuse facade seconds: `0.073796` +- Prepared reuse speedup: `1.104x` +- Array preparation seconds: `0.010897` +- Pure Numba kernel seconds: `0.000212` +- Report construction residual seconds: `0.070331` +- Pure kernel share: `0.26%` + +## Nautilus Portfolio + +- Status: `pass` +- Validation status: `pass` +- Equity tolerance profile: `1.0` +- Position tolerance profile: `0.005` +- Final equity diff: `0.00784839857078623` +- Max position diff: `0.004` + +## All-Or-None Basket + +- Status: `pass` +- Input orders: `2` +- Accepted orders: `0` +- Rejected orders: `2` +- Depth model: `ohlcv_volume_cap` + +## Cython/C++ Decision + +Cython/C++ is not justified yet; optimize cached array preparation and report construction first. diff --git a/benchmarks/run_phase12_arbitrage_cert.py b/benchmarks/run_phase12_arbitrage_cert.py new file mode 100644 index 0000000..2f5ede3 --- /dev/null +++ b/benchmarks/run_phase12_arbitrage_cert.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +Phase 12A arbitrage production-certification smoke runner. + +The runner uses deterministic realistic market data inspired by the local +Arbops Binance basis-arb alpha, but it does not import or commit that private +alpha. The copied alpha sandbox, if present, must live under +`.local_arbitrage_sandboxes/` and is intentionally git-ignored. +""" + +from __future__ import annotations + +import argparse +import json +import sys +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, + CrossExchangeArbSpec, + ExecutionConfig, + HedgePolicy, + HedgePolicyKind, + IndexBasketArbSpec, + NativeEventBackend, + NativeEventConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, + OptionsVolArbSpec, + PackageExecutionKind, + SignalModel, + SignalModelKind, + SizingPolicy, + SizingPolicyKind, + StatArbPairSpec, + TriangularArbSpec, + build_arbitrage_order_plan, + compare_native_arbitrage_results, + build_arbitrage_domain_audit, +) + + +def generate_basis_market(rows: int = 900, seed: int = 42): + rng = np.random.default_rng(seed) + idx = pd.date_range("2023-01-01", periods=rows, freq="1h", tz="UTC") + base_ret = rng.normal(0.0, 0.006, size=rows) + perp = 25_000.0 * np.exp(np.cumsum(base_ret)) + basis = 550.0 * np.exp(-np.linspace(0.0, 4.5, rows)) + 65.0 * np.sin(np.linspace(0.0, 18.0, rows)) + basis += rng.normal(0.0, 18.0, size=rows) + quarterly = np.maximum(perp + basis, 1.0) + spread = pd.Series(perp - quarterly, index=idx) + z = (spread - spread.rolling(48, min_periods=12).mean()) / spread.rolling(48, min_periods=12).std() + signal = pd.Series(np.where(z > 1.0, 1.0, np.where(z < -1.0, -1.0, 0.0)), index=idx).ffill().fillna(0.0) + # Force a terminal flat state so audit can verify package flattening. + signal.iloc[-3:] = 0.0 + + closes = { + "perpetual": pd.Series(perp, index=idx), + "quarterly": pd.Series(quarterly, index=idx), + } + highs = {symbol: series * 1.002 for symbol, series in closes.items()} + lows = {symbol: series * 0.998 for symbol, series in closes.items()} + funding = { + "perpetual": pd.Series(0.00004 + rng.normal(0.0, 0.00001, size=rows), index=idx), + "quarterly": pd.Series(0.0, index=idx), + } + return idx, signal, closes, highs, lows, funding + + +def basis_spec() -> BasisArbitrageSpec: + return BasisArbitrageSpec( + arb_id="PHASE12_BTC_PERP_QUARTERLY", + legs=( + ArbitrageLeg( + "perpetual", + 1.0, + role="perp", + contract_type=ContractType.LINEAR, + contract_size=1.0, + funding_enabled=True, + ), + ArbitrageLeg( + "quarterly", + -1.0, + role="quarterly", + contract_type=ContractType.LINEAR, + contract_size=1.0, + funding_enabled=False, + ), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy( + kind=SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=20_000.0, + reference_symbol="perpetual", + ), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + + +def stat_spec() -> StatArbPairSpec: + return StatArbPairSpec( + arb_id="PHASE12_STAT_PAIR", + legs=( + ArbitrageLeg("asset_a", 1.0, role="base", contract_type=ContractType.LINEAR), + ArbitrageLeg("asset_b", -1.0, role="hedge", contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=30_000.0), + signal_model=SignalModel(kind=SignalModelKind.ZSCORE), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + + +def run_certification(rows: int = 900, include_nautilus: bool = False) -> Dict: + idx, signal, closes, highs, lows, funding = generate_basis_market(rows=rows) + account = AccountConfig(initial_capital=100_000.0, leverage=8.0, maintenance_ratio=0.005) + execution = ExecutionConfig(slippage_bps=0.0) + event = NativeEventBackend(NativeEventConfig(account=account, execution=execution, fee_rate=0.0002, use_funding=True)) + vector = NativeVectorizedBackend(NativeVectorizedConfig(account=account, execution=execution, fee_rate=0.0002, use_funding=True)) + + spec = basis_spec() + event_result = event.run_basis_arbitrage(idx, spec, signal, closes, highs=highs, lows=lows, funding_rate=funding) + vector_result = vector.run_basis_arbitrage(idx, spec, signal, closes, highs=highs, lows=lows, funding_rate=funding) + basis_audit = build_arbitrage_domain_audit(event_result, raise_on_fail=False) + basis_parity = compare_native_arbitrage_results(event_result, vector_result, raise_on_fail=False) + + stat_idx, stat_signal, stat_closes, stat_highs, stat_lows, stat_funding = _stat_market(rows=rows) + stat_event = event.run_stat_arb_pair_arbitrage(stat_idx, stat_spec(), stat_signal, stat_closes, highs=stat_highs, lows=stat_lows, funding_rate=stat_funding) + stat_vector = vector.run_stat_arb_pair_arbitrage(stat_idx, stat_spec(), stat_signal, stat_closes, highs=stat_highs, lows=stat_lows, funding_rate=stat_funding) + stat_parity = compare_native_arbitrage_results(stat_event, stat_vector, raise_on_fail=False) + + basket_report = _index_basket_smoke(event, vector, rows) + schema_report = _schema_only_report(event, vector, idx, signal, closes) + nautilus_report = _optional_nautilus_report(idx, spec, signal, closes, include_nautilus) + + passed = bool( + basis_audit["passed"] + and basis_parity["passed"] + and _accounting_parity_passed(stat_parity) + and basket_report["passed"] + and schema_report["passed"] + and nautilus_report["status"] in {"pass", "skipped"} + ) + return { + "status": "pass" if passed else "fail", + "sandbox_path": str(PACKAGE_DIR / ".local_arbitrage_sandboxes" / "binance_basis_arb"), + "basis": _result_summary(event_result, vector_result, basis_audit, basis_parity), + "stat_pair": { + "event_final_equity": float(stat_event.equity.iloc[-1]), + "vectorized_final_equity": float(stat_vector.equity.iloc[-1]), + "accounting_parity_passed": _accounting_parity_passed(stat_parity), + "parity": stat_parity, + }, + "index_basket": basket_report, + "schema_only": schema_report, + "nautilus": nautilus_report, + } + + +def make_markdown(report: Dict) -> str: + basis = report["basis"] + lines = [ + "# Phase 12A Arbitrage Production Certification", + "", + f"Status: **{report['status']}**", + f"Sandbox path: `{report['sandbox_path']}`", + "", + "## Basis Perp-Quarterly", + "", + f"- Event final equity: `{basis['event_final_equity']:.6f}`", + f"- Vectorized final equity: `{basis['vectorized_final_equity']:.6f}`", + f"- Max equity diff: `{basis['parity']['max_abs_equity_diff']}`", + f"- Audit status: `{basis['audit']['status']}`", + f"- Orders: `{basis['order_count']}`", + f"- Fills: `{basis['fill_count']}`", + f"- Fees: `{basis['fee_total']:.6f}`", + f"- Funding: `{basis['funding_total']:.6f}`", + "", + "## Other Certification Checks", + "", + f"- Stat pair accounting parity: `{report['stat_pair']['accounting_parity_passed']}`", + f"- Stat pair package-residual report: `{report['stat_pair']['parity']['checks'].get('package_residuals_ok')}`", + f"- Index basket package smoke: `{report['index_basket']['status']}`", + f"- Schema-only guardrails: `{report['schema_only']['status']}`", + f"- Nautilus package parity: `{report['nautilus']['status']}`", + ] + return "\n".join(lines) + "\n" + + +def _stat_market(rows: int): + idx = pd.date_range("2023-01-01", periods=rows, freq="1h", tz="UTC") + t = np.linspace(0.0, 12.0, rows) + a = 100.0 + np.cumsum(np.sin(t) * 0.05 + 0.1) + b = 50.0 + np.cumsum(np.sin(t + 0.4) * 0.025 + 0.05) + spread = pd.Series(a - 2.0 * b, index=idx) + z = (spread - spread.rolling(36, min_periods=12).mean()) / spread.rolling(36, min_periods=12).std() + signal = pd.Series(np.where(z > 1.0, -1.0, np.where(z < -1.0, 1.0, 0.0)), index=idx).fillna(0.0) + signal.iloc[-3:] = 0.0 + closes = {"asset_a": pd.Series(a, index=idx), "asset_b": pd.Series(b, index=idx)} + highs = {s: c * 1.001 for s, c in closes.items()} + lows = {s: c * 0.999 for s, c in closes.items()} + funding = {s: pd.Series(0.0, index=idx) for s in closes} + return idx, signal, closes, highs, lows, funding + + +def _index_basket_smoke(event: NativeEventBackend, vector: NativeVectorizedBackend, rows: int) -> Dict: + idx = pd.date_range("2023-01-01", periods=rows, freq="1h", tz="UTC") + closes = { + "ETF": pd.Series(100.0 + np.linspace(0.0, 3.0, rows), index=idx), + "A": pd.Series(30.0 + np.linspace(0.0, 1.0, rows), index=idx), + "B": pd.Series(70.0 + np.linspace(0.0, 2.0, rows), index=idx), + } + signal = pd.Series(0.0, index=idx) + signal.iloc[20: rows // 2] = 1.0 + signal.iloc[-3:] = 0.0 + spec = IndexBasketArbSpec( + arb_id="PHASE12_INDEX_BASKET", + legs=(ArbitrageLeg("ETF", -1.0), ArbitrageLeg("A", 1.0), ArbitrageLeg("B", 1.0)), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.NOTIONAL_NEUTRAL, freeze_on_entry=True), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=30_000.0), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + event_result = event.run_package_arbitrage(idx, spec, signal, closes) + vector_result = vector.run_package_arbitrage(idx, spec, signal, closes) + parity = compare_native_arbitrage_results(event_result, vector_result, raise_on_fail=False) + return {"status": "pass" if parity["passed"] else "fail", "passed": bool(parity["passed"]), "parity": parity} + + +def _schema_only_report(event: NativeEventBackend, vector: NativeVectorizedBackend, idx, signal, closes) -> Dict: + probes = {} + specs = { + "cross_exchange": CrossExchangeArbSpec( + arb_id="X", + legs=( + ArbitrageLeg("BINANCE_BTCUSDT", 1.0, venue="BINANCE"), + ArbitrageLeg("OKX_BTCUSDT", -1.0, venue="OKX"), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.NOTIONAL_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=10_000.0), + ), + "triangular": TriangularArbSpec( + arb_id="T", + legs=( + ArbitrageLeg("BTCUSDT", 1.0, base_currency="BTC", quote_currency="USDT"), + ArbitrageLeg("ETHBTC", 1.0, base_currency="ETH", quote_currency="BTC"), + ArbitrageLeg("ETHUSDT", -1.0, base_currency="ETH", quote_currency="USDT"), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.NOTIONAL_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=10_000.0), + ), + "options_vol": OptionsVolArbSpec( + arb_id="O", + legs=( + ArbitrageLeg("BTC_CALL", 1.0, contract_type=ContractType.OPTION), + ArbitrageLeg("BTC_PERP", -0.5, contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.VEGA_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=10_000.0), + ), + } + for name, spec in specs.items(): + backend_rejections = {} + for backend_name, backend in (("native_event", event), ("native_vectorized", vector)): + try: + backend.run_package_arbitrage(idx, spec, signal, closes) + backend_rejections[backend_name] = {"rejected": False, "error": None} + except NotImplementedError as exc: + backend_rejections[backend_name] = {"rejected": True, "error": type(exc).__name__, "message": str(exc)} + probes[name] = backend_rejections + passed = all( + all(route["rejected"] for route in backend_rejections.values()) + for backend_rejections in probes.values() + ) + return {"status": "pass" if passed else "fail", "passed": passed, "probes": probes} + + +def _optional_nautilus_report(idx, spec, signal, closes, include_nautilus: bool) -> Dict: + if not include_nautilus: + return {"status": "skipped", "reason": "run with --include-nautilus"} + try: + from quantbt import QuantBTEndpoint + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt_symbols = ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE") + nt_spec = BasisArbitrageSpec( + arb_id="PHASE12_NAUTILUS_PACKAGE_SMOKE", + legs=( + ArbitrageLeg(nt_symbols[0], 1.0, role="perp", contract_type=ContractType.LINEAR), + ArbitrageLeg(nt_symbols[1], -1.0, role="quarterly", contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy( + kind=SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=50_000.0, + reference_symbol=nt_symbols[0], + ), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + metadata={"certification_note": "Nautilus supported-instrument package smoke; not a quarterly venue model."}, + ) + source_series = [closes["perpetual"], closes["quarterly"]] + data = { + symbol: pd.DataFrame( + { + "open": close, + "close": close, + "high": close * 1.001, + "low": close * 0.999, + "volume": 10_000.0, + }, + index=idx, + ) + for symbol, close in zip(nt_symbols, source_series) + } + endpoint = QuantBTEndpoint.arbitrage( + arb_type="basis", + spec=nt_spec, + backend="nautilus", + initial_capital=100_000.0, + leverage=8.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig(timeframe="1h", instrument_id=nt_symbols[0], bypass_risk=True), + ) + result = endpoint.simulate(data=data, signal=signal, symbols=list(nt_symbols)) + return {"status": "pass", "orders": int(result.metadata.get("orders_count", 0)), "fills": int(result.metadata.get("fills_count", 0))} + except Exception as exc: + return {"status": "skipped", "reason": f"{type(exc).__name__}: {exc}"} + + +def _result_summary(event_result, vector_result, audit, parity): + return { + "event_final_equity": float(event_result.equity.iloc[-1]), + "vectorized_final_equity": float(vector_result.equity.iloc[-1]), + "order_count": int(len(event_result.metadata.get("order_report", []))), + "fill_count": int(len(event_result.fills)), + "fee_total": float(event_result.fees.sum()), + "funding_total": float(event_result.funding.sum()), + "audit": audit, + "parity": parity, + } + + +def _accounting_parity_passed(parity: Dict) -> bool: + checks = parity.get("checks", {}) + return bool( + checks.get("equity_matches") + and checks.get("positions_match") + and checks.get("target_units_match") + ) + + +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=900) + parser.add_argument("--include-nautilus", action="store_true") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_arbitrage_cert.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_arbitrage_cert.md") + args = parser.parse_args(argv) + report = run_certification(rows=args.rows, include_nautilus=args.include_nautilus) + 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, 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/benchmarks/run_phase12_benchmark_nautilus_cert.py b/benchmarks/run_phase12_benchmark_nautilus_cert.py new file mode 100644 index 0000000..9dc0060 --- /dev/null +++ b/benchmarks/run_phase12_benchmark_nautilus_cert.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +""" +Phase 12B benchmark follow-up and Nautilus portfolio certification runner. + +The runner keeps production claims narrow and auditable: + +* benchmark stages separate full facade time from array preparation, pure + Numba portfolio kernel time, and report-construction residual time; +* Nautilus portfolio validation is optional because it depends on the external + NautilusTrader package and venue adapter state; +* all-or-none basket package semantics are certified through the deterministic + QuantBT depth preflight used before Nautilus package replay. +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +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, + NautilusExecutionDepthConfig, + OrderIntent, + OrderSide, + OrderType, + PortfolioBacktestEngine, + QuantBTEndpoint, + TimeInForce, + simulate_nautilus_order_package_depth, +) +from quantbt.backends import NativePortfolioBackend, NativePortfolioConfig # noqa: E402 +from quantbt.core.engine import _engine_portfolio # noqa: E402 +from quantbt.core.preprocessor import align_series, build_market_arrays, build_signal_matrix, prepare_funding, validate_datetime # noqa: E402 +from quantbt.sizing.fast import scale_signal_notional_matrix # noqa: E402 + + +def run_certification( + *, + rows: int = 2_000, + symbols: int = 6, + repeats: int = 3, + include_nautilus: bool = False, +) -> Dict: + benchmark = _benchmark_native_portfolio(rows=rows, symbols=symbols, repeats=repeats) + all_or_none = _all_or_none_basket_depth_smoke() + nautilus = _optional_real_nautilus_portfolio(include_nautilus=include_nautilus) + passed = ( + benchmark["status"] == "pass" + and all_or_none["status"] == "pass" + and nautilus["status"] in {"pass", "skipped", "diff"} + ) + return { + "status": "pass" if passed else "fail", + "benchmark_followup": benchmark, + "all_or_none_basket": all_or_none, + "nautilus_portfolio": nautilus, + "cython_cpp_recommendation": _cython_cpp_recommendation(benchmark), + } + + +def make_markdown(report: Dict) -> str: + bench = report["benchmark_followup"] + lines = [ + "# Phase 12B Benchmark And Nautilus Portfolio Certification", + "", + f"Status: **{report['status']}**", + "", + "## Benchmark Follow-Up", + "", + f"- Bars: `{bench['rows']}`", + f"- Symbols: `{bench['symbols']}`", + f"- Repeats: `{bench['repeats']}`", + f"- Full facade seconds: `{bench['stages']['full_facade_seconds']:.6f}`", + f"- Prepared reuse facade seconds: `{bench['stages']['prepared_reuse_facade_seconds']:.6f}`", + f"- Prepared reuse speedup: `{bench['stages']['prepared_reuse_speedup']:.3f}x`", + f"- Array preparation seconds: `{bench['stages']['array_preparation_seconds']:.6f}`", + f"- Pure Numba kernel seconds: `{bench['stages']['pure_numba_kernel_seconds']:.6f}`", + f"- Report construction residual seconds: `{bench['stages']['report_construction_estimate_seconds']:.6f}`", + f"- Pure kernel share: `{bench['stages']['pure_kernel_share_pct']:.2f}%`", + "", + "## Nautilus Portfolio", + "", + f"- Status: `{report['nautilus_portfolio']['status']}`", + f"- Validation status: `{report['nautilus_portfolio'].get('validation_status')}`", + f"- Equity tolerance profile: `{report['nautilus_portfolio'].get('equity_tolerance')}`", + f"- Position tolerance profile: `{report['nautilus_portfolio'].get('position_tolerance')}`", + f"- Final equity diff: `{report['nautilus_portfolio'].get('final_equity_diff')}`", + f"- Max position diff: `{report['nautilus_portfolio'].get('max_abs_position_diff')}`", + "", + "## All-Or-None Basket", + "", + f"- Status: `{report['all_or_none_basket']['status']}`", + f"- Input orders: `{report['all_or_none_basket']['input_orders']}`", + f"- Accepted orders: `{report['all_or_none_basket']['accepted_orders']}`", + f"- Rejected orders: `{report['all_or_none_basket']['rejected_orders']}`", + f"- Depth model: `{report['all_or_none_basket']['depth_model']}`", + "", + "## Cython/C++ Decision", + "", + report["cython_cpp_recommendation"], + ] + return "\n".join(lines) + "\n" + + +def _benchmark_native_portfolio(rows: int, symbols: int, repeats: int) -> Dict: + idx, positions, closes, highs, lows = _make_portfolio_fixture(rows, symbols) + account = AccountConfig(initial_capital=250_000.0, leverage=5.0, maintenance_ratio=0.005) + alloc = 10_000.0 + fee_rate = 0.0002 + fee_oneway = fee_rate / 2.0 + + def full_facade(): + return PortfolioBacktestEngine( + positions=positions, + closes=closes, + highs=highs, + lows=lows, + datetime_index=idx, + mode="longshort", + backend="native_portfolio", + account=account, + fee_rate=fee_rate, + alloc_per_trade=alloc, + hedge_type="signal_notional", + use_funding=False, + ).result + + backend = NativePortfolioBackend(NativePortfolioConfig(account=account, fee_rate=fee_oneway, use_funding=False)) + symbol_list = list(positions.keys()) + prepared_market = backend.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=0.0, + symbols=symbol_list, + ) + prepared_signals = backend.prepare_signal_matrix(positions, idx, symbol_list) + + def prepared_reuse(): + return backend.run_signals( + positions=None, + closes=closes, + highs=highs, + lows=lows, + datetime_index=idx, + mode="longshort", + alloc_per_trade=alloc, + contract_size=1.0, + hedge_type="signal_notional", + funding_rate=0.0, + leverage=account.leverage, + maintenance_ratio=account.maintenance_ratio, + symbols=symbol_list, + use_pyramiding=True, + market_arrays=prepared_market, + raw_signal_matrix=prepared_signals, + ) + + prepared = _prepare_portfolio_arrays(idx, positions, closes, highs, lows, account, alloc, fee_oneway) + _kernel_portfolio(prepared) + full_facade() + prepared_reuse() + + prep_seconds = _timeit(lambda: _prepare_portfolio_arrays(idx, positions, closes, highs, lows, account, alloc, fee_oneway), repeats) + kernel_seconds = _timeit(lambda: _kernel_portfolio(prepared), repeats) + full_seconds = _timeit(full_facade, repeats) + prepared_reuse_seconds = _timeit(prepared_reuse, repeats) + report_seconds = max(0.0, full_seconds - prep_seconds - kernel_seconds) + status = "pass" if full_seconds > 0.0 and kernel_seconds > 0.0 else "fail" + return { + "status": status, + "rows": int(rows), + "symbols": int(symbols), + "bar_symbols": int(rows * symbols), + "repeats": int(repeats), + "stages": { + "full_facade_seconds": float(full_seconds), + "prepared_reuse_facade_seconds": float(prepared_reuse_seconds), + "array_preparation_seconds": float(prep_seconds), + "pure_numba_kernel_seconds": float(kernel_seconds), + "report_construction_estimate_seconds": float(report_seconds), + "prepared_reuse_speedup": float(full_seconds / prepared_reuse_seconds) if prepared_reuse_seconds > 0.0 else 0.0, + "array_preparation_share_pct": float(prep_seconds / full_seconds * 100.0) if full_seconds > 0.0 else 0.0, + "pure_kernel_share_pct": float(kernel_seconds / full_seconds * 100.0) if full_seconds > 0.0 else 0.0, + "report_construction_share_pct": float(report_seconds / full_seconds * 100.0) if full_seconds > 0.0 else 0.0, + }, + "notes": ( + "Prepared-array cache targets WFO/service loops. Pure Numba kernel " + "remains separated from pandas normalization and report construction." + ), + } + + +def _optional_real_nautilus_portfolio(include_nautilus: bool) -> Dict: + if not include_nautilus: + return {"status": "skipped", "reason": "run with --include-nautilus"} + try: + from quantbt.adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine + + NautilusBacktestEngine.check_available() + idx, raw_positions, raw_closes, raw_highs, raw_lows = _make_portfolio_fixture(rows=96, symbols=2) + symbols = ["BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE"] + raw_symbols = list(raw_positions.keys()) + positions = {symbols[i]: raw_positions[raw_symbols[i]] for i in range(2)} + closes = {symbols[i]: raw_closes[raw_symbols[i]] for i in range(2)} + highs = {symbols[i]: raw_highs[raw_symbols[i]] for i in range(2)} + lows = {symbols[i]: raw_lows[raw_symbols[i]] for i in range(2)} + data = { + symbol: pd.DataFrame( + { + "open": closes[symbol], + "high": highs[symbol], + "low": lows[symbol], + "close": closes[symbol], + "volume": 1_000.0, + }, + index=idx, + ) + for symbol in symbols + } + endpoint = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="nautilus", + initial_capital=100_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + hedge_type="signal_notional", + alloc_per_trade={symbols[0]: 1_000_000.0, symbols[1]: 750_000.0}, + metadata={ + "portfolio_nautilus_equity_tolerance": 1.0, + "portfolio_nautilus_position_tolerance": 0.005, + }, + nautilus_config=NautilusBackendConfig(instrument_id=symbols[0], timeframe="1h", bypass_risk=True), + ) + result = endpoint.simulate(data=data, positions=pd.DataFrame(positions), symbols=symbols) + validation = result.metadata.get("portfolio_nautilus_validation_report", {}) + return { + "status": "pass" if validation.get("status") == "pass" else "diff", + "validation_status": validation.get("status"), + "checks": validation.get("checks", {}), + "equity_tolerance": validation.get("equity_tolerance"), + "position_tolerance": validation.get("position_tolerance"), + "expected_order_count": validation.get("expected_order_count"), + "nautilus_orders": validation.get("nautilus_orders"), + "nautilus_fills": validation.get("nautilus_fills"), + "final_equity_diff": validation.get("final_equity_diff"), + "max_abs_position_diff": validation.get("max_abs_position_diff"), + } + except Exception as exc: + return {"status": "skipped", "reason": f"{type(exc).__name__}: {exc}"} + + +def _all_or_none_basket_depth_smoke() -> Dict: + idx = pd.date_range("2024-01-01", periods=4, freq="1h", tz="UTC") + data = { + "BTC": _depth_frame(idx, close=100.0, high=101.0, low=95.0), + "ETH": _depth_frame(idx, close=50.0, high=51.0, low=49.0), + } + meta = {"package_id": "PHASE12-BASKET", "package_type": "basket_package"} + orders = ( + OrderIntent(idx[1], "BTC", OrderSide.BUY, OrderType.LIMIT, qty=1.0, price=96.0, tif=TimeInForce.GTC, metadata=meta), + OrderIntent(idx[1], "ETH", OrderSide.BUY, OrderType.LIMIT, qty=1.0, price=45.0, tif=TimeInForce.GTC, metadata=meta), + ) + result = simulate_nautilus_order_package_depth( + orders, + data, + NautilusExecutionDepthConfig(all_or_none_packages=True), + ) + package_status = result.package_report["status"].tolist() if not result.package_report.empty else [] + passed = len(result.orders) == 0 and result.metadata.get("rejected_orders") == 2 and package_status == ["rejected"] + return { + "status": "pass" if passed else "fail", + "input_orders": int(result.metadata.get("input_orders", len(orders))), + "accepted_orders": int(result.metadata.get("accepted_orders", len(result.orders))), + "rejected_orders": int(result.metadata.get("rejected_orders", 0)), + "package_status": package_status, + "depth_model": result.metadata.get("depth_model"), + } + + +def _make_portfolio_fixture(rows: int, symbols: int, symbol_prefix: str = "SYM"): + idx = pd.date_range("2022-01-01", periods=rows, freq="1h", tz="UTC") + grid = np.arange(rows) + base = 100.0 + np.cumsum(np.sin(grid / 19.0) * 0.08 + np.cos(grid / 37.0) * 0.02) + positions = {} + closes = {} + highs = {} + lows = {} + for j in range(symbols): + symbol = f"{symbol_prefix}{j:03d}" if symbol_prefix.endswith("SYM") else f"{symbol_prefix}{j}" + close = pd.Series(base * (1.0 + j * 0.015) + j * 3.0, index=idx) + raw = np.where(((grid // (18 + j % 4)) + j) % 4 == 0, 1.0, 0.0) + sign = 1.0 if j % 2 == 0 else -1.0 + positions[symbol] = pd.Series(raw * sign, index=idx) + closes[symbol] = close + highs[symbol] = close * 1.002 + lows[symbol] = close * 0.998 + return idx, positions, closes, highs, lows + + +def _prepare_portfolio_arrays(idx, positions, closes, highs, lows, account, alloc, fee_rate): + idx = validate_datetime(idx) + symbols = list(positions.keys()) + close_dict = align_series(closes, symbols, idx) + high_dict = align_series(highs, symbols, idx, fallback=close_dict) + low_dict = align_series(lows, symbols, idx, fallback=close_dict) + pos_dict = align_series(positions, symbols, idx, fill_val=0.0) + funding_dict = prepare_funding(0.0, symbols, idx) + market = build_market_arrays(symbols, idx, close_dict, high_dict, low_dict, funding_dict) + raw_signals = build_signal_matrix(symbols, idx, pos_dict) + alloc_arr = np.full(len(symbols), float(alloc), dtype=np.float64) + contract_sizes = np.ones(len(symbols), dtype=np.float64) + leverages = np.full(len(symbols), float(account.leverage), dtype=np.float64) + target_units = scale_signal_notional_matrix(raw_signals, market.closes, alloc_arr, use_pyramiding=True) + return { + "n_bars": len(idx), + "n_syms": len(symbols), + "highs": market.highs, + "lows": market.lows, + "closes": market.closes, + "target_units": target_units, + "funding": market.funding, + "is_funding_bar": market.is_funding_bar, + "initial_capital": float(account.initial_capital), + "leverages": leverages, + "maintenance_ratio": float(account.maintenance_ratio), + "fee_rate": float(fee_rate), + "contract_sizes": contract_sizes, + } + + +def _kernel_portfolio(prepared: Dict): + return _engine_portfolio( + n_bars=prepared["n_bars"], + n_syms=prepared["n_syms"], + highs=prepared["highs"], + lows=prepared["lows"], + closes=prepared["closes"], + target_pos=prepared["target_units"], + funding_rates=prepared["funding"], + is_funding_bar=prepared["is_funding_bar"], + init_capital=prepared["initial_capital"], + leverages=prepared["leverages"], + maint_ratio=prepared["maintenance_ratio"], + fee_rate=prepared["fee_rate"], + contract_sizes=prepared["contract_sizes"], + use_funding=False, + ) + + +def _depth_frame(idx, close: float, high: float, low: float) -> pd.DataFrame: + return pd.DataFrame( + { + "open": close, + "high": high, + "low": low, + "close": close, + "volume": 100.0, + }, + index=idx, + ) + + +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 _cython_cpp_recommendation(benchmark: Dict) -> str: + share = benchmark.get("stages", {}).get("pure_kernel_share_pct", 100.0) + if share >= 35.0: + return "Pure kernel share is large enough to justify investigating Cython/C++ after correctness locks." + return "Cython/C++ is not justified yet; optimize cached array preparation and report construction first." + + +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=2_000) + parser.add_argument("--symbols", type=int, default=6) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--include-nautilus", action="store_true") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_benchmark_nautilus_cert.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_benchmark_nautilus_cert.md") + args = parser.parse_args(argv) + report = run_certification(rows=args.rows, symbols=args.symbols, repeats=args.repeats, include_nautilus=args.include_nautilus) + 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, 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/portfolio_engine_v3.md b/docs/portfolio_engine_v3.md index f4149d4..6e4c8d1 100644 --- a/docs/portfolio_engine_v3.md +++ b/docs/portfolio_engine_v3.md @@ -189,3 +189,59 @@ from quantbt import ( build_portfolio_nautilus_validation_report, ) ``` + +## Phase 12B - Prepared Cache And Report Optimization + +The native portfolio backend now exposes prepared-array APIs for services and +WFO loops that replay many parameter sets over the same market tape. + +```python +from quantbt.backends import NativePortfolioBackend, NativePortfolioConfig +from quantbt import AccountConfig + +backend = NativePortfolioBackend( + NativePortfolioConfig( + account=AccountConfig(initial_capital=250_000, leverage=5), + fee_rate=0.0002, + use_funding=False, + ) +) + +market = backend.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=0.0, + symbols=symbols, +) + +signals = backend.prepare_signal_matrix(positions, idx, symbols) + +result = backend.run_signals( + positions=None, + closes=closes, + datetime_index=idx, + symbols=symbols, + mode="longshort", + hedge_type="signal_notional", + alloc_per_trade=10_000, + market_arrays=market, + raw_signal_matrix=signals, +) +``` + +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. + +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. + +Remaining optimization target: report construction is still the largest +measured residual bucket, so Cython/C++ is not justified before further +Python/reporting cleanup. diff --git a/tests/test_phase12_arbitrage_certification.py b/tests/test_phase12_arbitrage_certification.py new file mode 100644 index 0000000..daff36d --- /dev/null +++ b/tests/test_phase12_arbitrage_certification.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from quantbt.benchmarks.run_phase12_arbitrage_cert import make_markdown, run_certification + + +def test_phase12_arbitrage_certification_smoke(): + report = run_certification(rows=180, include_nautilus=False) + + assert report["status"] == "pass" + assert report["basis"]["audit"]["passed"] is True + assert report["basis"]["parity"]["passed"] is True + assert report["stat_pair"]["accounting_parity_passed"] is True + assert report["index_basket"]["passed"] is True + assert report["schema_only"]["passed"] is True + assert report["nautilus"]["status"] == "skipped" + + markdown = make_markdown(report) + assert "Phase 12A Arbitrage Production Certification" in markdown + assert "Basis Perp-Quarterly" in markdown diff --git a/tests/test_phase12_benchmark_nautilus_cert.py b/tests/test_phase12_benchmark_nautilus_cert.py new file mode 100644 index 0000000..145c6db --- /dev/null +++ b/tests/test_phase12_benchmark_nautilus_cert.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from quantbt import AccountConfig +from quantbt.backends import NativePortfolioBackend, NativePortfolioConfig +from quantbt.benchmarks.run_phase12_benchmark_nautilus_cert import make_markdown, run_certification + + +def test_phase12_benchmark_nautilus_certification_smoke(): + report = run_certification(rows=180, symbols=3, repeats=1, include_nautilus=False) + + assert report["status"] == "pass" + assert report["benchmark_followup"]["status"] == "pass" + assert report["benchmark_followup"]["stages"]["full_facade_seconds"] > 0.0 + assert report["benchmark_followup"]["stages"]["prepared_reuse_facade_seconds"] > 0.0 + assert report["benchmark_followup"]["stages"]["pure_numba_kernel_seconds"] > 0.0 + assert report["all_or_none_basket"]["status"] == "pass" + assert report["all_or_none_basket"]["accepted_orders"] == 0 + assert report["all_or_none_basket"]["rejected_orders"] == 2 + assert report["nautilus_portfolio"]["status"] == "skipped" + + markdown = make_markdown(report) + assert "Phase 12B Benchmark And Nautilus Portfolio Certification" in markdown + assert "Cython/C++ Decision" in markdown + + +def test_phase12_native_portfolio_prepared_reuse_matches_normal_run(): + idx, positions, closes = _portfolio_fixture() + backend = NativePortfolioBackend( + NativePortfolioConfig( + account=AccountConfig(initial_capital=100_000.0, leverage=4.0, maintenance_ratio=0.005), + fee_rate=0.0002, + use_funding=False, + ) + ) + symbols = list(positions) + + normal = backend.run_signals( + positions=positions, + closes=closes, + highs=closes, + lows=closes, + datetime_index=idx, + symbols=symbols, + mode="market_neutral", + hedge_type="notional", + alloc_per_trade={"BTC": 1_000.0, "ETH": 1_500.0}, + use_pyramiding=True, + funding_rate=0.0, + ) + + market = backend.prepare_market_arrays(idx, closes=closes, highs=closes, lows=closes, funding_rate=0.0, symbols=symbols) + signals = backend.prepare_signal_matrix(positions, idx, symbols) + reused = backend.run_signals( + positions=None, + closes=closes, + highs=closes, + lows=closes, + datetime_index=idx, + symbols=symbols, + mode="market_neutral", + hedge_type="notional", + alloc_per_trade={"BTC": 1_000.0, "ETH": 1_500.0}, + use_pyramiding=True, + funding_rate=0.0, + market_arrays=market, + raw_signal_matrix=signals, + ) + + np.testing.assert_allclose(reused.equity.to_numpy(), normal.equity.to_numpy(), rtol=0.0, atol=1e-10) + np.testing.assert_allclose(reused.positions.to_numpy(), normal.positions.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose( + reused.metadata["symbol_pnl_report"]["total_pnl"].to_numpy(), + normal.metadata["symbol_pnl_report"]["total_pnl"].to_numpy(), + rtol=0.0, + atol=1e-12, + ) + + +def test_phase12_native_portfolio_prepared_reuse_rejects_stale_signature(): + idx, positions, closes = _portfolio_fixture() + backend = NativePortfolioBackend( + NativePortfolioConfig(account=AccountConfig(initial_capital=100_000.0, leverage=4.0), use_funding=False) + ) + symbols = list(positions) + market = backend.prepare_market_arrays(idx, closes=closes, highs=closes, lows=closes, symbols=symbols) + signals = backend.prepare_signal_matrix(positions, idx, symbols) + + with pytest.raises(ValueError, match="prepared market arrays"): + backend.run_signals( + positions=None, + closes=closes, + datetime_index=idx[:-1], + symbols=symbols, + market_arrays=market, + raw_signal_matrix=signals[:-1], + ) + + +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 e6a037b..df3836c 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -1971,6 +1971,147 @@ Validation: --- +## Phase 12 - Production Certification, Benchmark Follow-Up, And Real Validation + +Goal: + +Close the remaining production-certification gaps across arbitrage, benchmark +optimization evidence, and Nautilus portfolio validation. This phase is split +into two implementation phases only. + +### Phase 12A - Arbitrage Production Certification + +Status: completed in `benchmarks/run_phase12_arbitrage_cert.py`. + +Validation artifacts: + +- `benchmarks/phase12_arbitrage_cert.json` +- `benchmarks/phase12_arbitrage_cert.md` +- `tests/test_phase12_arbitrage_certification.py` + +Latest certification summary: + +- Native basis perp/quarterly event vs vectorized parity: pass. +- Basis package domain audit: pass. +- Stat-arb pair accounting parity: pass for equity/positions/target units. +- Index basket package smoke: pass. +- Cross-exchange, triangular, and options-vol guardrails: pass as explicit + specialized-engine `NotImplemented` paths. +- Nautilus package smoke: pass with supported Binance test instruments. This + validates adapter package replay, not a real quarterly venue model. + +Remaining debt: + +- 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. + +Scope: + +- Use `/root/bobby/pool_alpha/Arbops/binance_basis_arb` only as a read-only + reference alpha. +- Copy a local sandbox into + `.local_arbitrage_sandboxes/binance_basis_arb/` and keep it git-ignored. +- Do not commit the copied alpha source, data, ML artifacts, or private reports. +- Add realistic basis/stat-arb/basket package simulations for QuantBT: + - perp vs quarterly basis with unit-equal sizing; + - funding on the perpetual leg and zero funding on the quarterly leg; + - synthetic spread convergence and adverse divergence; + - basket/index package smoke; + - native event vs native vectorized parity; + - optional Nautilus package parity when `nautilus-trader` is installed. +- Keep cross-exchange, triangular, and options-vol arbitrage schema-safe but + non-executable unless specialized engines are explicitly implemented. + +Acceptance: + +- A production-certification script writes JSON/Markdown artifacts: + - native event/vectorized final equity diff; + - max equity diff; + - fill/order count; + - funding/fee totals; + - audit pass/fail; + - schema-only spec rejection status. +- Tests cover realistic package behavior without depending on private Arbops + internals. +- Optional Nautilus execution is skipped cleanly when the dependency or venue + support is unavailable. + +### Phase 12B - Benchmark Follow-Up And Nautilus Portfolio Certification + +Status: completed in `benchmarks/run_phase12_benchmark_nautilus_cert.py`. + +Validation artifacts: + +- `benchmarks/phase12_benchmark_nautilus_cert.json` +- `benchmarks/phase12_benchmark_nautilus_cert.md` +- `tests/test_phase12_benchmark_nautilus_cert.py` + +Latest certification and optimization summary: + +- Native portfolio benchmark separates full facade, array preparation, pure + Numba kernel, and report-construction residual. +- `NativePortfolioBackend.prepare_market_arrays(...)` and + `prepare_signal_matrix(...)` now provide explicit prepared-cache APIs for + WFO/service loops. Reuse is guarded by datetime/symbol signatures, not pandas + object identity. +- `NativePortfolioBackend.run_signals(...)` accepts `market_arrays` and + `raw_signal_matrix` so repeated portfolio runs can skip pandas market + normalization while preserving the same accounting kernel. +- Native portfolio `notional` and `unit` sizing now use ndarray vector paths + instead of per-symbol pandas sizing dispatch. Legacy parity tests still pass. +- Native portfolio report construction was tightened by building common + DataFrames directly from ndarray blocks and by generating `symbol_pnl_report` + in one vectorized construction rather than per-symbol concat. +- Pure Numba kernel share remains about 0.2% in the current Phase 12B profile; + Cython/C++ is not justified until cached preparation/reporting are optimized. +- Current benchmark artifact reports prepared-cache reuse speedup for the small + Phase 12B profile. Larger WFO/service loops should benefit more because the + same market arrays are reused across many strategy parameter trials. +- Real Nautilus portfolio package replay ran and passed the tolerance profile: + equity tolerance `1.0`, position tolerance `0.005` for venue lot-size + rounding. +- 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. + +Scope: + +- Add a benchmark follow-up script that separates: + - pure Numba kernel runtime; + - pandas/data normalization; + - report construction; + - full facade runtime. +- Use this to decide whether speed debt is in Numba kernels or Python/reporting + layers before considering Cython/C++. +- Add real/representative Nautilus portfolio package validation: + - native portfolio reference; + - Nautilus package replay; + - fill-price/equity tolerance profile; + - all-or-none basket/package preflight parity; + - saved JSON/Markdown summary. + +Acceptance: + +- Benchmark report explains remaining optimization targets and whether Cython/C++ + is justified. +- Nautilus portfolio certification report exists and skips gracefully when + Nautilus is unavailable. +- Existing endpoint defaults and public API remain stable. + +--- + ## Backend Selection Guide Use `native_vectorized` when: