From 726e92d7d641924fa817adfa729a0eb4a1941f02 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 10:09:03 +0000 Subject: [PATCH 01/15] docs: add options engine execution plan --- .../quantbt_options_engine_execution_plan.md | 552 ++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md new file mode 100644 index 0000000..1ff35cf --- /dev/null +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -0,0 +1,552 @@ +# QuantBT Options Engine Execution Plan + +Branch note: requested branch `dev/option-engine` cannot be created while the +existing branch `dev` exists, because Git cannot store both `refs/heads/dev` +and `refs/heads/dev/option-engine`. The working branch for this plan is +`feat/option-engine`. + +This plan is derived from: + +- `upgrade/option_backtest_plan/quantbt_options_engine_verified_design.md` +- `upgrade/option_backtest_plan/learnfromnautilusframework.md` + +It is intentionally an implementation-control document, not a replacement for +the detailed design. The implementation must stay additive, preserve existing +QuantBT endpoint behavior, and only promote an options feature when the +domain-accounting tests pass. + +## Core Principles + +1. **Convention first** + Option accounting starts from instrument convention, not from a generic + payoff formula. Linear, inverse, quanto, premium currency, settlement + currency, multiplier, fee currency, and reporting currency must be explicit. + +2. **Ledger first** + Actual PnL comes from cash, fills, fees, marks, lifecycle events, hedge PnL, + and settlement cashflows. Greek attribution is explanatory only. + +3. **Specialized backend** + Do not patch generic `native_event` or `native_vectorized` to run options + directly. Options need `backends/native_option.py` and a bounded + `quantbt/options/` package. + +4. **Ragged option tape** + Do not represent the option chain as a dense `N_bars x N_contracts` matrix. + Use long-form canonical data, then compile to a CSR/ragged event tape for + hot loops. + +5. **Bid/ask execution** + Market buy fills at ask, market sell fills at bid. Mark/mid/model price may + value positions, but cannot be the default execution price. + +6. **No lookahead** + Contract selection, delta selection, IV, surface, DTE, and expiry settlement + must use only data observable at the decision timestamp. + +7. **BacktestResultV2 compatibility** + `OptionBacktestResult` can add option-specific artifacts, but must remain + compatible with metrics, plots, reports, endpoint helpers, and report bundle + workflows. + +8. **Nautilus optional** + Nautilus is a validation backend. Native option backtesting must not import + Nautilus at import time or require Nautilus to be installed. + +## Public Surface Target + +Initial endpoint: + +```python +bt = QuantBTEndpoint.options( + backend="native_option", + simulation_mode="event", # event | research + venue="deribit", + initial_capital=2.0, + base_currency="BTC", + reporting_currency="USD", + margin_mode="scenario_approximation", + mark_policy="venue_mark", + decision_fill_policy="next_snapshot", + max_quote_age_ns=5_000_000_000, +) + +result = bt.simulate( + chain=option_chain, + underlying=underlying_tape, + packages=package_intents, + instruments=instrument_specs, +) +``` + +Research helpers may later expose contract selection and surface diagnostics, +but research mode must be labelled as analytics/approximation, not +execution-accurate validation. + +Support matrix target: + +| Route | Native Option | Native Event | Native Vectorized | Nautilus | +|---|---|---|---|---| +| Single option | supported | unsupported | analytics only | planned validation | +| Multi-leg option package | supported | unsupported | analytics only | planned validation | +| Delta-hedged option package | supported | unsupported | unsupported | planned validation | +| `OptionsVolArbSpec` | supported specialized | schema-only | schema-only | planned validation | + +## Phase 0 - Baseline Protection + +Purpose: lock current QuantBT behavior before adding options. + +Tasks: + +- Confirm branch is `feat/option-engine`. +- Run full non-real regression suite. +- Snapshot support matrices: + - `QuantBTEndpoint.arbitrage_support_matrix()` + - `QuantBTEndpoint.nautilus_support_matrix()` +- Confirm `import quantbt` works without Nautilus. +- Add an options plan/status section to `upgrade/implement.md` only after the + first code phase starts. + +Acceptance: + +- Existing tests pass. +- No public endpoint behavior changes. +- No Nautilus import-time dependency. + +## Phase 1 - Domain Schema And Conventions + +Files: + +- `core/schema.py` +- `options/__init__.py` +- `options/schema.py` +- `options/conventions.py` +- `options/data.py` +- `tests/options/test_schema.py` +- `tests/options/test_conventions.py` + +Tasks: + +- Add `AssetType.OPTION` only. +- Add option enums: + - `OptionKind` + - `ExerciseStyle` + - `PremiumConvention` + - `SettlementStyle` + - `OptionDecisionFillPolicy` +- Add `OptionInstrumentSpec` extending `InstrumentSpec`. +- Add versioned venue conventions: + - Deribit inverse BTC/ETH; + - Deribit linear USDC; + - Binance European options config, without pretending unsupported details are + exact. +- Add instrument registry with symbol-to-code mapping and convention signature. +- Add canonical long-form chain schema validator. + +Acceptance: + +- Linear and inverse instruments cannot be confused. +- Missing premium/settlement/reporting currencies reject. +- Strike, multiplier, expiry, quantity step, venue and underlying fields are + validated. +- Current non-option schemas remain compatible. + +## Phase 2 - Pricing, IV, Greeks + +Files: + +- `options/pricing.py` +- `options/iv.py` +- `options/greeks.py` +- `options/surface.py` +- `tests/options/test_pricing.py` +- `tests/options/test_inverse_conventions.py` +- `tests/options/test_iv.py` +- `tests/options/test_greeks.py` +- `tests/options/test_surface.py` + +Tasks: + +- Implement Linear Black-76: + - call; + - put; + - intrinsic; + - parity. +- Implement inverse forward-based pricing: + - inverse call; + - inverse put; + - inverse intrinsic; + - inverse put-call parity. +- Implement Greeks with explicit units: + - native settlement-currency Greeks; + - reporting-currency Greeks; + - vega internal as per `1.0` vol change, reporting can show per vol point. +- Implement deterministic IV solver: + - no-arb bounds; + - bracketed bisection baseline; + - status enum for invalid cases. +- Implement minimal surface diagnostics: + - total variance interpolation; + - static no-arb guard placeholders; + - no future-expiry data in snapshot calibration. + +Acceptance: + +- Linear and inverse parity pass. +- IV recovers generated volatility. +- Invalid IV prices reject with status, not silent fallback. +- Finite-difference Greeks match analytic Greeks within tolerance. +- No `fastmath=True` in IV/no-arb critical paths. + +## Phase 3 - Data Tape And Selectors + +Files: + +- `options/tape.py` +- `options/selectors.py` +- `tests/options/test_tape.py` +- `tests/options/test_selectors.py` +- `tests/options/test_no_lookahead.py` + +Tasks: + +- Normalize long-form option chain. +- Compile to `PreparedOptionTape` / CSR-style ragged arrays: + - snapshot timestamps; + - row pointers; + - instrument codes; + - bid/ask/size/mark/IV/OI. +- Add stale quote and crossed-book guards. +- Add selectors: + - ATM; + - target delta; + - DTE; + - moneyness; + - liquidity/spread/OI filters. +- Add signatures: + - tape signature; + - instrument registry signature; + - convention signature. + +Acceptance: + +- No dense fixed-universe option matrix is used as canonical chain. +- Expired/unlisted contracts are not selected. +- Delta/IV selection uses only observable snapshot values. +- Prepared tape rejects stale registry/convention/timestamp mismatch. + +## Phase 4 - Package Compiler And Options Execution + +Files: + +- `options/packages.py` +- `options/execution.py` +- `tests/options/test_packages.py` +- `tests/options/test_execution.py` + +Tasks: + +- Add `OptionPackageLeg`: + - `side` owns direction; + - `ratio` is positive only. +- Add `OptionPackageIntent`. +- Compile option package to existing `OrderIntent` leaves with package metadata. +- Implement execution policies: + - `ATOMIC_ALL_OR_NONE`; + - `BEST_EFFORT`; + - `SEQUENTIAL`; + - `HEDGE_AFTER_PRIMARY`; + - `REBALANCE_ONLY`. +- Implement option fill model: + - market buy at ask; + - market sell at bid; + - limit maker fidelity modes; + - FOK/IOC/GTC semantics where feasible; + - package debit/credit guard; + - depth/size guard with explicit fidelity label. + +Acceptance: + +- AON rollback leaves cash, positions, margin and reports unchanged on failure. +- IOC partial reports residual risk. +- Market fills never use mark/mid by default. +- Package metadata states whether atomicity is simulated, exchange combo, or + block-trade style. + +## Phase 5 - Multi-Currency Ledger, Fees, Lifecycle + +Files: + +- `options/ledger.py` +- `options/fees.py` +- `options/lifecycle.py` +- `tests/options/test_ledger.py` +- `tests/options/test_fees.py` +- `tests/options/test_lifecycle.py` + +Tasks: + +- Add multi-currency ledger: + - cash; + - position quantity; + - avg entry; + - realized PnL; + - fees; + - settlement cashflows; + - margin locked. +- Implement premium cashflow: + - long option pays premium; + - short option receives premium; + - fee recorded separately. +- Implement Deribit-like per-leg capped fees: + - inverse base-currency fee cap; + - linear USDC fee cap; + - no package-level fee cap. +- Implement lifecycle: + - OTM expiry; + - ITM linear cash payoff; + - ITM inverse payoff; + - Deribit linear `economic_cash` and `future_then_cash` representations; + - settlement audit rows. + +Acceptance: + +- Equity identity reconciles every event. +- Round trip with no price move equals spread plus fees. +- Inverse BTC premium and USD reporting equity reconcile via conversion rate. +- Settlement closes exactly once. +- Fees are in correct currency and converted only for reporting. + +## Phase 6 - Hedging And Margin + +Files: + +- `options/hedging.py` +- `options/margin.py` +- `tests/options/test_hedging.py` +- `tests/options/test_margin.py` + +Tasks: + +- Implement hedge policies: + - fixed threshold; + - hysteresis band; + - time-based; + - realized-vol scaled band. +- Do not implement Whalley-Wilmott until objective, cost units and paper + reproduction are available. +- Implement margin models: + - long-premium-only; + - standard venue approximation; + - scenario portfolio margin approximation; + - no-margin research mode; + - external venue margin validator interface. +- Add liquidation sequence: + - maintenance margin check; + - adverse bid/ask liquidation; + - iterative liquidation audit. + +Acceptance: + +- Hedge PnL uses previous hedge position for prior price move. +- Hedge rebalance happens after option package fills and Greek recomputation. +- Scenario PM report states `venue_exact=false`. +- Liquidation audit explains breach, orders, fees and final state. + +## Phase 7 - Backend, Engine, Endpoint, Result + +Files: + +- `backends/native_option.py` +- `engines.py` +- `endpoint.py` +- `core/results.py` +- `metrics/options_analytics.py` +- `tests/options/test_endpoint_contract.py` +- `tests/options/test_result_contract.py` + +Tasks: + +- Add `NativeOptionConfig`. +- Add `NativeOptionBackend`. +- Add `OptionBacktestEngine`. +- Add `OptionBacktestResult` compatible with `BacktestResultV2`. +- Add `QuantBTEndpoint.options(...)`. +- Add `options_support_matrix()`. +- Wire `OptionsVolArbSpec` to specialized option route only. +- Add required option reports: + - fills; + - packages; + - cash balances; + - marks; + - Greeks; + - settlements; + - margin; + - attribution; + - run manifest. + +Acceptance: + +- `QuantBTEndpoint.options(...)` runs mock chain examples. +- Existing endpoints still pass tests. +- `import quantbt` still does not require Nautilus. +- Result supports `.show_metrics()`, `.full_report()`, and report bundle paths + where current `BacktestResultV2` supports them. + +## Phase 8 - Strategy Templates And Golden Payoff Tests + +Files: + +- `options/templates/*.py` +- `examples/options/*.py` +- `tests/options/test_strategy_payoffs.py` + +Tasks: + +- Implement package builders only, not accounting logic: + - long/short call; + - long/short put; + - straddle; + - strangle; + - vertical; + - butterfly; + - condor; + - calendar; + - covered call; + - collar; + - risk reversal. +- Add expiry payoff grid tests. +- Add mock examples: + - Deribit inverse gamma scalping; + - linear spread; + - covered call; + - calendar. + +Acceptance: + +- Golden payoff tests pass for all V1 structures. +- Templates only emit package intents; they do not compute PnL manually. + +## Phase 9 - Nautilus Validation + +Files: + +- `adapters/nautilus/options.py` +- `tests/options/test_nautilus_options.py` +- `docs/nautilus_backend.md` + +Tasks: + +- Keep Nautilus optional. +- Pin/inspect Nautilus version before constructing option instruments. +- Map to: + - `CryptoOption`; + - `CryptoOptionSpread`; + - `OptionContract`; + - `OptionSpread` where appropriate. +- Use Nautilus quote-driven matching: + - market buy at ask; + - market sell at bid; + - limit fills when BBO crosses limit policy. +- Validate representative cases: + - one linear option round trip; + - one inverse option if exact adapter convention is supported; + - two-leg spread; + - option plus perpetual/underlying delta hedge; + - expiry settlement; + - fees and account reports. +- Export component-specific parity: + - quantity; + - fill timestamp; + - fill price; + - fee; + - settlement; + - realized cashflow; + - final equity. + +Acceptance: + +- Missing Nautilus or incompatible version skips clearly. +- Validation reports never claim full mapping unless constructor compatibility + and instrument conventions are pinned. +- Native and Nautilus differences are component-labelled, not hidden in one + final-equity tolerance. + +## Phase 10 - Performance And Production Hardening + +Files: + +- `benchmarks/run_options_engine.py` +- `benchmarks/options_*.json` +- `benchmarks/options_*.md` +- `tests/options/test_fuzz_invalid_data.py` + +Tasks: + +- Add prepared tape cache. +- Add compiled package cache. +- Benchmark: + - snapshots; + - quotes; + - packages; + - fills; + - hedges; + - contracts; + - memory. +- Add deterministic replay with random seed. +- Add fuzz tests for invalid data. +- Add run manifest: + - data hash; + - convention version; + - fee schedule; + - margin model; + - pricing model; + - fidelity manifest. + +Acceptance: + +- Large mock chain benchmark has parity guards. +- Prepared tape rejects stale signatures. +- Cython/C++ is only considered after Numba/profile evidence shows pure kernel + bottlenecks. + +## V1 Completion Criteria + +V1 can be called usable only when: + +- current QuantBT regression suite passes; +- `QuantBTEndpoint.options(...)` runs; +- linear and inverse conventions are separate and tested; +- bid/ask fills and per-leg fees use correct units; +- premium cashflow is not double-counted; +- hedge PnL uses previous hedge quantity for prior price move; +- multi-currency equity reconciles each event; +- linear and inverse expiry settlement pass; +- AON package rollback is atomic; +- key strategy payoff grids pass; +- Greeks finite-difference tests pass; +- IV solver recovers known volatility; +- result artifacts are `BacktestResultV2` compatible; +- run manifest contains convention, fee, margin and data hashes; +- at least one inverse gamma-scalping example and one linear spread example are + archived; +- Nautilus or venue official parity samples exist for the supported validation + subset. + +## Non-Goals Until Later + +- Exact Deribit Portfolio Margin clone without official/API validation. +- True L2 queue-priority execution unless real L2 data is provided. +- Whalley-Wilmott hedge policy without dimensional/paper benchmark validation. +- Generic `native_event` options execution. +- Advertising Nautilus options mapping as complete before version-pinned + compatibility tests. +- Cross-venue volatility arbitrage production semantics before collateral, + transfer, latency and borrow constraints are implemented. + +## Immediate Next Step + +Start with Phase 0, then Phase 1. Do not jump to pricing or endpoint wiring +before schema/convention tests pass. The first code commit should be small: +`AssetType.OPTION`, `options/schema.py`, `options/conventions.py`, and schema +tests only. From 058a6c34e2f62bfd873a54e7487ffb966de2491c Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 10:41:47 +0000 Subject: [PATCH 02/15] test: snapshot options phase 0 baseline --- .../phase0_baseline_snapshot.json | 84 ++++++++++++ .../phase0_baseline_snapshot.md | 129 ++++++++++++++++++ .../quantbt_options_engine_execution_plan.md | 5 + 3 files changed, 218 insertions(+) create mode 100644 upgrade/option_backtest_plan/phase0_baseline_snapshot.json create mode 100644 upgrade/option_backtest_plan/phase0_baseline_snapshot.md diff --git a/upgrade/option_backtest_plan/phase0_baseline_snapshot.json b/upgrade/option_backtest_plan/phase0_baseline_snapshot.json new file mode 100644 index 0000000..9852386 --- /dev/null +++ b/upgrade/option_backtest_plan/phase0_baseline_snapshot.json @@ -0,0 +1,84 @@ +{ + "phase": "options_phase0_baseline_protection", + "branch": "feat/option-engine", + "base_commit_before_phase0_artifact": "726e92d", + "date_utc": "2026-07-23", + "status": "pass", + "git_worktree_clean_before_phase0": true, + "import_snapshot": { + "python": "3.12.13", + "quantbt_import": true, + "has_nautilus_imported_after_import_quantbt": false + }, + "regression": { + "command": "MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py", + "passed": 286, + "skipped": 1, + "warnings": 3, + "status": "pass" + }, + "support_matrix_snapshot": { + "OptionsVolArbSpec": { + "status": "schema_only", + "backends": "none", + "route": "needs option/greeks engine", + "sizing": "not executable yet" + }, + "arbitrage_supported_specs": [ + "BasisArbitrageSpec", + "StatArbPairSpec", + "CalendarSpreadSpec", + "FundingArbitrageSpec", + "SpotPerpCashCarrySpec", + "IndexBasketArbSpec" + ], + "arbitrage_schema_only_specs": [ + "CrossExchangeArbSpec", + "TriangularArbSpec", + "OptionsVolArbSpec" + ], + "nautilus_supported_routes": [ + "signal_series", + "explicit_orders", + "parity_audit" + ], + "nautilus_experimental_routes": [ + "dca_grid", + "bracket_oco", + "basket_pair", + "multi_symbol_portfolio", + "arbitrage_package_orders" + ] + }, + "benchmark_snapshot": { + "command": "MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python benchmarks/run_phase16_performance_debt.py --rows 720 --symbols 4 --replays 4 --repeats 1 --skip-large-wfo --output-json /tmp/options_phase0_benchmark.json --output-md /tmp/options_phase0_benchmark.md", + "status": "pass", + "rows": 720, + "symbols": 4, + "replays": 4, + "single_signal_notional": { + "normal_seconds": 0.03123383386991918, + "prepared_seconds": 0.016127109993249178, + "speedup": 1.9367285200506283, + "parity_passed": true + }, + "native_portfolio": { + "normal_seconds": 0.0903144299518317, + "prepared_seconds": 0.02591125899925828, + "speedup": 3.4855284320386355, + "parity_passed": true + }, + "portfolio_report_construction": { + "full_seconds": 0.042830555932596326, + "minimal_seconds": 0.022004221100360155, + "speedup": 1.9464699857926484, + "parity_passed": true + } + }, + "phase0_acceptance": { + "existing_tests_pass": true, + "no_public_endpoint_regression_observed": true, + "no_import_time_nautilus_dependency": true, + "options_engine_code_added": false + } +} diff --git a/upgrade/option_backtest_plan/phase0_baseline_snapshot.md b/upgrade/option_backtest_plan/phase0_baseline_snapshot.md new file mode 100644 index 0000000..303f25f --- /dev/null +++ b/upgrade/option_backtest_plan/phase0_baseline_snapshot.md @@ -0,0 +1,129 @@ +# Options Engine Phase 0 Baseline Snapshot + +Status: **pass** + +Branch: `feat/option-engine` + +Baseline commit before this artifact: `726e92d` + +Date: `2026-07-23 UTC` + +## Scope + +Phase 0 is baseline protection only. No options engine code was added. + +The purpose is to prove the current QuantBT state before Phase 1 touches schema +or conventions. + +## Regression + +Command: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha \ + poetry run pytest -q tests \ + --ignore=tests/test_real.py \ + --ignore=tests/test_real_endpoints.py +``` + +Result: + +- passed: `286` +- skipped: `1` +- warnings: `3` +- status: `pass` + +## Import Boundary + +Import smoke: + +- `import quantbt`: `pass` +- Python: `3.12.13` +- `nautilus_trader` imported as side effect: `false` + +This preserves the required optional Nautilus boundary. + +## Support Matrix Snapshot + +Arbitrage: + +- supported: + - `BasisArbitrageSpec` + - `StatArbPairSpec` + - `CalendarSpreadSpec` + - `FundingArbitrageSpec` + - `SpotPerpCashCarrySpec` + - `IndexBasketArbSpec` +- schema-only: + - `CrossExchangeArbSpec` + - `TriangularArbSpec` + - `OptionsVolArbSpec` + +Important options baseline: + +```text +OptionsVolArbSpec.status = schema_only +OptionsVolArbSpec.backends = none +OptionsVolArbSpec.route = needs option/greeks engine +``` + +This is the expected pre-options-engine state. Phase 7 may later route +`OptionsVolArbSpec` through `native_option`, but generic arbitrage routes should +remain schema-only for this spec. + +Nautilus: + +- supported: + - `signal_series` + - `explicit_orders` + - `parity_audit` +- experimental: + - `dca_grid` + - `bracket_oco` + - `basket_pair` + - `multi_symbol_portfolio` + - `arbitrage_package_orders` + +## Benchmark Snapshot + +Command: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha \ + poetry run python benchmarks/run_phase16_performance_debt.py \ + --rows 720 \ + --symbols 4 \ + --replays 4 \ + --repeats 1 \ + --skip-large-wfo \ + --output-json /tmp/options_phase0_benchmark.json \ + --output-md /tmp/options_phase0_benchmark.md +``` + +Result: + +| workload | normal | prepared/minimal | speedup | parity | +|---|---:|---:|---:|---| +| single `signal_notional` | `0.031234s` | `0.016127s` | `1.937x` | pass | +| native portfolio | `0.090314s` | `0.025911s` | `3.486x` | pass | +| portfolio reports | `0.042831s` full | `0.022004s` minimal | `1.946x` | pass | + +## Acceptance + +- Existing tests pass: `yes` +- No public endpoint regression observed: `yes` +- No import-time Nautilus dependency: `yes` +- Options engine code added: `no` + +## Next Phase + +Phase 1 should be the first code phase: + +- `AssetType.OPTION` +- `options/schema.py` +- `options/conventions.py` +- `options/data.py` +- schema/convention tests only + +Do not start pricing, endpoint wiring, or backend execution until Phase 1 +schema/convention tests pass. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 1ff35cf..815abb2 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -96,6 +96,11 @@ Support matrix target: Purpose: lock current QuantBT behavior before adding options. +Status: completed. See: + +- `phase0_baseline_snapshot.json` +- `phase0_baseline_snapshot.md` + Tasks: - Confirm branch is `feat/option-engine`. From c1294c48bc71917c96309ab139383aa63a7b4693 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 10:53:35 +0000 Subject: [PATCH 03/15] feat: add options phase 1 schema --- __init__.py | 30 +++ core/schema.py | 1 + options/__init__.py | 42 ++++ options/conventions.py | 158 ++++++++++++ options/data.py | 177 ++++++++++++++ options/schema.py | 227 ++++++++++++++++++ tests/options/test_phase1_data.py | 91 +++++++ .../options/test_phase1_schema_conventions.py | 214 +++++++++++++++++ upgrade/implement.md | 83 +++++++ .../phase1_domain_schema_status.md | 95 ++++++++ .../quantbt_options_engine_execution_plan.md | 48 +++- 11 files changed, 1164 insertions(+), 2 deletions(-) create mode 100644 options/__init__.py create mode 100644 options/conventions.py create mode 100644 options/data.py create mode 100644 options/schema.py create mode 100644 tests/options/test_phase1_data.py create mode 100644 tests/options/test_phase1_schema_conventions.py create mode 100644 upgrade/option_backtest_plan/phase1_domain_schema_status.md diff --git a/__init__.py b/__init__.py index 434eca2..3b8c5a3 100644 --- a/__init__.py +++ b/__init__.py @@ -175,6 +175,22 @@ portfolio_capability_matrix, validate_portfolio_result_contract, ) +from .options import ( + CANONICAL_OPTION_CHAIN_COLUMNS, + ExerciseStyle, + InstrumentRegistrySignature, + OptionDecisionFillPolicy, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + OptionVenueConvention, + PremiumConvention, + SettlementStyle, + binance_european_options_convention, + deribit_inverse_option_convention, + deribit_linear_usdc_option_convention, + validate_option_chain_frame, +) from .metrics import ( full_report, @@ -237,6 +253,20 @@ "QuantBTEndpoint", "QuantBTPreparedContext", "format_metrics_report", + "CANONICAL_OPTION_CHAIN_COLUMNS", + "ExerciseStyle", + "InstrumentRegistrySignature", + "OptionDecisionFillPolicy", + "OptionInstrumentRegistry", + "OptionInstrumentSpec", + "OptionKind", + "OptionVenueConvention", + "PremiumConvention", + "SettlementStyle", + "binance_european_options_convention", + "deribit_inverse_option_convention", + "deribit_linear_usdc_option_convention", + "validate_option_chain_frame", "LEGACY_PORTFOLIO_MODES", "LEGACY_PORTFOLIO_SIZING_MODES", "NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES", diff --git a/core/schema.py b/core/schema.py index 0eb4284..116ce97 100644 --- a/core/schema.py +++ b/core/schema.py @@ -20,6 +20,7 @@ class AssetType(str, Enum): STOCK = "stock" FUTURE = "future" FX = "fx" + OPTION = "option" class MarginMode(str, Enum): diff --git a/options/__init__.py b/options/__init__.py new file mode 100644 index 0000000..9346784 --- /dev/null +++ b/options/__init__.py @@ -0,0 +1,42 @@ +""" +QuantBT options domain package. + +Phase 1 exposes schema, convention, and canonical chain-data validation only. +Pricing, execution, ledger, margin, endpoint wiring, and Nautilus validation are +added in later phases. +""" + +from .conventions import ( + OptionVenueConvention, + binance_european_options_convention, + deribit_inverse_option_convention, + deribit_linear_usdc_option_convention, +) +from .data import CANONICAL_OPTION_CHAIN_COLUMNS, validate_option_chain_frame +from .schema import ( + ExerciseStyle, + InstrumentRegistrySignature, + OptionDecisionFillPolicy, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + SettlementStyle, +) + +__all__ = [ + "CANONICAL_OPTION_CHAIN_COLUMNS", + "ExerciseStyle", + "InstrumentRegistrySignature", + "OptionDecisionFillPolicy", + "OptionInstrumentRegistry", + "OptionInstrumentSpec", + "OptionKind", + "OptionVenueConvention", + "PremiumConvention", + "SettlementStyle", + "binance_european_options_convention", + "deribit_inverse_option_convention", + "deribit_linear_usdc_option_convention", + "validate_option_chain_frame", +] diff --git a/options/conventions.py b/options/conventions.py new file mode 100644 index 0000000..d8cb957 --- /dev/null +++ b/options/conventions.py @@ -0,0 +1,158 @@ +""" +Versioned option venue conventions. + +These conventions are descriptive configuration, not a pricing engine and not a +claim that venue portfolio margin is exactly replicated. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Tuple + +from .schema import ExerciseStyle, PremiumConvention, SettlementStyle + + +@dataclass(frozen=True) +class OptionVenueConvention: + venue: str + convention_id: str + premium_convention: PremiumConvention + exercise_style: ExerciseStyle + settlement_style: SettlementStyle + premium_currency: str + settlement_currency: str + quote_currency: str + supported_underlyings: Tuple[str, ...] = () + fee_schedule_id: str = "" + margin_schedule_id: str = "" + exact_venue_margin: bool = False + notes: str = "" + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "venue", str(self.venue).lower().strip()) + object.__setattr__(self, "premium_convention", _coerce(PremiumConvention, self.premium_convention, "premium_convention")) + object.__setattr__(self, "exercise_style", _coerce(ExerciseStyle, self.exercise_style, "exercise_style")) + object.__setattr__(self, "settlement_style", _coerce(SettlementStyle, self.settlement_style, "settlement_style")) + if not self.venue or not self.convention_id: + raise ValueError("venue and convention_id are required") + for field_name in ("premium_currency", "settlement_currency", "quote_currency"): + value = getattr(self, field_name) + if not value: + raise ValueError(f"{field_name} is required") + object.__setattr__(self, field_name, str(value).upper()) + object.__setattr__(self, "supported_underlyings", tuple(str(value).upper() for value in self.supported_underlyings)) + _validate_convention(self) + + @property + def signature(self) -> Tuple: + return ( + self.venue, + self.convention_id, + self.premium_convention.value, + self.exercise_style.value, + self.settlement_style.value, + self.premium_currency, + self.settlement_currency, + self.quote_currency, + self.supported_underlyings, + self.fee_schedule_id, + self.margin_schedule_id, + bool(self.exact_venue_margin), + ) + + +def deribit_inverse_option_convention( + *, + underlying: str = "BTC", + version: str = "deribit_inverse_v1", +) -> OptionVenueConvention: + base = str(underlying).upper() + if base not in {"BTC", "ETH"}: + raise ValueError("Deribit inverse convention currently supports BTC or ETH") + return OptionVenueConvention( + venue="deribit", + convention_id=version, + premium_convention=PremiumConvention.INVERSE_BASE, + exercise_style=ExerciseStyle.EUROPEAN, + settlement_style=SettlementStyle.CASH, + premium_currency=base, + settlement_currency=base, + quote_currency="USD", + supported_underlyings=(base,), + fee_schedule_id=f"deribit_{base.lower()}_inverse_options", + margin_schedule_id="deribit_pm_external_or_scenario_approximation", + exact_venue_margin=False, + notes="Inverse premium and settlement are in base currency; native margin is approximation unless validated externally.", + ) + + +def deribit_linear_usdc_option_convention( + *, + underlying: str = "BTC", + version: str = "deribit_linear_usdc_v1", + settlement_style: SettlementStyle = SettlementStyle.FUTURE_THEN_CASH, +) -> OptionVenueConvention: + base = str(underlying).upper() + return OptionVenueConvention( + venue="deribit", + convention_id=version, + premium_convention=PremiumConvention.LINEAR_QUOTE, + exercise_style=ExerciseStyle.EUROPEAN, + settlement_style=settlement_style, + premium_currency="USDC", + settlement_currency="USDC", + quote_currency="USDC", + supported_underlyings=(base,), + fee_schedule_id="deribit_linear_usdc_options", + margin_schedule_id="deribit_pm_external_or_scenario_approximation", + exact_venue_margin=False, + notes="Linear USDC option convention supports economic cash or future-then-cash settlement representation.", + ) + + +def binance_european_options_convention( + *, + underlying: str = "BTC", + version: str = "binance_european_options_v1", +) -> OptionVenueConvention: + base = str(underlying).upper() + return OptionVenueConvention( + venue="binance", + convention_id=version, + premium_convention=PremiumConvention.LINEAR_QUOTE, + exercise_style=ExerciseStyle.EUROPEAN, + settlement_style=SettlementStyle.CASH, + premium_currency="USDT", + settlement_currency="USDT", + quote_currency="USDT", + supported_underlyings=(base,), + fee_schedule_id="binance_options_versioned_external", + margin_schedule_id="binance_options_external_or_scenario_approximation", + exact_venue_margin=False, + notes="Binance config is schema/convention metadata only until official fee/margin parity tests are added.", + ) + + +def _coerce(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc + + +def _validate_convention(convention: OptionVenueConvention) -> None: + if convention.premium_convention is PremiumConvention.INVERSE_BASE: + if convention.premium_currency != convention.settlement_currency: + raise ValueError("inverse convention requires premium_currency == settlement_currency") + if convention.quote_currency == convention.premium_currency: + raise ValueError("inverse convention requires quote_currency distinct from base premium currency") + elif convention.premium_convention is PremiumConvention.LINEAR_QUOTE: + if convention.premium_currency != convention.quote_currency: + raise ValueError("linear convention requires premium_currency == quote_currency") + elif convention.premium_convention is PremiumConvention.QUANTO: + if convention.premium_currency == convention.settlement_currency == convention.quote_currency: + raise ValueError("quanto convention requires at least one distinct currency") diff --git a/options/data.py b/options/data.py new file mode 100644 index 0000000..35b3d4f --- /dev/null +++ b/options/data.py @@ -0,0 +1,177 @@ +""" +Canonical option chain data validation. + +The canonical chain is long-form. Phase 1 validates structure only; Phase 3 +will compile this data into a ragged/CSR option tape. +""" + +from __future__ import annotations + +from typing import Iterable, Optional, Sequence + +import numpy as np +import pandas as pd + + +CANONICAL_OPTION_CHAIN_COLUMNS = ( + "timestamp_ns", + "instrument_id", + "venue", + "underlying_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "last_price", + "index_price", + "forward_price", + "mark_iv", + "bid_iv", + "ask_iv", + "delta", + "gamma", + "vega", + "theta", + "open_interest", + "volume", + "quote_currency", + "settlement_currency", + "sequence_id", + "source_latency_ns", +) + +REQUIRED_OPTION_CHAIN_COLUMNS = ( + "timestamp_ns", + "instrument_id", + "venue", + "underlying_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "index_price", + "forward_price", + "quote_currency", + "settlement_currency", +) + + +def validate_option_chain_frame( + frame: pd.DataFrame, + *, + required_columns: Sequence[str] = REQUIRED_OPTION_CHAIN_COLUMNS, + max_spread_bps: Optional[float] = None, + reject_crossed: bool = True, +) -> pd.DataFrame: + """ + Validate and return a sorted canonical long-form option chain copy. + + This function intentionally avoids filling missing market values. Missing + fields must remain visible to later tape compilation and no-lookahead tests. + """ + if not isinstance(frame, pd.DataFrame): + raise TypeError("option chain must be a pandas DataFrame") + missing = [column for column in required_columns if column not in frame.columns] + if missing: + raise ValueError(f"option chain missing required columns: {missing}") + out = frame.copy() + _coerce_int64(out, ("timestamp_ns", "expiry_ns", "sequence_id", "source_latency_ns"), required=set(required_columns)) + _coerce_float( + out, + ( + "strike", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "last_price", + "index_price", + "forward_price", + "mark_iv", + "bid_iv", + "ask_iv", + "delta", + "gamma", + "vega", + "theta", + "open_interest", + "volume", + ), + ) + _normalize_strings(out, ("instrument_id", "underlying_id", "quote_currency", "settlement_currency")) + out["venue"] = out["venue"].astype(str).str.strip().str.lower() + out["option_kind"] = out["option_kind"].astype(str).str.strip().str.lower() + _validate_positive(out, ("timestamp_ns", "expiry_ns", "strike", "index_price", "forward_price")) + _validate_non_negative(out, ("bid_price", "bid_size", "ask_price", "ask_size", "mark_price")) + if reject_crossed and bool((out["bid_price"] > out["ask_price"]).any()): + raise ValueError("option chain contains crossed quotes: bid_price > ask_price") + if bool((out["bid_price"] <= 0.0).any()): + raise ValueError("option chain requires bid_price > 0 in Phase 1 canonical validation") + if bool((out["ask_price"] <= 0.0).any()): + raise ValueError("option chain requires ask_price > 0 in Phase 1 canonical validation") + if max_spread_bps is not None: + if max_spread_bps < 0.0: + raise ValueError("max_spread_bps must be >= 0") + mid = 0.5 * (out["bid_price"].to_numpy() + out["ask_price"].to_numpy()) + spread_bps = np.divide( + out["ask_price"].to_numpy() - out["bid_price"].to_numpy(), + mid, + out=np.full(len(out), np.inf, dtype=np.float64), + where=mid > 0.0, + ) * 10_000.0 + if bool((spread_bps > float(max_spread_bps)).any()): + raise ValueError("option chain contains quotes wider than max_spread_bps") + if bool((out["expiry_ns"] <= out["timestamp_ns"]).any()): + raise ValueError("option chain contains expired quotes") + if not set(out["option_kind"].unique()).issubset({"call", "put"}): + raise ValueError("option_kind must be call or put") + out = out.sort_values(["timestamp_ns", "sequence_id", "instrument_id"] if "sequence_id" in out else ["timestamp_ns", "instrument_id"]) + out = out.reset_index(drop=True) + if bool(out.duplicated(subset=[column for column in ("timestamp_ns", "instrument_id", "sequence_id") if column in out]).any()): + raise ValueError("option chain contains duplicate timestamp/instrument/sequence rows") + return out + + +def _coerce_int64(frame: pd.DataFrame, columns: Iterable[str], *, required: set[str]) -> None: + for column in columns: + if column not in frame: + if column in required: + raise ValueError(f"missing required integer column {column!r}") + continue + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("int64") + + +def _coerce_float(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame: + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("float64") + + +def _normalize_strings(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame: + frame[column] = frame[column].astype(str).str.strip() + for column in ("quote_currency", "settlement_currency"): + if column in frame: + frame[column] = frame[column].str.upper() + + +def _validate_positive(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame and bool((frame[column] <= 0).any()): + raise ValueError(f"{column} must be > 0") + + +def _validate_non_negative(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame and bool((frame[column] < 0).any()): + raise ValueError(f"{column} must be >= 0") diff --git a/options/schema.py b/options/schema.py new file mode 100644 index 0000000..4752b76 --- /dev/null +++ b/options/schema.py @@ -0,0 +1,227 @@ +""" +Option domain schema. + +These objects are deliberately dependency-free and do not import Nautilus. They +describe instrument conventions and registry signatures; they do not perform +pricing, execution, or ledger accounting. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Iterable, Optional, Tuple + +from ..core.schema import AssetType, InstrumentSpec + + +class OptionKind(str, Enum): + CALL = "call" + PUT = "put" + + +class ExerciseStyle(str, Enum): + EUROPEAN = "european" + AMERICAN = "american" + + +class PremiumConvention(str, Enum): + LINEAR_QUOTE = "linear_quote" + INVERSE_BASE = "inverse_base" + QUANTO = "quanto" + + +class SettlementStyle(str, Enum): + CASH = "cash" + FUTURE_THEN_CASH = "future_then_cash" + PHYSICAL = "physical" + + +class OptionDecisionFillPolicy(str, Enum): + NEXT_SNAPSHOT = "next_snapshot" + SAME_SNAPSHOT_AFTER_SIGNAL = "same_snapshot_after_signal" + NEXT_BAR_OPEN = "next_bar_open" + EXPLICIT_EVENT_SEQUENCE = "explicit_event_sequence" + + +@dataclass(frozen=True, kw_only=True) +class OptionInstrumentSpec(InstrumentSpec): + """ + Option instrument definition with explicit quote/settlement conventions. + + `contract_size` remains the generic QuantBT multiplier field. `multiplier` + is kept as an option-domain alias for readability; both must match. + + `lot_size` remains the generic QuantBT quantity increment field. `qty_step` + is kept as an option-domain alias because options venues usually describe + order precision this way. If either is supplied, both are normalized to the + same value. + """ + + asset_type: AssetType = AssetType.OPTION + venue: str + underlying_id: str + underlying_index_id: str + option_kind: OptionKind + exercise_style: ExerciseStyle + premium_convention: PremiumConvention + settlement_style: SettlementStyle + strike: float + expiry_ns: int + settlement_currency: str + premium_currency: str + quote_currency: str + multiplier: float = 1.0 + qty_step: float = 0.0 + settlement_time_ns: Optional[int] = None + fee_schedule_id: str = "" + margin_schedule_id: str = "" + convention_version: str = "" + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "asset_type", _coerce_enum(AssetType, self.asset_type, "asset_type")) + object.__setattr__(self, "option_kind", _coerce_enum(OptionKind, self.option_kind, "option_kind")) + object.__setattr__(self, "exercise_style", _coerce_enum(ExerciseStyle, self.exercise_style, "exercise_style")) + object.__setattr__( + self, + "premium_convention", + _coerce_enum(PremiumConvention, self.premium_convention, "premium_convention"), + ) + object.__setattr__( + self, + "settlement_style", + _coerce_enum(SettlementStyle, self.settlement_style, "settlement_style"), + ) + super().__post_init__() + if self.asset_type is not AssetType.OPTION: + raise ValueError("OptionInstrumentSpec.asset_type must be OPTION") + if not self.venue: + raise ValueError("venue is required") + if not self.underlying_id or not self.underlying_index_id: + raise ValueError("underlying identifiers are required") + if self.strike <= 0.0: + raise ValueError("strike must be > 0") + if int(self.expiry_ns) <= 0: + raise ValueError("expiry_ns must be > 0") + object.__setattr__(self, "expiry_ns", int(self.expiry_ns)) + if self.settlement_time_ns is not None and int(self.settlement_time_ns) <= 0: + raise ValueError("settlement_time_ns must be > 0") + if self.settlement_time_ns is not None: + object.__setattr__(self, "settlement_time_ns", int(self.settlement_time_ns)) + if self.multiplier <= 0.0: + raise ValueError("multiplier must be > 0") + if abs(float(self.multiplier) - float(self.contract_size)) > 1e-15: + raise ValueError("multiplier must match contract_size") + if self.qty_step < 0.0: + raise ValueError("qty_step must be >= 0") + _normalize_quantity_step_alias(self) + for field_name in ("settlement_currency", "premium_currency", "quote_currency"): + value = getattr(self, field_name) + if not value: + raise ValueError(f"{field_name} is required") + object.__setattr__(self, field_name, str(value).upper()) + object.__setattr__(self, "venue", str(self.venue).lower().strip()) + object.__setattr__(self, "underlying_id", str(self.underlying_id).strip()) + object.__setattr__(self, "underlying_index_id", str(self.underlying_index_id).strip()) + _validate_convention_currency_contract(self) + + @property + def convention_signature_tuple(self) -> Tuple: + return ( + self.symbol, + self.venue, + self.underlying_id, + self.option_kind.value, + self.exercise_style.value, + self.premium_convention.value, + self.settlement_style.value, + float(self.strike), + int(self.expiry_ns), + self.premium_currency, + self.settlement_currency, + self.quote_currency, + float(self.multiplier), + float(self.qty_step), + self.fee_schedule_id, + self.margin_schedule_id, + self.convention_version, + ) + + +@dataclass(frozen=True) +class InstrumentRegistrySignature: + count: int + symbols: Tuple[str, ...] + convention_versions: Tuple[str, ...] + signature: Tuple[Tuple, ...] + + +@dataclass(frozen=True) +class OptionInstrumentRegistry: + instruments: Tuple[OptionInstrumentSpec, ...] + + def __post_init__(self) -> None: + if not self.instruments: + raise ValueError("OptionInstrumentRegistry requires at least one instrument") + symbols = [instrument.symbol for instrument in self.instruments] + if len(symbols) != len(set(symbols)): + raise ValueError("option instrument symbols must be unique") + + @classmethod + def from_iterable(cls, instruments: Iterable[OptionInstrumentSpec]) -> "OptionInstrumentRegistry": + return cls(tuple(instruments)) + + @property + def symbols(self) -> Tuple[str, ...]: + return tuple(instrument.symbol for instrument in self.instruments) + + @property + def by_symbol(self) -> Dict[str, OptionInstrumentSpec]: + return {instrument.symbol: instrument for instrument in self.instruments} + + @property + def signature(self) -> InstrumentRegistrySignature: + ordered = tuple(sorted((instrument.convention_signature_tuple for instrument in self.instruments), key=lambda row: row[0])) + return InstrumentRegistrySignature( + count=len(ordered), + symbols=tuple(row[0] for row in ordered), + convention_versions=tuple(row[-1] for row in ordered), + signature=ordered, + ) + + +def _coerce_enum(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc + + +def _validate_convention_currency_contract(spec: OptionInstrumentSpec) -> None: + if spec.premium_convention is PremiumConvention.INVERSE_BASE: + if spec.premium_currency != spec.settlement_currency: + raise ValueError("inverse options require premium_currency == settlement_currency") + if spec.quote_currency == spec.premium_currency: + raise ValueError("inverse options require quote_currency distinct from premium currency") + elif spec.premium_convention is PremiumConvention.LINEAR_QUOTE: + if spec.premium_currency != spec.quote_currency: + raise ValueError("linear quote options require premium_currency == quote_currency") + if spec.settlement_style is SettlementStyle.PHYSICAL: + raise ValueError("linear quote options cannot use physical settlement in Phase 1 schema") + elif spec.premium_convention is PremiumConvention.QUANTO: + if spec.premium_currency == spec.settlement_currency == spec.quote_currency: + raise ValueError("quanto options require at least one distinct premium/settlement/quote currency") + + +def _normalize_quantity_step_alias(spec: OptionInstrumentSpec) -> None: + lot_size = float(spec.lot_size) + qty_step = float(spec.qty_step) + if lot_size > 0.0 and qty_step > 0.0 and abs(lot_size - qty_step) > 1e-15: + raise ValueError("qty_step must match lot_size when both are provided") + if qty_step <= 0.0 and lot_size > 0.0: + object.__setattr__(spec, "qty_step", lot_size) + elif lot_size <= 0.0 and qty_step > 0.0: + object.__setattr__(spec, "lot_size", qty_step) diff --git a/tests/options/test_phase1_data.py b/tests/options/test_phase1_data.py new file mode 100644 index 0000000..eb26da5 --- /dev/null +++ b/tests/options/test_phase1_data.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import CANONICAL_OPTION_CHAIN_COLUMNS, validate_option_chain_frame + + +def _chain() -> pd.DataFrame: + timestamp_ns = int(pd.Timestamp("2026-01-01 00:00:00", tz="UTC").value) + expiry_ns = int(pd.Timestamp("2026-02-01 08:00:00", tz="UTC").value) + return pd.DataFrame( + { + "timestamp_ns": [timestamp_ns, timestamp_ns], + "instrument_id": ["BTC-01FEB26-100000-C.DERIBIT", "BTC-01FEB26-90000-P.DERIBIT"], + "venue": ["DERIBIT", "DERIBIT"], + "underlying_id": ["BTC-PERPETUAL.DERIBIT", "BTC-PERPETUAL.DERIBIT"], + "expiry_ns": [expiry_ns, expiry_ns], + "strike": [100_000.0, 90_000.0], + "option_kind": ["CALL", "PUT"], + "bid_price": [0.010, 0.020], + "bid_size": [10.0, 20.0], + "ask_price": [0.011, 0.022], + "ask_size": [11.0, 21.0], + "mark_price": [0.0105, 0.021], + "last_price": [0.0105, 0.021], + "index_price": [95_000.0, 95_000.0], + "forward_price": [95_500.0, 95_500.0], + "mark_iv": [0.6, 0.7], + "bid_iv": [0.58, 0.68], + "ask_iv": [0.62, 0.72], + "delta": [0.45, -0.35], + "gamma": [0.0001, 0.0002], + "vega": [100.0, 120.0], + "theta": [-10.0, -12.0], + "open_interest": [100.0, 200.0], + "volume": [5.0, 10.0], + "quote_currency": ["USD", "USD"], + "settlement_currency": ["BTC", "BTC"], + "sequence_id": [2, 1], + "source_latency_ns": [1_000, 1_000], + } + ) + + +def test_phase1_canonical_option_chain_columns_are_public(): + assert "timestamp_ns" in CANONICAL_OPTION_CHAIN_COLUMNS + assert "instrument_id" in CANONICAL_OPTION_CHAIN_COLUMNS + assert "bid_price" in CANONICAL_OPTION_CHAIN_COLUMNS + assert "settlement_currency" in CANONICAL_OPTION_CHAIN_COLUMNS + + +def test_phase1_validate_option_chain_frame_sorts_and_normalizes_without_dense_matrix(): + out = validate_option_chain_frame(_chain(), max_spread_bps=2_000) + + assert out["venue"].tolist() == ["deribit", "deribit"] + assert out["option_kind"].tolist() == ["put", "call"] + assert out["quote_currency"].tolist() == ["USD", "USD"] + assert out["settlement_currency"].tolist() == ["BTC", "BTC"] + assert out["sequence_id"].tolist() == [1, 2] + assert out["instrument_id"].tolist() == ["BTC-01FEB26-90000-P.DERIBIT", "BTC-01FEB26-100000-C.DERIBIT"] + + +def test_phase1_validate_option_chain_rejects_missing_required_columns(): + frame = _chain().drop(columns=["forward_price"]) + with pytest.raises(ValueError, match="missing required columns"): + validate_option_chain_frame(frame) + + +def test_phase1_validate_option_chain_rejects_crossed_wide_and_expired_quotes(): + crossed = _chain() + crossed.loc[0, "bid_price"] = 0.02 + crossed.loc[0, "ask_price"] = 0.01 + with pytest.raises(ValueError, match="crossed"): + validate_option_chain_frame(crossed) + + wide = _chain() + wide.loc[0, "ask_price"] = 1.0 + with pytest.raises(ValueError, match="max_spread_bps"): + validate_option_chain_frame(wide, max_spread_bps=10) + + expired = _chain() + expired["expiry_ns"] = expired["timestamp_ns"] + with pytest.raises(ValueError, match="expired"): + validate_option_chain_frame(expired) + + +def test_phase1_validate_option_chain_rejects_duplicate_snapshot_rows(): + frame = pd.concat([_chain(), _chain().iloc[[0]]], ignore_index=True) + with pytest.raises(ValueError, match="duplicate"): + validate_option_chain_frame(frame) diff --git a/tests/options/test_phase1_schema_conventions.py b/tests/options/test_phase1_schema_conventions.py new file mode 100644 index 0000000..3691e64 --- /dev/null +++ b/tests/options/test_phase1_schema_conventions.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import sys + +import pandas as pd +import pytest + +import quantbt +from quantbt import ( + AssetType, + ExerciseStyle, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + SettlementStyle, + binance_european_options_convention, + deribit_inverse_option_convention, + deribit_linear_usdc_option_convention, +) + + +def _expiry_ns() -> int: + return int(pd.Timestamp("2026-12-25 08:00:00", tz="UTC").value) + + +def test_phase1_import_quantbt_does_not_import_nautilus(): + assert quantbt.OptionInstrumentSpec is OptionInstrumentSpec + assert not any(name.startswith("nautilus_trader") for name in sys.modules) + + +def test_phase1_option_asset_type_is_additive_to_core_schema(): + assert AssetType.OPTION.value == "option" + assert AssetType.CRYPTO.value == "crypto" + + +def test_phase1_inverse_option_spec_requires_base_premium_and_settlement_currency(): + spec = OptionInstrumentSpec( + symbol="BTC-25DEC26-100000-C.DERIBIT", + venue="DERIBIT", + underlying_id="BTC-PERPETUAL.DERIBIT", + underlying_index_id="BTC-INDEX.DERIBIT", + option_kind="call", + exercise_style="european", + premium_convention="inverse_base", + settlement_style="cash", + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="btc", + premium_currency="BTC", + quote_currency="USD", + contract_size=1.0, + multiplier=1.0, + fee_schedule_id="deribit_btc_inverse_options", + convention_version="deribit_inverse_v1", + ) + + assert spec.asset_type is AssetType.OPTION + assert spec.option_kind is OptionKind.CALL + assert spec.exercise_style is ExerciseStyle.EUROPEAN + assert spec.premium_convention is PremiumConvention.INVERSE_BASE + assert spec.settlement_style is SettlementStyle.CASH + assert spec.venue == "deribit" + assert spec.premium_currency == "BTC" + assert spec.settlement_currency == "BTC" + assert spec.quote_currency == "USD" + + +def test_phase1_linear_option_spec_rejects_wrong_premium_currency_and_physical_settlement(): + kwargs = dict( + symbol="BTC-25DEC26-100000-P.DERIBIT", + venue="deribit", + underlying_id="BTC-PERPETUAL.DERIBIT", + underlying_index_id="BTC-INDEX.DERIBIT", + option_kind=OptionKind.PUT, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="USDC", + quote_currency="USDC", + ) + with pytest.raises(ValueError, match="premium_currency == quote_currency"): + OptionInstrumentSpec(**kwargs, settlement_style=SettlementStyle.CASH, premium_currency="BTC") + + with pytest.raises(ValueError, match="cannot use physical settlement"): + OptionInstrumentSpec(**kwargs, settlement_style=SettlementStyle.PHYSICAL, premium_currency="USDC") + + +def test_phase1_option_quantity_step_alias_normalizes_with_lot_size(): + spec_from_qty_step = OptionInstrumentSpec( + symbol="BTC-QTY-STEP", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.CALL, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + qty_step=0.1, + ) + assert spec_from_qty_step.lot_size == 0.1 + assert spec_from_qty_step.qty_step == 0.1 + + spec_from_lot_size = OptionInstrumentSpec( + symbol="BTC-LOT-SIZE", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.CALL, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + lot_size=0.2, + ) + assert spec_from_lot_size.lot_size == 0.2 + assert spec_from_lot_size.qty_step == 0.2 + + with pytest.raises(ValueError, match="qty_step must match lot_size"): + OptionInstrumentSpec( + symbol="BTC-BAD-STEP", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.CALL, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + lot_size=0.1, + qty_step=0.2, + ) + + +def test_phase1_option_registry_signature_is_stable_and_rejects_duplicates(): + call = OptionInstrumentSpec( + symbol="BTC-C", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.CALL, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + convention_version="v1", + ) + put = OptionInstrumentSpec( + symbol="BTC-P", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.PUT, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=90_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + convention_version="v1", + ) + + registry = OptionInstrumentRegistry((put, call)) + assert registry.symbols == ("BTC-P", "BTC-C") + assert registry.by_symbol["BTC-C"] is call + assert registry.signature.symbols == ("BTC-C", "BTC-P") + assert registry.signature.convention_versions == ("v1", "v1") + + with pytest.raises(ValueError, match="unique"): + OptionInstrumentRegistry((call, call)) + + +def test_phase1_venue_conventions_are_versioned_and_do_not_claim_exact_margin(): + inverse = deribit_inverse_option_convention(underlying="BTC") + linear = deribit_linear_usdc_option_convention(underlying="ETH") + binance = binance_european_options_convention(underlying="BTC") + + assert inverse.premium_convention is PremiumConvention.INVERSE_BASE + assert inverse.premium_currency == "BTC" + assert inverse.settlement_currency == "BTC" + assert inverse.quote_currency == "USD" + assert inverse.exact_venue_margin is False + + assert linear.premium_convention is PremiumConvention.LINEAR_QUOTE + assert linear.premium_currency == "USDC" + assert linear.settlement_style is SettlementStyle.FUTURE_THEN_CASH + assert linear.exact_venue_margin is False + + assert binance.venue == "binance" + assert binance.premium_currency == "USDT" + assert binance.exact_venue_margin is False + + with pytest.raises(ValueError, match="BTC or ETH"): + deribit_inverse_option_convention(underlying="SOL") diff --git a/upgrade/implement.md b/upgrade/implement.md index 1cf7a8b..d1870b8 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -2844,6 +2844,89 @@ Safety notes: --- +## Phase 17 - Options Backtest Engine + +Planning source: + +- `upgrade/option_backtest_plan/quantbt_options_engine_verified_design.md` +- `upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md` + +Branch: + +- Requested branch `dev/option-engine` is not valid while branch `dev` exists, + because Git cannot store both `refs/heads/dev` and + `refs/heads/dev/option-engine`. +- Active implementation branch: `feat/option-engine`. + +Goal: + +Add an institutional-grade options backtest stack while keeping existing +QuantBT behavior stable: + +- option instrument conventions first; +- ledger-based PnL and expiry accounting; +- ragged option tape, not dense fixed-universe matrices; +- bid/ask execution; +- no lookahead in selector/tape usage; +- optional Nautilus validation, never an import-time dependency. + +### Phase 17.0 - Baseline Protection + +Status: completed. + +Artifacts: + +- `upgrade/option_backtest_plan/phase0_baseline_snapshot.json`; +- `upgrade/option_backtest_plan/phase0_baseline_snapshot.md`. + +Latest result: + +- full non-real regression: `286 passed, 1 skipped, 3 warnings`. +- `import quantbt` did not import `nautilus_trader`. +- existing endpoint support matrices were snapshotted. + +### Phase 17.1 - Domain Schema And Conventions + +Status: completed. + +Implemented: + +- Added `AssetType.OPTION`. +- Added `quantbt.options` bounded context: + - schema; + - venue conventions; + - canonical option-chain data validation. +- Added option enums, `OptionInstrumentSpec`, instrument registry signatures, + and versioned Deribit/Binance convention descriptors. +- Exported Phase 1 schema helpers from top-level `quantbt`. +- Added tests for additive import behavior, inverse/linear convention guards, + registry signatures, option chain normalization, quote guards, expiry guards, + and duplicate snapshot rejection. + +Latest tests: + +- Phase 1 option tests: `12 passed`. +- import smoke: `phase1_import_smoke=pass`. +- full non-real regression: `298 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 17.1: + +- `OptionInstrumentSpec.multiplier` currently mirrors + `InstrumentSpec.contract_size`; later phases should choose one canonical + reporting multiplier or keep both with stronger docs. +- `OptionInstrumentSpec.qty_step` mirrors `InstrumentSpec.lot_size`; later + endpoint docs should settle on one user-facing term for quantity increment. +- One-sided or zero-bid option quotes are not accepted yet; Phase 3 may add + explicit quote-status support. +- Venue convention descriptors do not yet include historical venue fee/margin + schedule snapshots. +- Binance option convention is metadata-safe only, not exact venue margin + certification. +- Pricing, IV, Greeks, tape compilation, package execution, ledger, expiry, + endpoint, and Nautilus validation remain future phases by design. + +--- + ## Backend Selection Guide Use `native_vectorized` when: diff --git a/upgrade/option_backtest_plan/phase1_domain_schema_status.md b/upgrade/option_backtest_plan/phase1_domain_schema_status.md new file mode 100644 index 0000000..3831c3e --- /dev/null +++ b/upgrade/option_backtest_plan/phase1_domain_schema_status.md @@ -0,0 +1,95 @@ +# Phase 1 - Domain Schema And Conventions Status + +Date: 2026-07-23 + +Branch: `feat/option-engine` + +## Scope Completed + +Phase 1 added the option domain boundary without changing existing backtest +execution behavior. + +Implemented: + +- `AssetType.OPTION` in `core/schema.py`. +- `quantbt.options` namespace: + - `schema.py`; + - `conventions.py`; + - `data.py`. +- Option enums: + - `OptionKind`; + - `ExerciseStyle`; + - `PremiumConvention`; + - `SettlementStyle`; + - `OptionDecisionFillPolicy`. +- `OptionInstrumentSpec` extending the existing `InstrumentSpec`. +- `OptionInstrumentRegistry` with deterministic convention signatures. +- Versioned venue convention descriptors: + - Deribit inverse BTC/ETH; + - Deribit linear USDC; + - Binance European options metadata-safe descriptor. +- Canonical long-form option chain validator. +- Public top-level exports from `quantbt`. + +## Domain Guarantees Locked + +- Linear quote options require `premium_currency == quote_currency`. +- Inverse base options require `premium_currency == settlement_currency`. +- Inverse base options require quote currency distinct from premium currency. +- Physical settlement is not allowed for linear quote options in Phase 1. +- Strike, expiry, multiplier, currency fields, venue, and underlying identifiers + are validated. +- Registry symbols must be unique. +- Registry signatures are stable after sorting by symbol. +- Canonical option chain input is long-form, not dense matrix based. +- Crossed quotes, expired rows, duplicate snapshot rows, missing required + columns, invalid option kinds, invalid timestamps, and invalid numeric fields + reject explicitly. +- `import quantbt` does not import `nautilus_trader`. + +## Tests Run + +Commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options core/schema.py __init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options/test_phase1_schema_conventions.py tests/options/test_phase1_data.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase1_import_smoke=pass')" +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +``` + +Results: + +- compileall: pass. +- Phase 1 option tests: `12 passed`. +- import smoke: `phase1_import_smoke=pass`. +- full non-real regression: `298 passed, 1 skipped, 3 warnings`. + +Existing warnings are unrelated to options: + +- one pandas runtime warning in a portfolio missing-data scenario; +- two matplotlib tight-layout warnings in walk-forward quick plot tests. + +## Technical Debt + +- `OptionInstrumentSpec.multiplier` must currently match + `InstrumentSpec.contract_size`. This is transparent and safe, but Phase 2+ + should decide whether one field becomes canonical for option reports. +- `OptionInstrumentSpec.qty_step` mirrors `InstrumentSpec.lot_size`. This is + explicit and tested, but endpoint docs should later settle on one user-facing + term for order quantity increment. +- The canonical chain validator rejects zero bid/ask quotes. That is safest for + executable quote input now; Phase 3 tape work may add explicit one-sided + quote semantics. +- Venue conventions are static descriptors. Historical fee, margin, settlement, + and contract metadata versioning still need venue samples or Nautilus parity. +- Binance European options support is metadata-safe only and does not claim + exact Binance venue margin behavior. +- Pricing, IV, Greeks, surface calibration, execution, ledger, expiry, endpoint, + and Nautilus validation are intentionally not implemented in Phase 1. + +## Conclusion + +Phase 1 is complete and safe to build on. It establishes option identity, +conventions, and chain input validation only; it does not alter any existing +QuantBT backtest engine behavior. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 815abb2..0b6c976 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -127,8 +127,8 @@ Files: - `options/schema.py` - `options/conventions.py` - `options/data.py` -- `tests/options/test_schema.py` -- `tests/options/test_conventions.py` +- `tests/options/test_phase1_schema_conventions.py` +- `tests/options/test_phase1_data.py` Tasks: @@ -156,6 +156,50 @@ Acceptance: validated. - Current non-option schemas remain compatible. +Status: completed. + +Implemented: + +- Added `AssetType.OPTION` without changing existing asset enum values. +- Added the bounded `quantbt.options` namespace for option schema, + conventions, registry signatures, and canonical long-form chain validation. +- Added public top-level exports for Phase 1 option schema helpers. +- Added dependency-free Deribit inverse, Deribit linear USDC, and Binance + European option convention descriptors. +- Added tests for additive imports, inverse-vs-linear convention validation, + registry signatures, canonical chain normalization, quote guards, expiry + guards, and duplicate snapshot rejection. + +Latest local tests: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options core/schema.py __init__.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options/test_phase1_schema_conventions.py tests/options/test_phase1_data.py` + - result: `12 passed` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase1_import_smoke=pass')"` + - result: `phase1_import_smoke=pass` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` + - result: `298 passed, 1 skipped, 3 warnings` + +Technical debt after Phase 1: + +- `OptionInstrumentSpec.multiplier` is currently required to equal the generic + `InstrumentSpec.contract_size`. This is intentional for Phase 1 parity, but + a later phase should decide whether option reporting uses one canonical + multiplier field or keeps both with clearer aliases. +- `OptionInstrumentSpec.qty_step` is an option-domain alias for the generic + `InstrumentSpec.lot_size`; both are normalized and must match when both are + provided. Later execution docs should make one wording canonical for users. +- The canonical chain validator currently rejects zero bid/ask quotes. This is + conservative for executable research input; later tape work may support + one-sided or zero-bid quotes with explicit quote-status fields. +- Venue conventions are static versioned descriptors. Historical fee, margin, + settlement, and instrument-specific schedule snapshots still need venue data + or Nautilus parity samples in later phases. +- Binance option convention support is metadata-safe only. It does not claim + exact venue margin or settlement behavior yet. +- No pricing, IV, Greeks, execution, ledger, expiry, endpoint, or Nautilus + adapter behavior is implemented in Phase 1 by design. + ## Phase 2 - Pricing, IV, Greeks Files: From b5b4b189dab36df610d86c90c31889df2c409db3 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 11:09:50 +0000 Subject: [PATCH 04/15] feat: add options phase 2 analytics --- __init__.py | 38 ++++ options/__init__.py | 38 ++++ options/greeks.py | 173 ++++++++++++++++ options/iv.py | 188 +++++++++++++++++ options/pricing.py | 191 ++++++++++++++++++ options/surface.py | 142 +++++++++++++ tests/options/test_phase2_greeks.py | 77 +++++++ .../test_phase2_inverse_conventions.py | 50 +++++ tests/options/test_phase2_iv.py | 65 ++++++ tests/options/test_phase2_pricing.py | 44 ++++ tests/options/test_phase2_surface.py | 83 ++++++++ upgrade/implement.md | 48 +++++ .../phase2_pricing_iv_greeks_status.md | 114 +++++++++++ .../quantbt_options_engine_execution_plan.md | 53 +++++ 14 files changed, 1304 insertions(+) create mode 100644 options/greeks.py create mode 100644 options/iv.py create mode 100644 options/pricing.py create mode 100644 options/surface.py create mode 100644 tests/options/test_phase2_greeks.py create mode 100644 tests/options/test_phase2_inverse_conventions.py create mode 100644 tests/options/test_phase2_iv.py create mode 100644 tests/options/test_phase2_pricing.py create mode 100644 tests/options/test_phase2_surface.py create mode 100644 upgrade/option_backtest_plan/phase2_pricing_iv_greeks_status.md diff --git a/__init__.py b/__init__.py index 3b8c5a3..18193fc 100644 --- a/__init__.py +++ b/__init__.py @@ -178,17 +178,36 @@ from .options import ( CANONICAL_OPTION_CHAIN_COLUMNS, ExerciseStyle, + IVStatus, + ImpliedVolResult, InstrumentRegistrySignature, OptionDecisionFillPolicy, + OptionGreeks, OptionInstrumentRegistry, OptionInstrumentSpec, OptionKind, OptionVenueConvention, PremiumConvention, SettlementStyle, + SurfaceDiagnostics, + TotalVarianceSurface, binance_european_options_convention, + black76_intrinsic, + black76_parity_residual, + black76_parity_value, + black76_price, deribit_inverse_option_convention, deribit_linear_usdc_option_convention, + implied_vol_black76, + implied_vol_inverse_black76_base, + inverse_black76_greeks_base, + inverse_black76_greeks_quote, + inverse_black76_intrinsic_base, + inverse_black76_parity_residual_base, + inverse_black76_parity_value_base, + inverse_black76_price_base, + linear_black76_greeks, + scale_greeks_to_reporting_currency, validate_option_chain_frame, ) @@ -255,17 +274,36 @@ "format_metrics_report", "CANONICAL_OPTION_CHAIN_COLUMNS", "ExerciseStyle", + "IVStatus", + "ImpliedVolResult", "InstrumentRegistrySignature", "OptionDecisionFillPolicy", + "OptionGreeks", "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", "OptionVenueConvention", "PremiumConvention", "SettlementStyle", + "SurfaceDiagnostics", + "TotalVarianceSurface", "binance_european_options_convention", + "black76_intrinsic", + "black76_parity_residual", + "black76_parity_value", + "black76_price", "deribit_inverse_option_convention", "deribit_linear_usdc_option_convention", + "implied_vol_black76", + "implied_vol_inverse_black76_base", + "inverse_black76_greeks_base", + "inverse_black76_greeks_quote", + "inverse_black76_intrinsic_base", + "inverse_black76_parity_residual_base", + "inverse_black76_parity_value_base", + "inverse_black76_price_base", + "linear_black76_greeks", + "scale_greeks_to_reporting_currency", "validate_option_chain_frame", "LEGACY_PORTFOLIO_MODES", "LEGACY_PORTFOLIO_SIZING_MODES", diff --git a/options/__init__.py b/options/__init__.py index 9346784..12a40e4 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -13,6 +13,24 @@ deribit_linear_usdc_option_convention, ) from .data import CANONICAL_OPTION_CHAIN_COLUMNS, validate_option_chain_frame +from .greeks import ( + OptionGreeks, + inverse_black76_greeks_base, + inverse_black76_greeks_quote, + linear_black76_greeks, + scale_greeks_to_reporting_currency, +) +from .iv import IVStatus, ImpliedVolResult, implied_vol_black76, implied_vol_inverse_black76_base +from .pricing import ( + black76_intrinsic, + black76_parity_residual, + black76_parity_value, + black76_price, + inverse_black76_intrinsic_base, + inverse_black76_parity_residual_base, + inverse_black76_parity_value_base, + inverse_black76_price_base, +) from .schema import ( ExerciseStyle, InstrumentRegistrySignature, @@ -23,6 +41,7 @@ PremiumConvention, SettlementStyle, ) +from .surface import SurfaceDiagnostics, TotalVarianceSurface __all__ = [ "CANONICAL_OPTION_CHAIN_COLUMNS", @@ -32,11 +51,30 @@ "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", + "OptionGreeks", "OptionVenueConvention", "PremiumConvention", "SettlementStyle", + "SurfaceDiagnostics", + "TotalVarianceSurface", "binance_european_options_convention", + "black76_intrinsic", + "black76_parity_residual", + "black76_parity_value", + "black76_price", "deribit_inverse_option_convention", "deribit_linear_usdc_option_convention", + "implied_vol_black76", + "implied_vol_inverse_black76_base", + "inverse_black76_greeks_base", + "inverse_black76_greeks_quote", + "inverse_black76_intrinsic_base", + "inverse_black76_parity_residual_base", + "inverse_black76_parity_value_base", + "inverse_black76_price_base", + "IVStatus", + "ImpliedVolResult", + "linear_black76_greeks", + "scale_greeks_to_reporting_currency", "validate_option_chain_frame", ] diff --git a/options/greeks.py b/options/greeks.py new file mode 100644 index 0000000..e952c1c --- /dev/null +++ b/options/greeks.py @@ -0,0 +1,173 @@ +""" +Option Greeks with explicit units. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Union + +from .pricing import ( + black76_d1_d2, + black76_price, + normal_cdf, + normal_pdf, + _coerce_kind, + _non_negative_float, + _positive_float, +) +from .schema import OptionKind + + +@dataclass(frozen=True) +class OptionGreeks: + price: float + delta: float + gamma: float + vega: float + theta: float + currency: str + unit: str + + @property + def vega_per_vol_point(self) -> float: + """Return vega for a 1 vol-point change, not a 1.0 vol change.""" + return self.vega / 100.0 + + +def linear_black76_greeks( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + currency: str = "QUOTE", +) -> OptionGreeks: + """Return Black-76 Greeks in quote currency per 1 underlying.""" + kind = _coerce_kind(option_kind) + fwd, strike_, tau, vol, df = _validated_greek_inputs(forward, strike, time_to_expiry, volatility, discount) + price = black76_price(fwd, strike_, tau, vol, kind, discount=df) + if tau <= 0.0 or vol <= 0.0: + delta = df if (kind is OptionKind.CALL and fwd > strike_) else 0.0 + if kind is OptionKind.PUT and fwd < strike_: + delta = -df + return OptionGreeks(price=price, delta=delta, gamma=0.0, vega=0.0, theta=0.0, currency=currency, unit="quote") + d1, _ = black76_d1_d2(fwd, strike_, tau, vol) + pdf = normal_pdf(d1) + if kind is OptionKind.CALL: + delta = df * normal_cdf(d1) + else: + delta = df * (normal_cdf(d1) - 1.0) + gamma = df * pdf / (fwd * vol * math.sqrt(tau)) + vega = df * fwd * pdf * math.sqrt(tau) + theta = -0.5 * df * fwd * pdf * vol / math.sqrt(tau) + return OptionGreeks( + price=price, + delta=delta, + gamma=gamma, + vega=vega, + theta=theta, + currency=str(currency).upper(), + unit="quote", + ) + + +def inverse_black76_greeks_base( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + currency: str = "BASE", +) -> OptionGreeks: + """Return inverse option Greeks in native base settlement currency.""" + fwd = _positive_float(forward, "forward") + linear = linear_black76_greeks(fwd, strike, time_to_expiry, volatility, option_kind, discount=discount) + price = linear.price / fwd + delta = linear.delta / fwd - linear.price / (fwd * fwd) + gamma = linear.gamma / fwd - 2.0 * linear.delta / (fwd * fwd) + 2.0 * linear.price / (fwd * fwd * fwd) + vega = linear.vega / fwd + theta = linear.theta / fwd + return OptionGreeks( + price=price, + delta=delta, + gamma=gamma, + vega=vega, + theta=theta, + currency=str(currency).upper(), + unit="base", + ) + + +def inverse_black76_greeks_quote( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + currency: str = "QUOTE", +) -> OptionGreeks: + """ + Return inverse option Greeks converted to quote reporting currency. + + Under the Phase 2 inverse convention, quote-reporting value equals the + corresponding linear Black-76 value, so Greeks match the linear Greeks. + """ + return linear_black76_greeks( + forward, + strike, + time_to_expiry, + volatility, + option_kind, + discount=discount, + currency=currency, + ) + + +def scale_greeks_to_reporting_currency( + greeks: OptionGreeks, + conversion_rate: float, + *, + reporting_currency: str, + vega_per_vol_point: bool = False, +) -> OptionGreeks: + """ + Statically scale Greeks into a reporting currency. + + This is a pure currency conversion helper. It does not add chain-rule delta + from a conversion rate that itself depends on the underlying. + """ + rate = _positive_float(conversion_rate, "conversion_rate") + vega_scale = 0.01 if vega_per_vol_point else 1.0 + return OptionGreeks( + price=greeks.price * rate, + delta=greeks.delta * rate, + gamma=greeks.gamma * rate, + vega=greeks.vega * rate * vega_scale, + theta=greeks.theta * rate, + currency=str(reporting_currency).upper(), + unit=f"{greeks.unit}_reported", + ) + + +def _validated_greek_inputs( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + discount: float, +) -> tuple[float, float, float, float, float]: + return ( + _positive_float(forward, "forward"), + _positive_float(strike, "strike"), + _non_negative_float(time_to_expiry, "time_to_expiry"), + _non_negative_float(volatility, "volatility"), + _positive_float(discount, "discount"), + ) diff --git a/options/iv.py b/options/iv.py new file mode 100644 index 0000000..dbd60aa --- /dev/null +++ b/options/iv.py @@ -0,0 +1,188 @@ +""" +Deterministic implied-volatility solvers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import math +from typing import Callable, Union + +from .pricing import ( + black76_intrinsic, + black76_price, + inverse_black76_intrinsic_base, + inverse_black76_price_base, + _coerce_kind, + _non_negative_float, + _positive_float, +) +from .schema import OptionKind + + +class IVStatus(str, Enum): + OK = "ok" + BELOW_INTRINSIC = "below_intrinsic" + ABOVE_MAX_PRICE = "above_max_price" + INVALID_INPUT = "invalid_input" + NOT_BRACKETED = "not_bracketed" + MAX_ITERATIONS = "max_iterations" + + +@dataclass(frozen=True) +class ImpliedVolResult: + implied_vol: float + status: IVStatus + iterations: int + model_price: float + lower_bound: float + upper_bound: float + residual: float + + @property + def ok(self) -> bool: + return self.status is IVStatus.OK + + +def implied_vol_black76( + price: float, + forward: float, + strike: float, + time_to_expiry: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + tolerance: float = 1e-12, + max_iterations: int = 100, + vol_lower: float = 0.0, + vol_upper: float = 5.0, + max_vol_upper: float = 20.0, +) -> ImpliedVolResult: + """Solve linear Black-76 implied volatility with bracketed bisection.""" + try: + kind = _coerce_kind(option_kind) + target = _non_negative_float(price, "price") + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + tau = _non_negative_float(time_to_expiry, "time_to_expiry") + df = _positive_float(discount, "discount") + except (TypeError, ValueError): + return _invalid_result(price) + lower_bound = black76_intrinsic(fwd, strike_, kind, discount=df) + upper_bound = _black76_upper_bound(fwd, strike_, kind, discount=df) + return _solve_bisection( + target, + lower_bound, + upper_bound, + lambda vol: black76_price(fwd, strike_, tau, vol, kind, discount=df), + tolerance=tolerance, + max_iterations=max_iterations, + vol_lower=vol_lower, + vol_upper=vol_upper, + max_vol_upper=max_vol_upper, + ) + + +def implied_vol_inverse_black76_base( + price_base: float, + forward: float, + strike: float, + time_to_expiry: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + tolerance: float = 1e-12, + max_iterations: int = 100, + vol_lower: float = 0.0, + vol_upper: float = 5.0, + max_vol_upper: float = 20.0, +) -> ImpliedVolResult: + """Solve inverse Black-76 implied volatility from base-currency price.""" + try: + kind = _coerce_kind(option_kind) + target = _non_negative_float(price_base, "price_base") + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + tau = _non_negative_float(time_to_expiry, "time_to_expiry") + df = _positive_float(discount, "discount") + except (TypeError, ValueError): + return _invalid_result(price_base) + lower_bound = inverse_black76_intrinsic_base(fwd, strike_, kind, discount=df) + upper_bound = _black76_upper_bound(fwd, strike_, kind, discount=df) / fwd + return _solve_bisection( + target, + lower_bound, + upper_bound, + lambda vol: inverse_black76_price_base(fwd, strike_, tau, vol, kind, discount=df), + tolerance=tolerance, + max_iterations=max_iterations, + vol_lower=vol_lower, + vol_upper=vol_upper, + max_vol_upper=max_vol_upper, + ) + + +def _solve_bisection( + target: float, + lower_bound: float, + upper_bound: float, + price_fn: Callable[[float], float], + *, + tolerance: float, + max_iterations: int, + vol_lower: float, + vol_upper: float, + max_vol_upper: float, +) -> ImpliedVolResult: + tol = _positive_float(tolerance, "tolerance") + if max_iterations <= 0: + return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, lower_bound, upper_bound, math.nan) + lower_vol = _non_negative_float(vol_lower, "vol_lower") + upper_vol = _positive_float(vol_upper, "vol_upper") + max_upper = _positive_float(max_vol_upper, "max_vol_upper") + if upper_vol <= lower_vol: + return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, lower_bound, upper_bound, math.nan) + if target < lower_bound - tol: + return ImpliedVolResult(math.nan, IVStatus.BELOW_INTRINSIC, 0, lower_bound, lower_bound, upper_bound, target - lower_bound) + if target > upper_bound + tol: + return ImpliedVolResult(math.nan, IVStatus.ABOVE_MAX_PRICE, 0, upper_bound, lower_bound, upper_bound, target - upper_bound) + if abs(target - lower_bound) <= tol: + return ImpliedVolResult(0.0, IVStatus.OK, 0, lower_bound, lower_bound, upper_bound, lower_bound - target) + + lower_price = price_fn(lower_vol) + upper_price = price_fn(upper_vol) + while upper_price < target and upper_vol < max_upper: + upper_vol = min(upper_vol * 2.0, max_upper) + upper_price = price_fn(upper_vol) + if target < lower_price - tol or upper_price < target - tol: + return ImpliedVolResult(math.nan, IVStatus.NOT_BRACKETED, 0, upper_price, lower_bound, upper_bound, upper_price - target) + + mid = 0.5 * (lower_vol + upper_vol) + mid_price = price_fn(mid) + for iteration in range(1, max_iterations + 1): + mid = 0.5 * (lower_vol + upper_vol) + mid_price = price_fn(mid) + residual = mid_price - target + if abs(residual) <= tol: + return ImpliedVolResult(mid, IVStatus.OK, iteration, mid_price, lower_bound, upper_bound, residual) + if mid_price < target: + lower_vol = mid + else: + upper_vol = mid + return ImpliedVolResult(mid, IVStatus.MAX_ITERATIONS, max_iterations, mid_price, lower_bound, upper_bound, mid_price - target) + + +def _black76_upper_bound(forward: float, strike: float, option_kind: OptionKind, *, discount: float) -> float: + if option_kind is OptionKind.CALL: + return discount * forward + return discount * strike + + +def _invalid_result(price: float) -> ImpliedVolResult: + try: + raw = float(price) + except (TypeError, ValueError): + raw = math.nan + target = raw if math.isfinite(raw) else math.nan + return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, math.nan, math.nan, target) diff --git a/options/pricing.py b/options/pricing.py new file mode 100644 index 0000000..05e48b3 --- /dev/null +++ b/options/pricing.py @@ -0,0 +1,191 @@ +""" +Option pricing primitives. + +Phase 2 intentionally keeps pricing deterministic and scalar. Execution, +margin, expiry, and ledger accounting are added in later phases. +""" + +from __future__ import annotations + +import math +from typing import Union + +from .schema import OptionKind + + +Number = Union[int, float] + + +def black76_price( + forward: Number, + strike: Number, + time_to_expiry: Number, + volatility: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """Return linear Black-76 option value in quote currency per 1 underlying.""" + kind = _coerce_kind(option_kind) + fwd, strike_, tau, vol, df = _validate_inputs(forward, strike, time_to_expiry, volatility, discount) + intrinsic = black76_intrinsic(fwd, strike_, kind, discount=df) + if tau <= 0.0 or vol <= 0.0: + return intrinsic + d1, d2 = black76_d1_d2(fwd, strike_, tau, vol) + if kind is OptionKind.CALL: + return df * (fwd * normal_cdf(d1) - strike_ * normal_cdf(d2)) + return df * (strike_ * normal_cdf(-d2) - fwd * normal_cdf(-d1)) + + +def black76_intrinsic( + forward: Number, + strike: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """Return discounted intrinsic value in quote currency.""" + kind = _coerce_kind(option_kind) + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + df = _positive_float(discount, "discount") + if kind is OptionKind.CALL: + return df * max(fwd - strike_, 0.0) + return df * max(strike_ - fwd, 0.0) + + +def black76_parity_value(forward: Number, strike: Number, *, discount: Number = 1.0) -> float: + """Return theoretical linear call-put parity value: C - P.""" + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + df = _positive_float(discount, "discount") + return df * (fwd - strike_) + + +def black76_parity_residual( + call_price: Number, + put_price: Number, + forward: Number, + strike: Number, + *, + discount: Number = 1.0, +) -> float: + """Return residual of linear Black-76 put-call parity.""" + return float(call_price) - float(put_price) - black76_parity_value(forward, strike, discount=discount) + + +def inverse_black76_price_base( + forward: Number, + strike: Number, + time_to_expiry: Number, + volatility: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """ + Return inverse option value in base settlement currency. + + The Phase 2 convention prices inverse BTC/ETH options as the corresponding + forward Black-76 quote-currency option divided by forward. This gives the + expiry payoff shape `max(S-K, 0) / S` for calls and `max(K-S, 0) / S` for + puts, and locks inverse parity to `DF * (1 - K/F)`. + """ + fwd = _positive_float(forward, "forward") + return black76_price(fwd, strike, time_to_expiry, volatility, option_kind, discount=discount) / fwd + + +def inverse_black76_intrinsic_base( + forward: Number, + strike: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """Return inverse intrinsic value in base settlement currency.""" + fwd = _positive_float(forward, "forward") + return black76_intrinsic(fwd, strike, option_kind, discount=discount) / fwd + + +def inverse_black76_parity_value_base(forward: Number, strike: Number, *, discount: Number = 1.0) -> float: + """Return inverse call-put parity value in base settlement currency.""" + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + df = _positive_float(discount, "discount") + return df * (1.0 - strike_ / fwd) + + +def inverse_black76_parity_residual_base( + call_price_base: Number, + put_price_base: Number, + forward: Number, + strike: Number, + *, + discount: Number = 1.0, +) -> float: + """Return residual of inverse put-call parity in base settlement currency.""" + return ( + float(call_price_base) + - float(put_price_base) + - inverse_black76_parity_value_base(forward, strike, discount=discount) + ) + + +def black76_d1_d2(forward: Number, strike: Number, time_to_expiry: Number, volatility: Number) -> tuple[float, float]: + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + tau = _non_negative_float(time_to_expiry, "time_to_expiry") + vol = _non_negative_float(volatility, "volatility") + if tau <= 0.0 or vol <= 0.0: + raise ValueError("d1/d2 require time_to_expiry > 0 and volatility > 0") + vol_sqrt_t = vol * math.sqrt(tau) + d1 = (math.log(fwd / strike_) + 0.5 * vol * vol * tau) / vol_sqrt_t + return d1, d1 - vol_sqrt_t + + +def normal_pdf(x: Number) -> float: + value = float(x) + return math.exp(-0.5 * value * value) / math.sqrt(2.0 * math.pi) + + +def normal_cdf(x: Number) -> float: + return 0.5 * (1.0 + math.erf(float(x) / math.sqrt(2.0))) + + +def _coerce_kind(option_kind: Union[OptionKind, str]) -> OptionKind: + if isinstance(option_kind, OptionKind): + return option_kind + try: + return OptionKind(str(option_kind).lower()) + except ValueError as exc: + raise ValueError("option_kind must be call or put") from exc + + +def _validate_inputs( + forward: Number, + strike: Number, + time_to_expiry: Number, + volatility: Number, + discount: Number, +) -> tuple[float, float, float, float, float]: + return ( + _positive_float(forward, "forward"), + _positive_float(strike, "strike"), + _non_negative_float(time_to_expiry, "time_to_expiry"), + _non_negative_float(volatility, "volatility"), + _positive_float(discount, "discount"), + ) + + +def _positive_float(value: Number, name: str) -> float: + out = float(value) + if not math.isfinite(out) or out <= 0.0: + raise ValueError(f"{name} must be finite and > 0") + return out + + +def _non_negative_float(value: Number, name: str) -> float: + out = float(value) + if not math.isfinite(out) or out < 0.0: + raise ValueError(f"{name} must be finite and >= 0") + return out diff --git a/options/surface.py b/options/surface.py new file mode 100644 index 0000000..4018795 --- /dev/null +++ b/options/surface.py @@ -0,0 +1,142 @@ +""" +Minimal option surface diagnostics. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, Tuple + +import numpy as np +import pandas as pd + + +@dataclass(frozen=True) +class SurfaceDiagnostics: + positive_total_variance: bool + no_future_timestamps: bool + expiries_after_snapshot: bool + calendar_total_variance_non_decreasing: bool + butterfly_convexity_checked: bool + notes: Tuple[str, ...] + + @property + def pass_basic(self) -> bool: + return ( + self.positive_total_variance + and self.no_future_timestamps + and self.expiries_after_snapshot + and self.calendar_total_variance_non_decreasing + ) + + +@dataclass(frozen=True) +class TotalVarianceSurface: + timestamp_ns: int + expiry_ns: np.ndarray + strike: np.ndarray + total_variance: np.ndarray + + def __post_init__(self) -> None: + timestamp = int(self.timestamp_ns) + expiry = np.asarray(self.expiry_ns, dtype=np.int64) + strike = np.asarray(self.strike, dtype=np.float64) + variance = np.asarray(self.total_variance, dtype=np.float64) + if timestamp <= 0: + raise ValueError("timestamp_ns must be > 0") + if expiry.ndim != 1 or strike.ndim != 1 or variance.ndim != 1: + raise ValueError("surface arrays must be 1-D") + if len(expiry) == 0 or len(expiry) != len(strike) or len(expiry) != len(variance): + raise ValueError("surface arrays must be non-empty and equal length") + if bool((expiry <= timestamp).any()): + raise ValueError("surface expiry_ns must be after timestamp_ns") + if bool((strike <= 0.0).any()): + raise ValueError("surface strikes must be > 0") + if bool((~np.isfinite(variance)).any()) or bool((variance < 0.0).any()): + raise ValueError("total_variance must be finite and >= 0") + order = np.lexsort((strike, expiry)) + object.__setattr__(self, "timestamp_ns", timestamp) + object.__setattr__(self, "expiry_ns", expiry[order]) + object.__setattr__(self, "strike", strike[order]) + object.__setattr__(self, "total_variance", variance[order]) + + @classmethod + def from_snapshot_frame( + cls, + frame: pd.DataFrame, + *, + timestamp_ns: int, + volatility_column: str = "mark_iv", + ) -> "TotalVarianceSurface": + required = {"timestamp_ns", "expiry_ns", "strike", volatility_column} + missing = sorted(required.difference(frame.columns)) + if missing: + raise ValueError(f"surface frame missing required columns: {missing}") + timestamp = int(timestamp_ns) + future_rows = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") > timestamp] + if len(future_rows) > 0: + raise ValueError("surface calibration cannot include future timestamp rows") + snapshot = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") == timestamp].copy() + if snapshot.empty: + raise ValueError("surface snapshot has no rows for timestamp_ns") + expiry = pd.to_numeric(snapshot["expiry_ns"], errors="raise").astype("int64").to_numpy() + strike = pd.to_numeric(snapshot["strike"], errors="raise").astype("float64").to_numpy() + vol = pd.to_numeric(snapshot[volatility_column], errors="raise").astype("float64").to_numpy() + tau_years = (expiry.astype(np.float64) - float(timestamp)) / (365.0 * 24.0 * 60.0 * 60.0 * 1_000_000_000.0) + total_variance = vol * vol * tau_years + return cls(timestamp_ns=timestamp, expiry_ns=expiry, strike=strike, total_variance=total_variance) + + @property + def expiries(self) -> np.ndarray: + return np.unique(self.expiry_ns) + + def interpolate_total_variance(self, *, expiry_ns: int, strike: float) -> float: + """Interpolate total variance by strike first, then expiry.""" + target_expiry = int(expiry_ns) + target_strike = float(strike) + if target_expiry <= self.timestamp_ns: + raise ValueError("target expiry must be after surface timestamp") + if target_strike <= 0.0: + raise ValueError("target strike must be > 0") + expiries = self.expiries + per_expiry = np.array([self._interpolate_strike(expiry, target_strike) for expiry in expiries], dtype=np.float64) + if len(expiries) == 1: + return float(per_expiry[0]) + return float(np.interp(float(target_expiry), expiries.astype(np.float64), per_expiry)) + + def diagnostics(self) -> SurfaceDiagnostics: + notes = ["butterfly convexity is placeholder-only in Phase 2"] + by_strike = _group_by_strike(self.expiry_ns, self.strike, self.total_variance) + calendar_ok = True + for rows in by_strike.values(): + rows_sorted = sorted(rows, key=lambda item: item[0]) + variances = np.array([item[1] for item in rows_sorted], dtype=np.float64) + if len(variances) > 1 and bool((np.diff(variances) < -1e-12).any()): + calendar_ok = False + break + return SurfaceDiagnostics( + positive_total_variance=bool((self.total_variance >= 0.0).all()), + no_future_timestamps=True, + expiries_after_snapshot=bool((self.expiry_ns > self.timestamp_ns).all()), + calendar_total_variance_non_decreasing=calendar_ok, + butterfly_convexity_checked=False, + notes=tuple(notes), + ) + + def _interpolate_strike(self, expiry_ns: int, strike: float) -> float: + mask = self.expiry_ns == int(expiry_ns) + strikes = self.strike[mask] + variances = self.total_variance[mask] + if len(strikes) == 0: + raise ValueError("expiry not found") + if len(strikes) == 1: + return float(variances[0]) + order = np.argsort(strikes) + return float(np.interp(strike, strikes[order], variances[order])) + + +def _group_by_strike(expiry_ns: Iterable[int], strike: Iterable[float], total_variance: Iterable[float]) -> Dict[float, list[tuple[int, float]]]: + grouped: Dict[float, list[tuple[int, float]]] = {} + for expiry, strike_value, variance in zip(expiry_ns, strike, total_variance): + grouped.setdefault(float(strike_value), []).append((int(expiry), float(variance))) + return grouped diff --git a/tests/options/test_phase2_greeks.py b/tests/options/test_phase2_greeks.py new file mode 100644 index 0000000..a621efe --- /dev/null +++ b/tests/options/test_phase2_greeks.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + black76_price, + inverse_black76_greeks_base, + inverse_black76_greeks_quote, + inverse_black76_price_base, + linear_black76_greeks, + scale_greeks_to_reporting_currency, +) + + +def _central_delta(fn, x: float, eps: float) -> float: + return (fn(x + eps) - fn(x - eps)) / (2.0 * eps) + + +def _central_gamma(fn, x: float, eps: float) -> float: + return (fn(x + eps) - 2.0 * fn(x) + fn(x - eps)) / (eps * eps) + + +def test_phase2_linear_greeks_match_finite_difference_delta_gamma_vega(): + forward = 100.0 + strike = 97.0 + tau = 0.6 + vol = 0.38 + discount = 0.99 + greeks = linear_black76_greeks(forward, strike, tau, vol, "call", discount=discount, currency="USD") + + price_by_forward = lambda fwd: black76_price(fwd, strike, tau, vol, "call", discount=discount) + price_by_vol = lambda sigma: black76_price(forward, strike, tau, sigma, "call", discount=discount) + + assert greeks.currency == "USD" + assert greeks.unit == "quote" + assert greeks.delta == pytest.approx(_central_delta(price_by_forward, forward, 1e-3), rel=1e-7) + assert greeks.gamma == pytest.approx(_central_gamma(price_by_forward, forward, 1e-2), rel=1e-5) + assert greeks.vega == pytest.approx(_central_delta(price_by_vol, vol, 1e-5), rel=1e-7) + assert greeks.vega_per_vol_point == pytest.approx(greeks.vega / 100.0) + assert greeks.theta < 0.0 + + +def test_phase2_inverse_base_greeks_match_finite_difference(): + forward = 95_000.0 + strike = 100_000.0 + tau = 0.25 + vol = 0.7 + greeks = inverse_black76_greeks_base(forward, strike, tau, vol, "put", currency="BTC") + + price_by_forward = lambda fwd: inverse_black76_price_base(fwd, strike, tau, vol, "put") + price_by_vol = lambda sigma: inverse_black76_price_base(forward, strike, tau, sigma, "put") + + assert greeks.currency == "BTC" + assert greeks.unit == "base" + assert greeks.delta == pytest.approx(_central_delta(price_by_forward, forward, 1.0), rel=1e-6) + assert greeks.gamma == pytest.approx(_central_gamma(price_by_forward, forward, 10.0), rel=1e-4) + assert greeks.vega == pytest.approx(_central_delta(price_by_vol, vol, 1e-5), rel=1e-7) + + +def test_phase2_inverse_quote_reporting_matches_linear_quote_greeks(): + inverse_quote = inverse_black76_greeks_quote(90_000.0, 95_000.0, 0.5, 0.6, "call", currency="USD") + linear = linear_black76_greeks(90_000.0, 95_000.0, 0.5, 0.6, "call", currency="USD") + + assert inverse_quote.price == pytest.approx(linear.price) + assert inverse_quote.delta == pytest.approx(linear.delta) + assert inverse_quote.gamma == pytest.approx(linear.gamma) + assert inverse_quote.vega == pytest.approx(linear.vega) + assert inverse_quote.theta == pytest.approx(linear.theta) + + +def test_phase2_static_reporting_currency_scaling_is_explicit(): + native = linear_black76_greeks(100.0, 100.0, 1.0, 0.5, "call", currency="USD") + scaled = scale_greeks_to_reporting_currency(native, 25_000.0, reporting_currency="VND", vega_per_vol_point=True) + + assert scaled.currency == "VND" + assert scaled.price == pytest.approx(native.price * 25_000.0) + assert scaled.vega == pytest.approx(native.vega * 25_000.0 / 100.0) diff --git a/tests/options/test_phase2_inverse_conventions.py b/tests/options/test_phase2_inverse_conventions.py new file mode 100644 index 0000000..8a932f4 --- /dev/null +++ b/tests/options/test_phase2_inverse_conventions.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + black76_price, + inverse_black76_intrinsic_base, + inverse_black76_parity_residual_base, + inverse_black76_parity_value_base, + inverse_black76_price_base, +) + + +def test_phase2_inverse_price_is_linear_quote_price_divided_by_forward(): + forward = 95_000.0 + strike = 100_000.0 + tau = 45.0 / 365.0 + vol = 0.68 + + call_base = inverse_black76_price_base(forward, strike, tau, vol, "call") + put_base = inverse_black76_price_base(forward, strike, tau, vol, "put") + call_quote = black76_price(forward, strike, tau, vol, "call") + put_quote = black76_price(forward, strike, tau, vol, "put") + + assert call_base == pytest.approx(call_quote / forward, rel=0, abs=1e-15) + assert put_base == pytest.approx(put_quote / forward, rel=0, abs=1e-15) + + +def test_phase2_inverse_put_call_parity_is_base_currency_parity(): + forward = 105_000.0 + strike = 100_000.0 + tau = 0.5 + vol = 0.55 + discount = 0.99 + + call_base = inverse_black76_price_base(forward, strike, tau, vol, "call", discount=discount) + put_base = inverse_black76_price_base(forward, strike, tau, vol, "put", discount=discount) + + assert inverse_black76_parity_value_base(forward, strike, discount=discount) == pytest.approx( + discount * (1.0 - strike / forward) + ) + assert inverse_black76_parity_residual_base(call_base, put_base, forward, strike, discount=discount) == pytest.approx( + 0.0, + abs=1e-14, + ) + + +def test_phase2_inverse_intrinsic_matches_base_payoff_shape(): + assert inverse_black76_intrinsic_base(110_000.0, 100_000.0, "call") == pytest.approx(10_000.0 / 110_000.0) + assert inverse_black76_intrinsic_base(90_000.0, 100_000.0, "put") == pytest.approx(10_000.0 / 90_000.0) diff --git a/tests/options/test_phase2_iv.py b/tests/options/test_phase2_iv.py new file mode 100644 index 0000000..d6f1f90 --- /dev/null +++ b/tests/options/test_phase2_iv.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import math + +import pytest + +from quantbt import ( + IVStatus, + black76_intrinsic, + black76_price, + implied_vol_black76, + implied_vol_inverse_black76_base, + inverse_black76_price_base, +) + + +def test_phase2_implied_vol_recovers_linear_generated_price(): + expected_vol = 0.73 + price = black76_price(100.0, 105.0, 0.8, expected_vol, "call", discount=0.98) + + result = implied_vol_black76(price, 100.0, 105.0, 0.8, "call", discount=0.98) + + assert result.status is IVStatus.OK + assert result.ok + assert result.implied_vol == pytest.approx(expected_vol, abs=1e-10) + assert result.model_price == pytest.approx(price, abs=1e-10) + + +def test_phase2_implied_vol_recovers_inverse_base_generated_price(): + expected_vol = 0.61 + price = inverse_black76_price_base(95_000.0, 100_000.0, 0.4, expected_vol, "put") + + result = implied_vol_inverse_black76_base(price, 95_000.0, 100_000.0, 0.4, "put") + + assert result.status is IVStatus.OK + assert result.implied_vol == pytest.approx(expected_vol, abs=1e-10) + assert result.model_price == pytest.approx(price, abs=1e-12) + + +def test_phase2_implied_vol_returns_zero_at_intrinsic_boundary(): + intrinsic = black76_intrinsic(120.0, 100.0, "call") + + result = implied_vol_black76(intrinsic, 120.0, 100.0, 1.0, "call") + + assert result.status is IVStatus.OK + assert result.implied_vol == 0.0 + + +def test_phase2_implied_vol_invalid_prices_have_explicit_status(): + below = implied_vol_black76(0.5, 120.0, 100.0, 1.0, "call") + above = implied_vol_black76(121.0, 120.0, 100.0, 1.0, "call") + invalid = implied_vol_black76(math.nan, 120.0, 100.0, 1.0, "call") + + assert below.status is IVStatus.BELOW_INTRINSIC + assert above.status is IVStatus.ABOVE_MAX_PRICE + assert invalid.status is IVStatus.INVALID_INPUT + assert math.isnan(below.implied_vol) + assert math.isnan(above.implied_vol) + + +def test_phase2_implied_vol_invalid_type_returns_status_not_exception(): + result = implied_vol_black76("bad-price", 120.0, 100.0, 1.0, "call") + + assert result.status is IVStatus.INVALID_INPUT + assert math.isnan(result.implied_vol) diff --git a/tests/options/test_phase2_pricing.py b/tests/options/test_phase2_pricing.py new file mode 100644 index 0000000..f18d344 --- /dev/null +++ b/tests/options/test_phase2_pricing.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import math + +import pytest + +from quantbt import ( + black76_intrinsic, + black76_parity_residual, + black76_parity_value, + black76_price, +) + + +def test_phase2_linear_black76_put_call_parity_and_intrinsic_limits(): + forward = 100.0 + strike = 95.0 + tau = 0.75 + vol = 0.42 + discount = 0.97 + + call = black76_price(forward, strike, tau, vol, "call", discount=discount) + put = black76_price(forward, strike, tau, vol, "put", discount=discount) + + assert call > black76_intrinsic(forward, strike, "call", discount=discount) + assert put > black76_intrinsic(forward, strike, "put", discount=discount) + assert black76_parity_value(forward, strike, discount=discount) == pytest.approx(discount * (forward - strike)) + assert black76_parity_residual(call, put, forward, strike, discount=discount) == pytest.approx(0.0, abs=1e-10) + + +def test_phase2_linear_black76_zero_time_or_zero_vol_returns_intrinsic(): + assert black76_price(100.0, 90.0, 0.0, 0.5, "call") == pytest.approx(10.0) + assert black76_price(100.0, 110.0, 1.0, 0.0, "put") == pytest.approx(10.0) + + +def test_phase2_linear_black76_rejects_invalid_inputs(): + with pytest.raises(ValueError, match="forward"): + black76_price(0.0, 100.0, 1.0, 0.2, "call") + with pytest.raises(ValueError, match="strike"): + black76_price(100.0, -1.0, 1.0, 0.2, "call") + with pytest.raises(ValueError, match="option_kind"): + black76_price(100.0, 100.0, 1.0, 0.2, "straddle") + with pytest.raises(ValueError, match="volatility"): + black76_price(100.0, 100.0, 1.0, math.nan, "call") diff --git a/tests/options/test_phase2_surface.py b/tests/options/test_phase2_surface.py new file mode 100644 index 0000000..8d8dd02 --- /dev/null +++ b/tests/options/test_phase2_surface.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import TotalVarianceSurface + + +def _timestamp(value: str) -> int: + return int(pd.Timestamp(value, tz="UTC").value) + + +def _surface_frame() -> pd.DataFrame: + ts = _timestamp("2026-01-01 00:00:00") + expiry_1 = _timestamp("2026-02-01 08:00:00") + expiry_2 = _timestamp("2026-03-01 08:00:00") + return pd.DataFrame( + { + "timestamp_ns": [ts, ts, ts, ts], + "expiry_ns": [expiry_1, expiry_1, expiry_2, expiry_2], + "strike": [90_000.0, 100_000.0, 90_000.0, 100_000.0], + "mark_iv": [0.60, 0.62, 0.65, 0.67], + } + ) + + +def test_phase2_total_variance_surface_interpolates_strike_then_expiry(): + frame = _surface_frame() + ts = int(frame["timestamp_ns"].iloc[0]) + surface = TotalVarianceSurface.from_snapshot_frame(frame, timestamp_ns=ts) + + expiry_1, expiry_2 = sorted(frame["expiry_ns"].unique()) + strike_mid = 95_000.0 + expiry_mid = int((expiry_1 + expiry_2) // 2) + + v1 = surface.interpolate_total_variance(expiry_ns=int(expiry_1), strike=strike_mid) + v2 = surface.interpolate_total_variance(expiry_ns=int(expiry_2), strike=strike_mid) + vmid = surface.interpolate_total_variance(expiry_ns=expiry_mid, strike=strike_mid) + + assert v1 > 0.0 + assert v2 > v1 + assert v1 < vmid < v2 + assert surface.diagnostics().pass_basic + assert surface.diagnostics().butterfly_convexity_checked is False + + +def test_phase2_total_variance_surface_rejects_future_timestamp_rows(): + frame = _surface_frame() + ts = int(frame["timestamp_ns"].iloc[0]) + frame.loc[0, "timestamp_ns"] = ts + 1 + + with pytest.raises(ValueError, match="future timestamp"): + TotalVarianceSurface.from_snapshot_frame(frame, timestamp_ns=ts) + + +def test_phase2_total_variance_surface_flags_calendar_variance_decrease(): + ts = _timestamp("2026-01-01 00:00:00") + expiry_1 = _timestamp("2026-02-01 08:00:00") + expiry_2 = _timestamp("2026-03-01 08:00:00") + surface = TotalVarianceSurface( + timestamp_ns=ts, + expiry_ns=[expiry_1, expiry_2], + strike=[100_000.0, 100_000.0], + total_variance=[0.20, 0.10], + ) + + diag = surface.diagnostics() + assert diag.positive_total_variance + assert diag.calendar_total_variance_non_decreasing is False + assert diag.pass_basic is False + + +def test_phase2_total_variance_surface_rejects_expired_or_negative_variance(): + ts = _timestamp("2026-01-01 00:00:00") + with pytest.raises(ValueError, match="after timestamp"): + TotalVarianceSurface(timestamp_ns=ts, expiry_ns=[ts], strike=[100_000.0], total_variance=[0.1]) + with pytest.raises(ValueError, match="total_variance"): + TotalVarianceSurface( + timestamp_ns=ts, + expiry_ns=[_timestamp("2026-02-01 08:00:00")], + strike=[100_000.0], + total_variance=[-0.1], + ) diff --git a/upgrade/implement.md b/upgrade/implement.md index d1870b8..b1cb689 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -2925,6 +2925,54 @@ Technical debt after Phase 17.1: - Pricing, IV, Greeks, tape compilation, package execution, ledger, expiry, endpoint, and Nautilus validation remain future phases by design. +### Phase 17.2 - Pricing, IV, Greeks + +Status: completed. + +Implemented: + +- Added deterministic scalar option analytics primitives: + - linear Black-76 call/put pricing; + - linear intrinsic and put-call parity; + - inverse base-currency forward pricing; + - inverse intrinsic and base-currency parity; + - linear quote Greeks; + - inverse native base Greeks; + - inverse quote-reporting Greeks; + - static reporting-currency Greek scaling; + - bisection IV solvers with explicit status enum; + - minimal total variance surface and diagnostics. +- Exported Phase 2 analytics helpers from top-level `quantbt`. +- Added tests for: + - linear parity; + - inverse parity; + - IV recovery; + - invalid IV status; + - finite-difference delta/gamma/vega; + - no-future-timestamp surface calibration; + - basic calendar total variance diagnostics. + +Latest tests: + +- options tests: `31 passed`. +- fastmath scan: no matches in `options` or `tests/options`. +- import smoke: `phase2_import_smoke=pass`. +- full non-real regression: `317 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 17.2: + +- Pricing/Greeks are scalar primitives; vectorized or Numba kernels should be + added only after Phase 3/4 tape and execution array shapes are stable. +- Inverse pricing uses the Phase 2 forward convention: linear quote price + divided by forward. Venue-exact Deribit/Binance option accounting needs later + sample parity. +- Theta assumes fixed forward and discount. +- Surface diagnostics are minimal; butterfly convexity and full no-arb fitting + are not production-certified yet. +- IV uses deterministic bisection for auditability; faster solvers are deferred. +- Options still have no tape compiler, selector, execution engine, ledger, + expiry lifecycle, endpoint route, or Nautilus validation. + --- ## Backend Selection Guide diff --git a/upgrade/option_backtest_plan/phase2_pricing_iv_greeks_status.md b/upgrade/option_backtest_plan/phase2_pricing_iv_greeks_status.md new file mode 100644 index 0000000..3c08491 --- /dev/null +++ b/upgrade/option_backtest_plan/phase2_pricing_iv_greeks_status.md @@ -0,0 +1,114 @@ +# Phase 2 - Pricing, IV, Greeks Status + +Date: 2026-07-23 + +Branch: `feat/option-engine` + +## Scope Completed + +Phase 2 added deterministic option analytics primitives only. It does not add +option execution, ledger accounting, expiry handling, endpoint routing, or +Nautilus validation. + +Implemented: + +- `options/pricing.py` + - linear Black-76 call/put pricing; + - linear intrinsic value; + - linear put-call parity value and residual; + - inverse base-currency pricing; + - inverse base-currency intrinsic; + - inverse base-currency parity value and residual. +- `options/greeks.py` + - `OptionGreeks`; + - linear quote-currency Greeks; + - inverse native base-currency Greeks; + - inverse quote-reporting Greeks; + - static reporting-currency scaling. +- `options/iv.py` + - `IVStatus`; + - `ImpliedVolResult`; + - deterministic bisection solver for linear Black-76 IV; + - deterministic bisection solver for inverse base-currency IV; + - explicit invalid-price statuses. +- `options/surface.py` + - `TotalVarianceSurface`; + - `SurfaceDiagnostics`; + - same-snapshot total variance calibration; + - strike-then-expiry interpolation; + - basic calendar total variance diagnostics. +- Public exports through `quantbt.options` and top-level `quantbt`. + +## Domain Guarantees Locked + +- Linear call-put parity holds: + +```text +C - P = DF * (F - K) +``` + +- Inverse base-currency parity holds: + +```text +C_base - P_base = DF * (1 - K / F) +``` + +- Inverse base option price equals linear quote option price divided by forward + under the Phase 2 forward convention. +- IV recovers generated volatility for both linear and inverse prices. +- Invalid IV inputs return explicit status and `NaN` implied vol instead of a + silent fallback. +- Analytic delta, gamma, and vega match finite-difference checks. +- Vega is internally represented per `1.0` volatility change; `vega_per_vol_point` + exposes the reporting-scale value. +- Surface calibration rejects future timestamp rows and expired expiries. +- Phase 2 code contains no `fastmath=True`. +- `import quantbt` does not import `nautilus_trader`. + +## Tests Run + +Commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +rg -n "fastmath" options tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.black76_price(100,100,1,0.2,'call') > 0; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase2_import_smoke=pass')" +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +``` + +Results: + +- compileall: pass. +- options tests: `31 passed`. +- fastmath scan: no matches. +- import smoke: `phase2_import_smoke=pass`. +- full non-real regression: `317 passed, 1 skipped, 3 warnings`. + +Existing warnings are unrelated to options: + +- one pandas runtime warning in a portfolio missing-data scenario; +- two matplotlib tight-layout warnings in walk-forward quick plot tests. + +## Technical Debt + +- Pricing and Greeks are scalar deterministic primitives. Vectorized or Numba + kernels should wait until Phase 3/4 tape and execution shapes are stable. +- Inverse pricing uses the Phase 2 forward convention. Venue-exact option + accounting still needs Deribit/Binance samples, fee schedules, settlement + rules, and Nautilus parity. +- Theta holds forward and discount fixed. Full curve/rate theta attribution is + deferred. +- Surface diagnostics are intentionally minimal. Butterfly convexity and full + arbitrage-free surface fitting remain future work. +- IV uses auditable bisection. Faster Newton/hybrid methods may be added later + only with deterministic parity tests. +- No option tape, selector, execution, ledger, endpoint, expiry, lifecycle, or + Nautilus adapter behavior is implemented in Phase 2. + +## Conclusion + +Phase 2 is complete and safe to build on. QuantBT now has tested option +analytics primitives, but it is not yet an options backtest engine. Phase 3 +must compile validated long-form chains into a no-lookahead ragged tape and +selection layer before execution logic is introduced. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 0b6c976..58333e1 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -247,6 +247,59 @@ Acceptance: - Finite-difference Greeks match analytic Greeks within tolerance. - No `fastmath=True` in IV/no-arb critical paths. +Status: completed. + +Implemented: + +- Added scalar deterministic pricing primitives in `options/pricing.py`. +- Added linear Black-76 call/put price, intrinsic value, and put-call parity + helpers. +- Added inverse forward-based pricing in base settlement currency, with inverse + intrinsic and base-currency parity helpers. +- Added `OptionGreeks` and Greek helpers in `options/greeks.py`: + - linear quote-currency Greeks; + - inverse native base-currency Greeks; + - inverse quote-reporting Greeks; + - explicit static currency scaling helper. +- Added `IVStatus`, `ImpliedVolResult`, and deterministic bisection IV solvers + in `options/iv.py`. +- Added `TotalVarianceSurface` and `SurfaceDiagnostics` in + `options/surface.py`: + - total variance from same-timestamp snapshots; + - strike-then-expiry interpolation; + - calendar total variance diagnostic; + - placeholder flag for butterfly convexity. +- Exported Phase 2 primitives from `quantbt.options` and top-level `quantbt`. + +Latest local tests: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` + - result: `31 passed` +- `rg -n "fastmath" options tests/options` + - result: no matches +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.black76_price(100,100,1,0.2,'call') > 0; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase2_import_smoke=pass')"` + - result: `phase2_import_smoke=pass` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` + - result: `317 passed, 1 skipped, 3 warnings` + +Technical debt after Phase 2: + +- Pricing and Greeks are scalar deterministic primitives. Phase 3/4 hot paths + may add vectorized or Numba kernels after tape/execution shapes are stable. +- Inverse pricing uses the Phase 2 forward convention: quote-currency + Black-76 price divided by forward. Venue-exact Deribit/Binance settlement, + fees, and margin still require later parity data. +- Theta holds forward and discount fixed. Full curve/rate theta attribution is + intentionally deferred. +- Surface diagnostics are intentionally minimal. Butterfly convexity and full + arbitrage-free surface fitting are placeholders, not production-certified + surface construction. +- IV uses bracketed bisection for determinism and auditability. Faster Newton + or hybrid solvers can be added later only with parity locks. +- No option tape, selector, execution, ledger, endpoint, expiry, or Nautilus + adapter behavior is implemented in Phase 2 by design. + ## Phase 3 - Data Tape And Selectors Files: From 6f83ae3d720c3ab95f05425e9168381bd25f2b19 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 11:19:01 +0000 Subject: [PATCH 05/15] feat: add options phase 3 tape selectors --- __init__.py | 22 ++ options/__init__.py | 21 ++ options/selectors.py | 281 ++++++++++++++++++ options/tape.py | 226 ++++++++++++++ tests/options/conftest.py | 113 +++++++ tests/options/test_phase3_no_lookahead.py | 48 +++ tests/options/test_phase3_selectors.py | 104 +++++++ tests/options/test_phase3_tape.py | 71 +++++ upgrade/implement.md | 48 +++ .../phase3_tape_selectors_status.md | 100 +++++++ .../quantbt_options_engine_execution_plan.md | 64 ++++ 11 files changed, 1098 insertions(+) create mode 100644 options/selectors.py create mode 100644 options/tape.py create mode 100644 tests/options/conftest.py create mode 100644 tests/options/test_phase3_no_lookahead.py create mode 100644 tests/options/test_phase3_selectors.py create mode 100644 tests/options/test_phase3_tape.py create mode 100644 upgrade/option_backtest_plan/phase3_tape_selectors_status.md diff --git a/__init__.py b/__init__.py index 18193fc..97696cc 100644 --- a/__init__.py +++ b/__init__.py @@ -186,11 +186,17 @@ OptionInstrumentRegistry, OptionInstrumentSpec, OptionKind, + OptionSelection, + OptionSelectionFilters, + OptionTapeSignature, OptionVenueConvention, PremiumConvention, + PreparedOptionTape, SettlementStyle, SurfaceDiagnostics, TotalVarianceSurface, + YEAR_NS, + available_option_rows, binance_european_options_convention, black76_intrinsic, black76_parity_residual, @@ -207,7 +213,12 @@ inverse_black76_parity_value_base, inverse_black76_price_base, linear_black76_greeks, + prepare_option_tape, scale_greeks_to_reporting_currency, + select_atm_option, + select_target_delta_option, + select_target_dte_option, + select_target_moneyness_option, validate_option_chain_frame, ) @@ -282,11 +293,17 @@ "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", + "OptionSelection", + "OptionSelectionFilters", + "OptionTapeSignature", "OptionVenueConvention", "PremiumConvention", + "PreparedOptionTape", "SettlementStyle", "SurfaceDiagnostics", "TotalVarianceSurface", + "YEAR_NS", + "available_option_rows", "binance_european_options_convention", "black76_intrinsic", "black76_parity_residual", @@ -303,7 +320,12 @@ "inverse_black76_parity_value_base", "inverse_black76_price_base", "linear_black76_greeks", + "prepare_option_tape", "scale_greeks_to_reporting_currency", + "select_atm_option", + "select_target_delta_option", + "select_target_dte_option", + "select_target_moneyness_option", "validate_option_chain_frame", "LEGACY_PORTFOLIO_MODES", "LEGACY_PORTFOLIO_SIZING_MODES", diff --git a/options/__init__.py b/options/__init__.py index 12a40e4..fe88978 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -41,7 +41,17 @@ PremiumConvention, SettlementStyle, ) +from .selectors import ( + OptionSelection, + OptionSelectionFilters, + available_option_rows, + select_atm_option, + select_target_delta_option, + select_target_dte_option, + select_target_moneyness_option, +) from .surface import SurfaceDiagnostics, TotalVarianceSurface +from .tape import YEAR_NS, OptionTapeSignature, PreparedOptionTape, prepare_option_tape __all__ = [ "CANONICAL_OPTION_CHAIN_COLUMNS", @@ -53,10 +63,15 @@ "OptionKind", "OptionGreeks", "OptionVenueConvention", + "OptionSelection", + "OptionSelectionFilters", + "OptionTapeSignature", "PremiumConvention", + "PreparedOptionTape", "SettlementStyle", "SurfaceDiagnostics", "TotalVarianceSurface", + "YEAR_NS", "binance_european_options_convention", "black76_intrinsic", "black76_parity_residual", @@ -75,6 +90,12 @@ "IVStatus", "ImpliedVolResult", "linear_black76_greeks", + "available_option_rows", + "prepare_option_tape", "scale_greeks_to_reporting_currency", + "select_atm_option", + "select_target_delta_option", + "select_target_dte_option", + "select_target_moneyness_option", "validate_option_chain_frame", ] diff --git a/options/selectors.py b/options/selectors.py new file mode 100644 index 0000000..80dc9cc --- /dev/null +++ b/options/selectors.py @@ -0,0 +1,281 @@ +""" +No-lookahead option selectors. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Union + +import numpy as np + +from .schema import OptionKind +from .tape import PreparedOptionTape, YEAR_NS + + +@dataclass(frozen=True) +class OptionSelectionFilters: + option_kind: Optional[Union[OptionKind, str]] = None + min_bid_size: float = 0.0 + min_ask_size: float = 0.0 + max_spread_bps: Optional[float] = None + min_open_interest: float = 0.0 + min_volume: float = 0.0 + min_dte_days: Optional[float] = None + max_dte_days: Optional[float] = None + min_moneyness: Optional[float] = None + max_moneyness: Optional[float] = None + require_mark_iv: bool = False + require_delta: bool = False + + +@dataclass(frozen=True) +class OptionSelection: + row_index: int + snapshot_index: int + snapshot_timestamp_ns: int + decision_timestamp_ns: int + instrument_id: str + instrument_code: int + option_kind: OptionKind + expiry_ns: int + strike: float + dte_years: float + moneyness: float + bid_price: float + ask_price: float + mark_price: float + mid_price: float + mark_iv: float + delta: float + score: float + + +def select_atm_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the listed option closest to ATM at the observable snapshot.""" + return _select_min_score( + tape, + decision_timestamp_ns, + filters=filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(tape.strike[rows] / tape.forward_price[rows] - 1.0), + ) + + +def select_target_delta_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + target_delta: float, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the option with observable delta closest to `target_delta`.""" + base_filters = _merge_require_delta(filters) + target = float(target_delta) + if not np.isfinite(target): + raise ValueError("target_delta must be finite") + return _select_min_score( + tape, + decision_timestamp_ns, + filters=base_filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(tape.delta[rows] - target), + ) + + +def select_target_dte_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + target_dte_days: float, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the option with expiry closest to target DTE at the snapshot.""" + target_years = _positive_days(target_dte_days, "target_dte_days") / 365.0 + return _select_min_score( + tape, + decision_timestamp_ns, + filters=filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(_dte_years(tape, rows, decision_timestamp_ns) - target_years), + ) + + +def select_target_moneyness_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + target_moneyness: float, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the option with strike/forward closest to target moneyness.""" + target = float(target_moneyness) + if not np.isfinite(target) or target <= 0.0: + raise ValueError("target_moneyness must be finite and > 0") + return _select_min_score( + tape, + decision_timestamp_ns, + filters=filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(tape.strike[rows] / tape.forward_price[rows] - target), + ) + + +def available_option_rows( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> np.ndarray: + """Return global row indexes listed and tradable at the observable snapshot.""" + snapshot_idx = tape.snapshot_index_at_or_before(decision_timestamp_ns, max_quote_age_ns=max_quote_age_ns) + rows = np.arange(tape.row_ptr[snapshot_idx], tape.row_ptr[snapshot_idx + 1], dtype=np.int64) + mask = _filter_mask(tape, rows, int(decision_timestamp_ns), filters or OptionSelectionFilters()) + return rows[mask] + + +def _select_min_score( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + filters: Optional[OptionSelectionFilters], + max_quote_age_ns: Optional[int], + score_fn, +) -> OptionSelection: + snapshot_idx = tape.snapshot_index_at_or_before(decision_timestamp_ns, max_quote_age_ns=max_quote_age_ns) + rows = np.arange(tape.row_ptr[snapshot_idx], tape.row_ptr[snapshot_idx + 1], dtype=np.int64) + filtered = _filter_mask(tape, rows, int(decision_timestamp_ns), filters or OptionSelectionFilters()) + candidates = rows[filtered] + if len(candidates) == 0: + raise ValueError("no option candidates pass filters at observable snapshot") + scores = np.asarray(score_fn(candidates), dtype=np.float64) + valid_scores = np.isfinite(scores) + if not bool(valid_scores.any()): + raise ValueError("no option candidates have finite selector score") + candidates = candidates[valid_scores] + scores = scores[valid_scores] + local_idx = int(np.argmin(scores)) + return _build_selection(tape, int(candidates[local_idx]), snapshot_idx, int(decision_timestamp_ns), float(scores[local_idx])) + + +def _filter_mask( + tape: PreparedOptionTape, + rows: np.ndarray, + decision_timestamp_ns: int, + filters: OptionSelectionFilters, +) -> np.ndarray: + if len(rows) == 0: + return np.zeros(0, dtype=bool) + mask = np.ones(len(rows), dtype=bool) + if filters.option_kind is not None: + kind = _coerce_kind(filters.option_kind) + mask &= tape.option_kind_code[rows] == (0 if kind is OptionKind.CALL else 1) + mask &= tape.expiry_ns[rows] > int(decision_timestamp_ns) + mask &= tape.bid_size[rows] >= float(filters.min_bid_size) + mask &= tape.ask_size[rows] >= float(filters.min_ask_size) + mask &= tape.open_interest[rows] >= float(filters.min_open_interest) + mask &= tape.volume[rows] >= float(filters.min_volume) + if filters.max_spread_bps is not None: + mid = 0.5 * (tape.bid_price[rows] + tape.ask_price[rows]) + spread_bps = np.divide( + tape.ask_price[rows] - tape.bid_price[rows], + mid, + out=np.full(len(rows), np.inf, dtype=np.float64), + where=mid > 0.0, + ) * 10_000.0 + mask &= spread_bps <= float(filters.max_spread_bps) + dte_days = _dte_years(tape, rows, decision_timestamp_ns) * 365.0 + if filters.min_dte_days is not None: + mask &= dte_days >= float(filters.min_dte_days) + if filters.max_dte_days is not None: + mask &= dte_days <= float(filters.max_dte_days) + moneyness = tape.strike[rows] / tape.forward_price[rows] + if filters.min_moneyness is not None: + mask &= moneyness >= float(filters.min_moneyness) + if filters.max_moneyness is not None: + mask &= moneyness <= float(filters.max_moneyness) + if filters.require_mark_iv: + mask &= np.isfinite(tape.mark_iv[rows]) + if filters.require_delta: + mask &= np.isfinite(tape.delta[rows]) + return mask + + +def _build_selection( + tape: PreparedOptionTape, + row_index: int, + snapshot_index: int, + decision_timestamp_ns: int, + score: float, +) -> OptionSelection: + mid = 0.5 * (float(tape.bid_price[row_index]) + float(tape.ask_price[row_index])) + kind = OptionKind.CALL if int(tape.option_kind_code[row_index]) == 0 else OptionKind.PUT + return OptionSelection( + row_index=row_index, + snapshot_index=snapshot_index, + snapshot_timestamp_ns=int(tape.timestamp_ns[snapshot_index]), + decision_timestamp_ns=int(decision_timestamp_ns), + instrument_id=tape.instrument_id[row_index], + instrument_code=int(tape.instrument_code[row_index]), + option_kind=kind, + expiry_ns=int(tape.expiry_ns[row_index]), + strike=float(tape.strike[row_index]), + dte_years=float((int(tape.expiry_ns[row_index]) - int(decision_timestamp_ns)) / YEAR_NS), + moneyness=float(tape.strike[row_index] / tape.forward_price[row_index]), + bid_price=float(tape.bid_price[row_index]), + ask_price=float(tape.ask_price[row_index]), + mark_price=float(tape.mark_price[row_index]), + mid_price=mid, + mark_iv=float(tape.mark_iv[row_index]), + delta=float(tape.delta[row_index]), + score=float(score), + ) + + +def _dte_years(tape: PreparedOptionTape, rows: np.ndarray, decision_timestamp_ns: int) -> np.ndarray: + return (tape.expiry_ns[rows].astype(np.float64) - float(decision_timestamp_ns)) / float(YEAR_NS) + + +def _merge_require_delta(filters: Optional[OptionSelectionFilters]) -> OptionSelectionFilters: + if filters is None: + return OptionSelectionFilters(require_delta=True) + return OptionSelectionFilters( + option_kind=filters.option_kind, + min_bid_size=filters.min_bid_size, + min_ask_size=filters.min_ask_size, + max_spread_bps=filters.max_spread_bps, + min_open_interest=filters.min_open_interest, + min_volume=filters.min_volume, + min_dte_days=filters.min_dte_days, + max_dte_days=filters.max_dte_days, + min_moneyness=filters.min_moneyness, + max_moneyness=filters.max_moneyness, + require_mark_iv=filters.require_mark_iv, + require_delta=True, + ) + + +def _coerce_kind(option_kind: Union[OptionKind, str]) -> OptionKind: + if isinstance(option_kind, OptionKind): + return option_kind + try: + return OptionKind(str(option_kind).lower()) + except ValueError as exc: + raise ValueError("option_kind must be call or put") from exc + + +def _positive_days(value: float, name: str) -> float: + out = float(value) + if not np.isfinite(out) or out <= 0.0: + raise ValueError(f"{name} must be finite and > 0") + return out diff --git a/options/tape.py b/options/tape.py new file mode 100644 index 0000000..cbda657 --- /dev/null +++ b/options/tape.py @@ -0,0 +1,226 @@ +""" +Prepared ragged option tape. + +The canonical option chain remains long-form. This module compiles validated +rows into CSR-style arrays so later selectors and execution code can scan the +listed contracts at each observable snapshot without building a dense +bar-by-contract matrix. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .data import validate_option_chain_frame +from .schema import InstrumentRegistrySignature, OptionInstrumentRegistry + + +YEAR_NS = 365 * 24 * 60 * 60 * 1_000_000_000 + + +@dataclass(frozen=True) +class OptionTapeSignature: + row_count: int + snapshot_count: int + first_timestamp_ns: int + last_timestamp_ns: int + instrument_registry_signature: InstrumentRegistrySignature + convention_signature: Tuple + + +@dataclass(frozen=True) +class PreparedOptionTape: + timestamp_ns: np.ndarray + row_ptr: np.ndarray + instrument_code: np.ndarray + instrument_id: Tuple[str, ...] + expiry_ns: np.ndarray + strike: np.ndarray + option_kind_code: np.ndarray + bid_price: np.ndarray + bid_size: np.ndarray + ask_price: np.ndarray + ask_size: np.ndarray + mark_price: np.ndarray + index_price: np.ndarray + forward_price: np.ndarray + mark_iv: np.ndarray + bid_iv: np.ndarray + ask_iv: np.ndarray + delta: np.ndarray + gamma: np.ndarray + vega: np.ndarray + theta: np.ndarray + open_interest: np.ndarray + volume: np.ndarray + source_latency_ns: np.ndarray + registry: OptionInstrumentRegistry + signature: OptionTapeSignature + + def __post_init__(self) -> None: + if self.timestamp_ns.ndim != 1 or self.row_ptr.ndim != 1: + raise ValueError("timestamp_ns and row_ptr must be 1-D") + if len(self.row_ptr) != len(self.timestamp_ns) + 1: + raise ValueError("row_ptr length must equal snapshot_count + 1") + if len(self.instrument_code) != self.signature.row_count: + raise ValueError("instrument_code length must match row_count") + if self.row_ptr[0] != 0 or self.row_ptr[-1] != self.signature.row_count: + raise ValueError("row_ptr bounds do not match row_count") + if bool((np.diff(self.row_ptr) < 0).any()): + raise ValueError("row_ptr must be non-decreasing") + if bool((np.diff(self.timestamp_ns) <= 0).any()): + raise ValueError("timestamp_ns must be strictly increasing") + + @property + def snapshot_count(self) -> int: + return len(self.timestamp_ns) + + @property + def row_count(self) -> int: + return len(self.instrument_code) + + def snapshot_index_at_or_before(self, decision_timestamp_ns: int, *, max_quote_age_ns: Optional[int] = None) -> int: + decision_ts = int(decision_timestamp_ns) + idx = int(np.searchsorted(self.timestamp_ns, decision_ts, side="right") - 1) + if idx < 0: + raise ValueError("no option snapshot is observable at or before decision_timestamp_ns") + if max_quote_age_ns is not None and decision_ts - int(self.timestamp_ns[idx]) > int(max_quote_age_ns): + raise ValueError("latest option snapshot is stale for decision_timestamp_ns") + return idx + + def snapshot_slice(self, snapshot_index: int) -> slice: + idx = int(snapshot_index) + if idx < 0 or idx >= self.snapshot_count: + raise IndexError("snapshot_index out of range") + return slice(int(self.row_ptr[idx]), int(self.row_ptr[idx + 1])) + + def validate_compatible( + self, + *, + registry_signature: Optional[InstrumentRegistrySignature] = None, + convention_signature: Optional[Tuple] = None, + timestamps_ns: Optional[Sequence[int]] = None, + ) -> None: + if registry_signature is not None and registry_signature != self.signature.instrument_registry_signature: + raise ValueError("prepared option tape registry signature mismatch") + if convention_signature is not None and tuple(convention_signature) != self.signature.convention_signature: + raise ValueError("prepared option tape convention signature mismatch") + if timestamps_ns is not None: + expected = np.asarray(timestamps_ns, dtype=np.int64) + if len(expected) != len(self.timestamp_ns) or bool((expected != self.timestamp_ns).any()): + raise ValueError("prepared option tape timestamp mismatch") + + +def prepare_option_tape( + chain: pd.DataFrame, + registry: OptionInstrumentRegistry, + *, + max_spread_bps: Optional[float] = None, + max_source_latency_ns: Optional[int] = None, + convention_signature: Optional[Tuple] = None, +) -> PreparedOptionTape: + """ + Validate long-form chain rows and compile a CSR-style option tape. + + `max_source_latency_ns` checks the per-row venue/source latency column when + present. Decision-time quote age is checked later by selectors because it + depends on the strategy timestamp. + """ + canonical = validate_option_chain_frame(chain, max_spread_bps=max_spread_bps) + registry_symbols = registry.by_symbol + unknown = sorted(set(canonical["instrument_id"]).difference(registry_symbols)) + if unknown: + raise ValueError(f"option chain contains instruments not in registry: {unknown}") + if max_source_latency_ns is not None: + if max_source_latency_ns < 0: + raise ValueError("max_source_latency_ns must be >= 0") + if "source_latency_ns" not in canonical: + raise ValueError("source_latency_ns is required when max_source_latency_ns is set") + if bool((canonical["source_latency_ns"].to_numpy(dtype=np.int64) > int(max_source_latency_ns)).any()): + raise ValueError("option chain contains stale source latency rows") + _validate_registry_static_fields(canonical, registry) + timestamps, row_ptr = _build_row_ptr(canonical["timestamp_ns"].to_numpy(dtype=np.int64)) + ids = tuple(canonical["instrument_id"].astype(str).tolist()) + code_map = {symbol: code for code, symbol in enumerate(registry.symbols)} + instrument_code = np.asarray([code_map[symbol] for symbol in ids], dtype=np.int32) + kind_code = np.asarray([0 if kind == "call" else 1 for kind in canonical["option_kind"].astype(str)], dtype=np.int8) + convention_sig = tuple(convention_signature) if convention_signature is not None else registry.signature.signature + signature = OptionTapeSignature( + row_count=len(canonical), + snapshot_count=len(timestamps), + first_timestamp_ns=int(timestamps[0]), + last_timestamp_ns=int(timestamps[-1]), + instrument_registry_signature=registry.signature, + convention_signature=convention_sig, + ) + return PreparedOptionTape( + timestamp_ns=timestamps, + row_ptr=row_ptr, + instrument_code=instrument_code, + instrument_id=ids, + expiry_ns=canonical["expiry_ns"].to_numpy(dtype=np.int64), + strike=canonical["strike"].to_numpy(dtype=np.float64), + option_kind_code=kind_code, + bid_price=canonical["bid_price"].to_numpy(dtype=np.float64), + bid_size=canonical["bid_size"].to_numpy(dtype=np.float64), + ask_price=canonical["ask_price"].to_numpy(dtype=np.float64), + ask_size=canonical["ask_size"].to_numpy(dtype=np.float64), + mark_price=canonical["mark_price"].to_numpy(dtype=np.float64), + index_price=canonical["index_price"].to_numpy(dtype=np.float64), + forward_price=canonical["forward_price"].to_numpy(dtype=np.float64), + mark_iv=_float_column(canonical, "mark_iv", default=np.nan), + bid_iv=_float_column(canonical, "bid_iv", default=np.nan), + ask_iv=_float_column(canonical, "ask_iv", default=np.nan), + delta=_float_column(canonical, "delta", default=np.nan), + gamma=_float_column(canonical, "gamma", default=np.nan), + vega=_float_column(canonical, "vega", default=np.nan), + theta=_float_column(canonical, "theta", default=np.nan), + open_interest=_float_column(canonical, "open_interest", default=0.0), + volume=_float_column(canonical, "volume", default=0.0), + source_latency_ns=_int_column(canonical, "source_latency_ns", default=0), + registry=registry, + signature=signature, + ) + + +def _build_row_ptr(timestamp_ns: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + timestamps, counts = np.unique(timestamp_ns, return_counts=True) + row_ptr = np.empty(len(timestamps) + 1, dtype=np.int64) + row_ptr[0] = 0 + row_ptr[1:] = np.cumsum(counts, dtype=np.int64) + return timestamps.astype(np.int64), row_ptr + + +def _float_column(frame: pd.DataFrame, column: str, *, default: float) -> np.ndarray: + if column not in frame: + return np.full(len(frame), default, dtype=np.float64) + return frame[column].to_numpy(dtype=np.float64) + + +def _int_column(frame: pd.DataFrame, column: str, *, default: int) -> np.ndarray: + if column not in frame: + return np.full(len(frame), default, dtype=np.int64) + return frame[column].to_numpy(dtype=np.int64) + + +def _validate_registry_static_fields(chain: pd.DataFrame, registry: OptionInstrumentRegistry) -> None: + for row in chain.itertuples(index=False): + spec = registry.by_symbol[getattr(row, "instrument_id")] + if int(getattr(row, "expiry_ns")) != int(spec.expiry_ns): + raise ValueError("option chain expiry_ns does not match registry") + if abs(float(getattr(row, "strike")) - float(spec.strike)) > 1e-12: + raise ValueError("option chain strike does not match registry") + if str(getattr(row, "option_kind")).lower() != spec.option_kind.value: + raise ValueError("option chain option_kind does not match registry") + if str(getattr(row, "venue")).lower() != spec.venue: + raise ValueError("option chain venue does not match registry") + if str(getattr(row, "underlying_id")).strip() != spec.underlying_id: + raise ValueError("option chain underlying_id does not match registry") + if str(getattr(row, "quote_currency")).upper() != spec.quote_currency: + raise ValueError("option chain quote_currency does not match registry") + if str(getattr(row, "settlement_currency")).upper() != spec.settlement_currency: + raise ValueError("option chain settlement_currency does not match registry") diff --git a/tests/options/conftest.py b/tests/options/conftest.py new file mode 100644 index 0000000..3895d3d --- /dev/null +++ b/tests/options/conftest.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import ( + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + SettlementStyle, + ExerciseStyle, +) + + +def ns(value: str) -> int: + return int(pd.Timestamp(value, tz="UTC").value) + + +@pytest.fixture +def option_phase3_registry() -> OptionInstrumentRegistry: + expiry_1 = ns("2026-02-01 08:00:00") + expiry_2 = ns("2026-03-01 08:00:00") + specs = [] + for symbol, strike, kind, expiry in ( + ("BTC-01FEB26-90000-C.DERIBIT", 90_000.0, OptionKind.CALL, expiry_1), + ("BTC-01FEB26-100000-C.DERIBIT", 100_000.0, OptionKind.CALL, expiry_1), + ("BTC-01FEB26-110000-P.DERIBIT", 110_000.0, OptionKind.PUT, expiry_1), + ("BTC-01MAR26-100000-C.DERIBIT", 100_000.0, OptionKind.CALL, expiry_2), + ): + specs.append( + OptionInstrumentSpec( + symbol=symbol, + venue="deribit", + underlying_id="BTC-PERPETUAL.DERIBIT", + underlying_index_id="BTC-INDEX.DERIBIT", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry, + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + qty_step=0.1, + convention_version="deribit_inverse_v1", + ) + ) + return OptionInstrumentRegistry.from_iterable(specs) + + +@pytest.fixture +def option_phase3_chain() -> pd.DataFrame: + ts0 = ns("2026-01-01 00:00:00") + ts1 = ns("2026-01-01 01:00:00") + expiry_1 = ns("2026-02-01 08:00:00") + expiry_2 = ns("2026-03-01 08:00:00") + rows = [] + for ts, forward, seq_base in ((ts0, 100_000.0, 1), (ts1, 102_000.0, 10)): + rows.extend( + [ + _row(ts, seq_base + 0, "BTC-01FEB26-90000-C.DERIBIT", 90_000.0, "call", expiry_1, forward, 0.012, 0.013, 0.86), + _row(ts, seq_base + 1, "BTC-01FEB26-100000-C.DERIBIT", 100_000.0, "call", expiry_1, forward, 0.020, 0.021, 0.50), + _row(ts, seq_base + 2, "BTC-01FEB26-110000-P.DERIBIT", 110_000.0, "put", expiry_1, forward, 0.030, 0.032, -0.64), + _row(ts, seq_base + 3, "BTC-01MAR26-100000-C.DERIBIT", 100_000.0, "call", expiry_2, forward, 0.050, 0.052, 0.54), + ] + ) + return pd.DataFrame(rows) + + +def _row( + timestamp_ns: int, + sequence_id: int, + instrument_id: str, + strike: float, + option_kind: str, + expiry_ns: int, + forward_price: float, + bid_price: float, + ask_price: float, + delta: float, +) -> dict: + return { + "timestamp_ns": timestamp_ns, + "instrument_id": instrument_id, + "venue": "DERIBIT", + "underlying_id": "BTC-PERPETUAL.DERIBIT", + "expiry_ns": expiry_ns, + "strike": strike, + "option_kind": option_kind, + "bid_price": bid_price, + "bid_size": 10.0, + "ask_price": ask_price, + "ask_size": 12.0, + "mark_price": 0.5 * (bid_price + ask_price), + "last_price": 0.5 * (bid_price + ask_price), + "index_price": forward_price - 100.0, + "forward_price": forward_price, + "mark_iv": 0.60, + "bid_iv": 0.58, + "ask_iv": 0.62, + "delta": delta, + "gamma": 0.0001, + "vega": 100.0, + "theta": -10.0, + "open_interest": 100.0, + "volume": 25.0, + "quote_currency": "USD", + "settlement_currency": "BTC", + "sequence_id": sequence_id, + "source_latency_ns": 1_000_000, + } diff --git a/tests/options/test_phase3_no_lookahead.py b/tests/options/test_phase3_no_lookahead.py new file mode 100644 index 0000000..86e780c --- /dev/null +++ b/tests/options/test_phase3_no_lookahead.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import OptionSelectionFilters, prepare_option_tape, select_atm_option, select_target_delta_option + + +def test_phase3_selector_never_uses_future_snapshot(option_phase3_chain, option_phase3_registry): + chain = option_phase3_chain.copy() + ts0, ts1 = sorted(chain["timestamp_ns"].unique()) + chain.loc[(chain["timestamp_ns"] == ts1) & (chain["instrument_id"] == "BTC-01FEB26-100000-C.DERIBIT"), "delta"] = 0.90 + tape = prepare_option_tape(chain, option_phase3_registry) + decision_between_snapshots = int(ts0 + (ts1 - ts0) // 2) + + selected = select_target_delta_option( + tape, + decision_between_snapshots, + target_delta=0.90, + filters=OptionSelectionFilters(option_kind="call"), + ) + + assert selected.snapshot_timestamp_ns == int(ts0) + assert selected.delta != pytest.approx(0.90) + + +def test_phase3_selector_rejects_before_first_snapshot_and_stale_snapshot(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + + with pytest.raises(ValueError, match="no option snapshot"): + select_atm_option(tape, int(tape.timestamp_ns[0]) - 1) + + with pytest.raises(ValueError, match="stale"): + select_atm_option(tape, int(tape.timestamp_ns[-1]) + 10_000_000_000, max_quote_age_ns=1_000_000) + + +def test_phase3_selector_filters_expired_contracts_at_decision_time(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + decision_after_feb_expiry = int(pd.Timestamp("2026-02-15 00:00:00", tz="UTC").value) + + selected = select_atm_option( + tape, + decision_after_feb_expiry, + filters=OptionSelectionFilters(option_kind="call"), + ) + + assert selected.instrument_id == "BTC-01MAR26-100000-C.DERIBIT" + assert selected.expiry_ns > decision_after_feb_expiry diff --git a/tests/options/test_phase3_selectors.py b/tests/options/test_phase3_selectors.py new file mode 100644 index 0000000..38e38e9 --- /dev/null +++ b/tests/options/test_phase3_selectors.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + OptionKind, + OptionSelectionFilters, + available_option_rows, + prepare_option_tape, + select_atm_option, + select_target_delta_option, + select_target_dte_option, + select_target_moneyness_option, +) + + +def test_phase3_select_atm_uses_observable_snapshot(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + decision_ts = int(tape.timestamp_ns[1]) + 10 + + selected = select_atm_option( + tape, + decision_ts, + filters=OptionSelectionFilters(option_kind=OptionKind.CALL, min_open_interest=10.0), + ) + + assert selected.snapshot_index == 1 + assert selected.snapshot_timestamp_ns == int(tape.timestamp_ns[1]) + assert selected.decision_timestamp_ns == decision_ts + assert selected.instrument_id == "BTC-01FEB26-100000-C.DERIBIT" + assert selected.option_kind is OptionKind.CALL + assert selected.moneyness == pytest.approx(100_000.0 / 102_000.0) + + +def test_phase3_select_target_delta_requires_observable_delta(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + decision_ts = int(tape.timestamp_ns[0]) + + selected = select_target_delta_option( + tape, + decision_ts, + target_delta=0.55, + filters=OptionSelectionFilters(option_kind="call", min_bid_size=1.0, min_ask_size=1.0, max_dte_days=40.0), + ) + + assert selected.instrument_id == "BTC-01FEB26-100000-C.DERIBIT" + assert selected.delta == pytest.approx(0.50) + + chain = option_phase3_chain.copy() + chain.loc[chain["instrument_id"] == "BTC-01FEB26-100000-C.DERIBIT", "delta"] = float("nan") + tape_without_delta = prepare_option_tape(chain, option_phase3_registry) + fallback = select_target_delta_option( + tape_without_delta, + decision_ts, + target_delta=0.55, + filters=OptionSelectionFilters(option_kind="call", max_dte_days=40.0), + ) + assert fallback.instrument_id != "BTC-01FEB26-100000-C.DERIBIT" + + +def test_phase3_select_dte_and_moneyness_filters(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + decision_ts = int(tape.timestamp_ns[0]) + + dte = select_target_dte_option( + tape, + decision_ts, + target_dte_days=60.0, + filters=OptionSelectionFilters(option_kind="call"), + ) + assert dte.instrument_id == "BTC-01MAR26-100000-C.DERIBIT" + + money = select_target_moneyness_option( + tape, + decision_ts, + target_moneyness=0.90, + filters=OptionSelectionFilters(option_kind="call", min_dte_days=1.0, max_dte_days=70.0), + ) + assert money.instrument_id == "BTC-01FEB26-90000-C.DERIBIT" + + +def test_phase3_liquidity_filters_return_available_rows(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + decision_ts = int(tape.timestamp_ns[0]) + + rows = available_option_rows( + tape, + decision_ts, + filters=OptionSelectionFilters(option_kind="call", min_open_interest=50.0, min_volume=20.0, max_spread_bps=1_000), + ) + + assert len(rows) == 3 + assert set(tape.instrument_id[int(row)] for row in rows) == { + "BTC-01FEB26-90000-C.DERIBIT", + "BTC-01FEB26-100000-C.DERIBIT", + "BTC-01MAR26-100000-C.DERIBIT", + } + + with pytest.raises(ValueError, match="no option candidates"): + select_atm_option( + tape, + decision_ts, + filters=OptionSelectionFilters(option_kind="call", min_open_interest=1_000_000.0), + ) diff --git a/tests/options/test_phase3_tape.py b/tests/options/test_phase3_tape.py new file mode 100644 index 0000000..7f49c4a --- /dev/null +++ b/tests/options/test_phase3_tape.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from quantbt import InstrumentRegistrySignature, PreparedOptionTape, prepare_option_tape + + +def test_phase3_prepare_option_tape_builds_csr_ragged_arrays(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry, max_spread_bps=1_000) + + assert isinstance(tape, PreparedOptionTape) + assert tape.snapshot_count == 2 + assert tape.row_count == 8 + assert tape.row_ptr.tolist() == [0, 4, 8] + assert tape.timestamp_ns.tolist() == sorted(option_phase3_chain["timestamp_ns"].unique().tolist()) + assert tape.instrument_code.dtype == np.int32 + assert tape.option_kind_code.tolist().count(0) == 6 + assert tape.option_kind_code.tolist().count(1) == 2 + assert tape.signature.row_count == 8 + assert tape.signature.snapshot_count == 2 + assert tape.signature.instrument_registry_signature == option_phase3_registry.signature + assert tape.signature.convention_signature == option_phase3_registry.signature.signature + + +def test_phase3_prepare_option_tape_rejects_unknown_instrument(option_phase3_chain, option_phase3_registry): + chain = option_phase3_chain.copy() + chain.loc[0, "instrument_id"] = "BTC-UNKNOWN.DERIBIT" + + with pytest.raises(ValueError, match="not in registry"): + prepare_option_tape(chain, option_phase3_registry) + + +def test_phase3_prepare_option_tape_rejects_registry_static_mismatch(option_phase3_chain, option_phase3_registry): + bad_strike = option_phase3_chain.copy() + bad_strike.loc[0, "strike"] = 91_000.0 + with pytest.raises(ValueError, match="strike"): + prepare_option_tape(bad_strike, option_phase3_registry) + + bad_kind = option_phase3_chain.copy() + bad_kind.loc[0, "option_kind"] = "put" + with pytest.raises(ValueError, match="option_kind"): + prepare_option_tape(bad_kind, option_phase3_registry) + + +def test_phase3_prepare_option_tape_rejects_crossed_and_stale_source_latency(option_phase3_chain, option_phase3_registry): + crossed = option_phase3_chain.copy() + crossed.loc[0, "bid_price"] = crossed.loc[0, "ask_price"] + 0.01 + with pytest.raises(ValueError, match="crossed"): + prepare_option_tape(crossed, option_phase3_registry) + + stale = option_phase3_chain.copy() + stale.loc[0, "source_latency_ns"] = 10_000_000_000 + with pytest.raises(ValueError, match="stale source latency"): + prepare_option_tape(stale, option_phase3_registry, max_source_latency_ns=1_000_000) + + +def test_phase3_prepared_tape_compatibility_checks(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry, convention_signature=("custom", "v1")) + + tape.validate_compatible( + registry_signature=option_phase3_registry.signature, + convention_signature=("custom", "v1"), + timestamps_ns=tape.timestamp_ns, + ) + with pytest.raises(ValueError, match="registry signature"): + tape.validate_compatible(registry_signature=InstrumentRegistrySignature(0, (), (), ())) + with pytest.raises(ValueError, match="convention signature"): + tape.validate_compatible(convention_signature=("custom", "v2")) + with pytest.raises(ValueError, match="timestamp mismatch"): + tape.validate_compatible(timestamps_ns=[int(tape.timestamp_ns[0])]) diff --git a/upgrade/implement.md b/upgrade/implement.md index b1cb689..c2bd07b 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -2973,6 +2973,54 @@ Technical debt after Phase 17.2: - Options still have no tape compiler, selector, execution engine, ledger, expiry lifecycle, endpoint route, or Nautilus validation. +### Phase 17.3 - Data Tape And Selectors + +Status: completed. + +Implemented: + +- Added a ragged/CSR option tape: + - `PreparedOptionTape`; + - `OptionTapeSignature`; + - `prepare_option_tape(...)`; + - snapshot timestamps; + - row pointers; + - per-row instrument codes and market fields. +- Added no-lookahead option selectors: + - ATM; + - target delta; + - target DTE; + - target moneyness; + - available rows with liquidity/spread/OI filters. +- Added registry, convention, and timestamp signature validation. +- Added guards for: + - unknown/unlisted instruments; + - registry static-field mismatch; + - crossed quotes; + - stale source latency; + - stale decision-time quote age; + - expired contracts at decision time. +- Exported Phase 3 APIs from top-level `quantbt`. + +Latest tests: + +- options tests: `43 passed`. +- import smoke: `phase3_import_smoke=pass`. +- dense/fastmath scan: no dense matrix construction and no `fastmath`. +- full non-real regression: `329 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 17.3: + +- Selector scans are Python/NumPy. Numba kernels should wait until option + execution/package shapes are stable. +- Delta/IV selectors use observable chain columns only. Model-derived fallback + selection must be explicit in later phases. +- Stale checks are snapshot-level guards, not L2/order-book replay. +- Tie-break policies are first-minimum after canonical sort; richer secondary + policies are future work. +- Options still have no package compiler, execution engine, ledger, expiry + lifecycle, endpoint route, or Nautilus validation. + --- ## Backend Selection Guide diff --git a/upgrade/option_backtest_plan/phase3_tape_selectors_status.md b/upgrade/option_backtest_plan/phase3_tape_selectors_status.md new file mode 100644 index 0000000..70bc487 --- /dev/null +++ b/upgrade/option_backtest_plan/phase3_tape_selectors_status.md @@ -0,0 +1,100 @@ +# Phase 3 - Data Tape And Selectors Status + +Date: 2026-07-23 + +Branch: `feat/option-engine` + +## Scope Completed + +Phase 3 added the option market-data tape and no-lookahead selector layer. It +does not add option execution, ledger accounting, expiry handling, endpoint +routing, or Nautilus validation. + +Implemented: + +- `options/tape.py` + - `PreparedOptionTape`; + - `OptionTapeSignature`; + - `prepare_option_tape(...)`; + - CSR-style snapshot arrays: + - `timestamp_ns`; + - `row_ptr`; + - per-row instrument and market fields. +- `options/selectors.py` + - `OptionSelectionFilters`; + - `OptionSelection`; + - `available_option_rows(...)`; + - ATM selector; + - target-delta selector; + - target-DTE selector; + - target-moneyness selector. +- Public exports through `quantbt.options` and top-level `quantbt`. + +## Domain Guarantees Locked + +- Canonical chain stays long-form; no dense bar-by-contract matrix is used. +- Prepared tape uses CSR-style ragged arrays. +- Chain instruments must exist in the registry. +- Chain static fields must match registry: + - expiry; + - strike; + - option kind; + - venue; + - underlying; + - quote currency; + - settlement currency. +- Crossed quotes reject during canonical validation. +- Source latency can reject stale rows at tape preparation time. +- Decision-time quote age can reject stale snapshots at selector time. +- Selectors use the latest snapshot at or before the decision timestamp. +- Selectors reject decisions before the first observable snapshot. +- Expired contracts are filtered at decision time. +- Delta and IV selectors only use observable columns already present in the + chain/tape. +- Prepared tape can validate registry, convention, and timestamp signatures. + +## Tests Run + +Commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.prepare_option_tape; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase3_import_smoke=pass')" +rg -n "pivot|unstack|N_bars|dense|fastmath" options tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +``` + +Results: + +- compileall: pass. +- options tests: `43 passed`. +- import smoke: `phase3_import_smoke=pass`. +- dense/fastmath scan: no dense matrix construction and no `fastmath`. +- full non-real regression: `329 passed, 1 skipped, 3 warnings`. + +Existing warnings are unrelated to options: + +- one pandas runtime warning in a portfolio missing-data scenario; +- two matplotlib tight-layout warnings in walk-forward quick plot tests. + +## Technical Debt + +- Tape and selectors are array-first but selector scans are still Python/NumPy. + Numba optimization should wait until Phase 4 execution/package shapes are + finalized. +- Delta/IV selectors trust observable tape columns. Model-derived fallback + Greeks/IV should be explicit and tagged in later phases, not implicit. +- Source latency and quote age checks are snapshot guards only, not L2 replay or + queue-priority simulation. +- Tie-breaks currently use first minimum after canonical sort. If a strategy + needs secondary ranking such as max OI or tightest spread, add explicit + selector policy fields. +- No option package compiler, execution, ledger, expiry lifecycle, endpoint, or + Nautilus validation is implemented in Phase 3. + +## Conclusion + +Phase 3 is complete and safe to build on. QuantBT now has validated long-form +option data, a ragged prepared tape, signatures, and no-lookahead selectors. +Phase 4 can now compile option packages and simulate fills against this tape. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 58333e1..af617a5 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -337,6 +337,70 @@ Acceptance: - Delta/IV selection uses only observable snapshot values. - Prepared tape rejects stale registry/convention/timestamp mismatch. +Status: completed. + +Implemented: + +- Added `options/tape.py`: + - `PreparedOptionTape`; + - `OptionTapeSignature`; + - `prepare_option_tape(...)`; + - CSR-style `timestamp_ns` and `row_ptr`; + - per-row instrument codes, bid/ask/size, mark, forward/index, IV, Greeks, + OI, volume, and source latency arrays; + - registry static-field checks; + - stale source-latency guard; + - registry, convention, and timestamp compatibility checks. +- Added `options/selectors.py`: + - `OptionSelectionFilters`; + - `OptionSelection`; + - `available_option_rows(...)`; + - `select_atm_option(...)`; + - `select_target_delta_option(...)`; + - `select_target_dte_option(...)`; + - `select_target_moneyness_option(...)`. +- Added Phase 3 tests: + - CSR tape shape and signatures; + - unknown/unlisted instrument rejection; + - registry strike/kind mismatch rejection; + - crossed quote and stale source latency rejection; + - ATM, target-delta, target-DTE, and moneyness selectors; + - liquidity/spread/OI filters; + - no-lookahead snapshot selection; + - stale decision-time quote age rejection; + - expired contract filtering at decision time. +- Exported Phase 3 tape and selector APIs from `quantbt.options` and top-level + `quantbt`. + +Latest local tests: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` + - result: `43 passed` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.prepare_option_tape; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase3_import_smoke=pass')"` + - result: `phase3_import_smoke=pass` +- `rg -n "pivot|unstack|N_bars|dense|fastmath" options tests/options` + - result: no dense matrix construction or `fastmath`; only documentation/test + wording contains `dense`. +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` + - result: `329 passed, 1 skipped, 3 warnings` + +Technical debt after Phase 3: + +- Tape and selectors are array-first but still Python/NumPy scalar scans at the + selector layer. Numba kernels should wait until Phase 4 execution package + shapes are stable. +- Delta/IV selectors trust observable chain columns. Later phases should add + optional fallback to Phase 2 model Greeks/IV only when explicitly requested + and tagged as model-derived. +- Source latency and quote age guards are deterministic snapshot guards, not + real venue L2 replay or queue priority. +- Selector tie-breaks currently use first minimum after canonical sort. If + strategies need deterministic secondary rules such as max OI or tightest + spread, add explicit selector policies. +- No option package compiler, execution, ledger, expiry lifecycle, endpoint, or + Nautilus validation is implemented in Phase 3 by design. + ## Phase 4 - Package Compiler And Options Execution Files: From bbaf955f622e4bd4f65d6a84f3044000d8b0279c Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 11:28:43 +0000 Subject: [PATCH 06/15] feat: add options phase 4 execution --- __init__.py | 18 + options/__init__.py | 22 + options/execution.py | 599 ++++++++++++++++++ options/packages.py | 147 +++++ tests/options/test_phase4_execution.py | 166 +++++ tests/options/test_phase4_packages.py | 61 ++ upgrade/implement.md | 51 ++ .../phase4_packages_execution_status.md | 100 +++ .../quantbt_options_engine_execution_plan.md | 77 +++ 9 files changed, 1241 insertions(+) create mode 100644 options/execution.py create mode 100644 options/packages.py create mode 100644 tests/options/test_phase4_execution.py create mode 100644 tests/options/test_phase4_packages.py create mode 100644 upgrade/option_backtest_plan/phase4_packages_execution_status.md diff --git a/__init__.py b/__init__.py index 97696cc..9663c5b 100644 --- a/__init__.py +++ b/__init__.py @@ -182,10 +182,17 @@ ImpliedVolResult, InstrumentRegistrySignature, OptionDecisionFillPolicy, + OptionDepthFidelity, + OptionExecutionConfig, OptionGreeks, OptionInstrumentRegistry, OptionInstrumentSpec, OptionKind, + OptionLimitFidelity, + OptionPackageExecutionPolicy, + OptionPackageExecutionResult, + OptionPackageIntent, + OptionPackageLeg, OptionSelection, OptionSelectionFilters, OptionTapeSignature, @@ -202,6 +209,7 @@ black76_parity_residual, black76_parity_value, black76_price, + compile_option_package_orders, deribit_inverse_option_convention, deribit_linear_usdc_option_convention, implied_vol_black76, @@ -213,6 +221,7 @@ inverse_black76_parity_value_base, inverse_black76_price_base, linear_black76_greeks, + execute_option_package, prepare_option_tape, scale_greeks_to_reporting_currency, select_atm_option, @@ -289,10 +298,17 @@ "ImpliedVolResult", "InstrumentRegistrySignature", "OptionDecisionFillPolicy", + "OptionDepthFidelity", + "OptionExecutionConfig", "OptionGreeks", "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", + "OptionLimitFidelity", + "OptionPackageExecutionPolicy", + "OptionPackageExecutionResult", + "OptionPackageIntent", + "OptionPackageLeg", "OptionSelection", "OptionSelectionFilters", "OptionTapeSignature", @@ -309,6 +325,7 @@ "black76_parity_residual", "black76_parity_value", "black76_price", + "compile_option_package_orders", "deribit_inverse_option_convention", "deribit_linear_usdc_option_convention", "implied_vol_black76", @@ -320,6 +337,7 @@ "inverse_black76_parity_value_base", "inverse_black76_price_base", "linear_black76_greeks", + "execute_option_package", "prepare_option_tape", "scale_greeks_to_reporting_currency", "select_atm_option", diff --git a/options/__init__.py b/options/__init__.py index fe88978..7e289e3 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -13,6 +13,13 @@ deribit_linear_usdc_option_convention, ) from .data import CANONICAL_OPTION_CHAIN_COLUMNS, validate_option_chain_frame +from .execution import ( + OptionDepthFidelity, + OptionExecutionConfig, + OptionLimitFidelity, + OptionPackageExecutionResult, + execute_option_package, +) from .greeks import ( OptionGreeks, inverse_black76_greeks_base, @@ -21,6 +28,12 @@ scale_greeks_to_reporting_currency, ) from .iv import IVStatus, ImpliedVolResult, implied_vol_black76, implied_vol_inverse_black76_base +from .packages import ( + OptionPackageExecutionPolicy, + OptionPackageIntent, + OptionPackageLeg, + compile_option_package_orders, +) from .pricing import ( black76_intrinsic, black76_parity_residual, @@ -58,10 +71,17 @@ "ExerciseStyle", "InstrumentRegistrySignature", "OptionDecisionFillPolicy", + "OptionDepthFidelity", + "OptionExecutionConfig", "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", "OptionGreeks", + "OptionLimitFidelity", + "OptionPackageExecutionPolicy", + "OptionPackageExecutionResult", + "OptionPackageIntent", + "OptionPackageLeg", "OptionVenueConvention", "OptionSelection", "OptionSelectionFilters", @@ -77,6 +97,7 @@ "black76_parity_residual", "black76_parity_value", "black76_price", + "compile_option_package_orders", "deribit_inverse_option_convention", "deribit_linear_usdc_option_convention", "implied_vol_black76", @@ -91,6 +112,7 @@ "ImpliedVolResult", "linear_black76_greeks", "available_option_rows", + "execute_option_package", "prepare_option_tape", "scale_greeks_to_reporting_currency", "select_atm_option", diff --git a/options/execution.py b/options/execution.py new file mode 100644 index 0000000..63a2e17 --- /dev/null +++ b/options/execution.py @@ -0,0 +1,599 @@ +""" +Snapshot-level option package execution. + +Phase 4 is an execution simulator on a prepared option tape. It is intentionally +not the final multi-currency ledger, margin, expiry, or Nautilus adapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple + +import pandas as pd + +from ..core.orders import Fill, OrderIntent +from ..core.schema import LiquiditySide, OrderSide, OrderType, TimeInForce +from .packages import OptionPackageExecutionPolicy, OptionPackageIntent, compile_option_package_orders +from .tape import PreparedOptionTape + + +class OptionLimitFidelity(str, Enum): + CROSS_ONLY = "cross_only" + MAKER_TOUCH = "maker_touch" + + +class OptionDepthFidelity(str, Enum): + TOP_OF_BOOK = "top_of_book" + + +@dataclass(frozen=True) +class OptionExecutionConfig: + initial_cash: float = 0.0 + fee_rate: float = 0.0 + allow_partial_fill: bool = True + max_quote_age_ns: Optional[int] = None + limit_fidelity: OptionLimitFidelity = OptionLimitFidelity.CROSS_ONLY + depth_fidelity: OptionDepthFidelity = OptionDepthFidelity.TOP_OF_BOOK + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "limit_fidelity", _coerce_enum(OptionLimitFidelity, self.limit_fidelity, "limit_fidelity")) + object.__setattr__(self, "depth_fidelity", _coerce_enum(OptionDepthFidelity, self.depth_fidelity, "depth_fidelity")) + if self.fee_rate < 0.0: + raise ValueError("fee_rate must be >= 0") + if self.max_quote_age_ns is not None and self.max_quote_age_ns < 0: + raise ValueError("max_quote_age_ns must be >= 0") + + +@dataclass(frozen=True) +class OptionPackageExecutionResult: + fills: Tuple[Fill, ...] + order_report: pd.DataFrame + package_report: pd.DataFrame + cash: float + positions: Dict[str, float] + margin_report: Dict + metadata: Dict = field(default_factory=dict) + + +@dataclass +class _ExecutionState: + cash: float + positions: Dict[str, float] + + def copy(self) -> "_ExecutionState": + return _ExecutionState(cash=float(self.cash), positions=dict(self.positions)) + + +@dataclass(frozen=True) +class _OrderEvaluation: + fill: Optional[Fill] + row: Dict + cash_delta: float + position_delta: float + + +_ORDER_REPORT_COLUMNS = [ + "package_id", + "order_id", + "symbol", + "side", + "order_type", + "tif", + "requested_qty", + "filled_qty", + "residual_qty", + "fill_price", + "fee", + "cash_delta", + "status", + "reject_reason", + "liquidity", + "snapshot_timestamp_ns", + "decision_timestamp_ns", + "row_index", + "depth_fidelity", + "limit_fidelity", + "residual_risk", + "atomicity", +] + +_PACKAGE_REPORT_COLUMNS = [ + "package_id", + "execution_policy", + "status", + "reject_reason", + "requested_orders", + "filled_orders", + "partial_orders", + "cash_before", + "cash_after", + "net_cash_delta", + "gross_premium", + "debit", + "credit", + "max_debit", + "min_credit", + "atomicity", + "exchange_combo", + "block_trade_style", + "depth_fidelity", +] + + +def execute_option_package( + package: OptionPackageIntent, + tape: PreparedOptionTape, + *, + config: Optional[OptionExecutionConfig] = None, + positions: Optional[Dict[str, float]] = None, +) -> OptionPackageExecutionResult: + """Execute one option package against the latest observable tape snapshot.""" + cfg = config or OptionExecutionConfig() + state = _ExecutionState(cash=float(cfg.initial_cash), positions=dict(positions or {})) + orders = compile_option_package_orders(package) + policy = package.execution_policy + if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE: + return _execute_atomic_all_or_none(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.BEST_EFFORT: + return _execute_best_effort(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.SEQUENTIAL: + return _execute_sequential(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY: + return _execute_hedge_after_primary(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY: + return _execute_rebalance_only(package, orders, tape, cfg, state) + raise ValueError(f"unsupported option execution policy: {policy}") + + +def _execute_atomic_all_or_none( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + evaluations = [_evaluate_order(order, tape, cfg, trial, package.package_id) for order in orders] + all_full = all(row.row["status"] == "filled" for row in evaluations) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not all_full or not guard_ok: + reason = guard_reason or "atomic_all_or_none_unfilled_leg" + rows = [_rejected_row(ev.row, reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", reason) + fills = [] + for ev in evaluations: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], "filled", "") + + +def _execute_best_effort( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + for order in orders: + ev = _evaluate_order(order, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _execute_sequential( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + stopped = False + for order in orders: + if stopped: + row = _base_skipped_row(package.package_id, order, "sequential_previous_leg_failed", cfg) + evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0)) + continue + ev = _evaluate_order(order, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + if ev.row["status"] not in {"filled", "partial"}: + stopped = True + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _execute_hedge_after_primary( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + primary = next((order for order in orders if order.metadata.get("option_leg_role") == "primary"), orders[0]) + hedge_orders = tuple(order for order in orders if order is not primary) + primary_ev = _evaluate_order(primary, tape, cfg, trial, package.package_id) + evaluations.append(primary_ev) + if primary_ev.row["status"] != "filled": + rows = [primary_ev.row] + [_base_skipped_row(package.package_id, order, "primary_not_filled", cfg) for order in hedge_orders] + return _final_result(package, cfg, state, state, [], rows, "rejected", "primary_not_filled") + _apply_evaluation(trial, primary_ev) + fills.append(primary_ev.fill) + for order in hedge_orders: + ev = _evaluate_order(order, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _execute_rebalance_only( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + for order in orders: + target_signed = float(order.side.sign) * float(order.qty) + current = float(trial.positions.get(order.symbol, 0.0)) + delta = target_signed - current + if abs(delta) <= 1e-12: + row = _base_skipped_row(package.package_id, order, "already_at_target", cfg) + row["status"] = "no_op" + evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0)) + continue + adjusted = OrderIntent( + timestamp=order.timestamp, + symbol=order.symbol, + side=OrderSide.BUY if delta > 0 else OrderSide.SELL, + order_type=order.order_type, + qty=abs(delta), + price=order.price, + tif=order.tif, + tag=order.tag, + metadata={**order.metadata, "rebalance_target_signed_qty": target_signed, "rebalance_current_qty": current}, + ) + ev = _evaluate_order(adjusted, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _evaluate_order( + order: OrderIntent, + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, + package_id: str, +) -> _OrderEvaluation: + snapshot_index = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns) + rows = tape.snapshot_slice(snapshot_index) + row_index = _find_row_index(tape, rows, order.symbol) + if row_index is None: + return _OrderEvaluation(None, _base_rejected_row(package_id, order, "instrument_not_listed_at_snapshot", cfg), 0.0, 0.0) + fill_price, liquidity, fillable, reason = _fill_price(order, tape, row_index, cfg) + if not fillable: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, 0.0, "open", reason, liquidity), 0.0, 0.0) + available = _available_qty(order, tape, row_index) + fill_qty = min(float(order.qty), available) + residual = float(order.qty) - fill_qty + if fill_qty <= 0.0: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "no_top_of_book_size", liquidity), 0.0, 0.0) + if residual > 1e-12 and order.tif is TimeInForce.FOK: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "fok_insufficient_size", liquidity), 0.0, 0.0) + if residual > 1e-12 and order.tif is TimeInForce.IOC: + if not cfg.allow_partial_fill: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "ioc_partial_not_allowed", liquidity), 0.0, 0.0) + status = "partial" + reason = "ioc_residual_canceled" + elif residual > 1e-12 and order.tif is TimeInForce.GTC: + if not cfg.allow_partial_fill: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "gtc_waiting_for_size", liquidity), 0.0, 0.0) + status = "partial" + reason = "gtc_residual_open" + else: + status = "filled" + reason = "" + fee = fill_qty * fill_price * cfg.fee_rate + cash_delta = fill_qty * fill_price - fee if order.side is OrderSide.SELL else -(fill_qty * fill_price + fee) + position_delta = order.side.sign * fill_qty + fill = Fill( + timestamp=tape.timestamp_ns[snapshot_index], + symbol=order.symbol, + side=order.side, + qty=fill_qty, + price=fill_price, + fee=fee, + liquidity=liquidity, + order_id=order.order_id, + metadata={**order.metadata, "option_row_index": int(row_index), "package_id": package_id}, + ) + row = _row_from_order(package_id, order, tape, row_index, cfg, fill_qty, fill_price, status, reason, liquidity, fee=fee, cash_delta=cash_delta) + return _OrderEvaluation(fill, row, cash_delta, position_delta) + + +def _fill_price( + order: OrderIntent, + tape: PreparedOptionTape, + row_index: int, + cfg: OptionExecutionConfig, +) -> tuple[float, LiquiditySide, bool, str]: + bid = float(tape.bid_price[row_index]) + ask = float(tape.ask_price[row_index]) + if order.order_type is OrderType.MARKET: + return (ask if order.side is OrderSide.BUY else bid), LiquiditySide.TAKER, True, "" + if order.order_type is not OrderType.LIMIT: + return float("nan"), LiquiditySide.TAKER, False, "unsupported_option_order_type" + limit = float(order.price) + if cfg.limit_fidelity is OptionLimitFidelity.CROSS_ONLY: + if order.side is OrderSide.BUY and limit >= ask: + return ask, LiquiditySide.TAKER, True, "" + if order.side is OrderSide.SELL and limit <= bid: + return bid, LiquiditySide.TAKER, True, "" + return limit, LiquiditySide.MAKER, False, "limit_not_crossed" + if order.side is OrderSide.BUY and limit >= bid: + return min(limit, ask), LiquiditySide.MAKER if limit < ask else LiquiditySide.TAKER, True, "maker_touch_simulated" + if order.side is OrderSide.SELL and limit <= ask: + return max(limit, bid), LiquiditySide.MAKER if limit > bid else LiquiditySide.TAKER, True, "maker_touch_simulated" + return limit, LiquiditySide.MAKER, False, "limit_not_touched" + + +def _available_qty(order: OrderIntent, tape: PreparedOptionTape, row_index: int) -> float: + return float(tape.ask_size[row_index] if order.side is OrderSide.BUY else tape.bid_size[row_index]) + + +def _find_row_index(tape: PreparedOptionTape, rows: slice, symbol: str) -> Optional[int]: + for idx in range(rows.start, rows.stop): + if tape.instrument_id[idx] == symbol: + return idx + return None + + +def _apply_evaluation(state: _ExecutionState, evaluation: _OrderEvaluation) -> None: + if evaluation.fill is None: + return + state.cash += float(evaluation.cash_delta) + state.positions[evaluation.fill.symbol] = state.positions.get(evaluation.fill.symbol, 0.0) + evaluation.position_delta + + +def _package_cash_guard(package: OptionPackageIntent, evaluations: List[_OrderEvaluation]) -> tuple[bool, str]: + net_cash_delta = sum(ev.cash_delta for ev in evaluations if ev.fill is not None) + debit = max(-net_cash_delta, 0.0) + credit = max(net_cash_delta, 0.0) + if package.max_debit is not None and debit > float(package.max_debit) + 1e-12: + return False, "max_debit_exceeded" + if package.min_credit is not None and credit + 1e-12 < float(package.min_credit): + return False, "min_credit_not_met" + return True, "" + + +def _final_result( + package: OptionPackageIntent, + cfg: OptionExecutionConfig, + initial_state: _ExecutionState, + final_state: _ExecutionState, + fills: List[Optional[Fill]], + rows: List[Dict], + package_status: str, + reject_reason: str, +) -> OptionPackageExecutionResult: + concrete_fills = tuple(fill for fill in fills if fill is not None) + order_report = pd.DataFrame(rows, columns=_ORDER_REPORT_COLUMNS) + filled_orders = int((order_report["status"] == "filled").sum()) if not order_report.empty else 0 + partial_orders = int((order_report["status"] == "partial").sum()) if not order_report.empty else 0 + net_cash_delta = float(final_state.cash - initial_state.cash) + gross_premium = float(order_report["filled_qty"].mul(order_report["fill_price"]).sum()) if not order_report.empty else 0.0 + package_report = pd.DataFrame( + [ + { + "package_id": package.package_id, + "execution_policy": package.execution_policy.value, + "status": package_status, + "reject_reason": reject_reason, + "requested_orders": len(package.legs), + "filled_orders": filled_orders, + "partial_orders": partial_orders, + "cash_before": initial_state.cash, + "cash_after": final_state.cash, + "net_cash_delta": net_cash_delta, + "gross_premium": gross_premium, + "debit": max(-net_cash_delta, 0.0), + "credit": max(net_cash_delta, 0.0), + "max_debit": package.max_debit, + "min_credit": package.min_credit, + "atomicity": _atomicity_for_report(package.execution_policy), + "exchange_combo": False, + "block_trade_style": False, + "depth_fidelity": cfg.depth_fidelity.value, + } + ], + columns=_PACKAGE_REPORT_COLUMNS, + ) + positions = {symbol: qty for symbol, qty in final_state.positions.items() if abs(qty) > 1e-12} + return OptionPackageExecutionResult( + fills=concrete_fills, + order_report=order_report, + package_report=package_report, + cash=float(final_state.cash), + positions=positions, + margin_report={ + "phase": "phase4_snapshot_execution", + "margin_model": "not_implemented_until_phase5", + "gross_premium": gross_premium, + "position_count": len(positions), + }, + metadata={ + "backend": "native_option_phase4", + "execution_scope": "snapshot_package_execution", + "depth_fidelity": cfg.depth_fidelity.value, + "limit_fidelity": cfg.limit_fidelity.value, + "atomicity": _atomicity_for_report(package.execution_policy), + **cfg.metadata, + }, + ) + + +def _package_status(evaluations: List[_OrderEvaluation]) -> str: + statuses = [ev.row["status"] for ev in evaluations] + if statuses and all(status == "filled" for status in statuses): + return "filled" + if any(status == "partial" for status in statuses): + return "partial" + if any(status == "filled" for status in statuses): + return "partial" + if any(status == "open" for status in statuses): + return "open" + return "rejected" + + +def _row_from_order( + package_id: str, + order: OrderIntent, + tape: PreparedOptionTape, + row_index: int, + cfg: OptionExecutionConfig, + filled_qty: float, + fill_price: float, + status: str, + reject_reason: str, + liquidity: LiquiditySide, + *, + fee: float = 0.0, + cash_delta: float = 0.0, +) -> Dict: + snapshot_idx = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns) + return { + "package_id": package_id, + "order_id": order.order_id, + "symbol": order.symbol, + "side": order.side.value, + "order_type": order.order_type.value, + "tif": order.tif.value, + "requested_qty": float(order.qty), + "filled_qty": float(filled_qty), + "residual_qty": max(float(order.qty) - float(filled_qty), 0.0), + "fill_price": float(fill_price), + "fee": float(fee), + "cash_delta": float(cash_delta), + "status": status, + "reject_reason": reject_reason, + "liquidity": liquidity.value, + "snapshot_timestamp_ns": int(tape.timestamp_ns[snapshot_idx]), + "decision_timestamp_ns": int(order.timestamp), + "row_index": int(row_index), + "depth_fidelity": cfg.depth_fidelity.value, + "limit_fidelity": cfg.limit_fidelity.value, + "residual_risk": bool(status == "partial"), + "atomicity": order.metadata.get("atomicity", ""), + } + + +def _base_rejected_row(package_id: str, order: OrderIntent, reason: str, cfg: OptionExecutionConfig) -> Dict: + return _base_skipped_row(package_id, order, reason, cfg, status="rejected") + + +def _base_skipped_row( + package_id: str, + order: OrderIntent, + reason: str, + cfg: OptionExecutionConfig, + *, + status: str = "skipped", +) -> Dict: + return { + "package_id": package_id, + "order_id": order.order_id, + "symbol": order.symbol, + "side": order.side.value, + "order_type": order.order_type.value, + "tif": order.tif.value, + "requested_qty": float(order.qty), + "filled_qty": 0.0, + "residual_qty": float(order.qty), + "fill_price": float("nan"), + "fee": 0.0, + "cash_delta": 0.0, + "status": status, + "reject_reason": reason, + "liquidity": "", + "snapshot_timestamp_ns": 0, + "decision_timestamp_ns": int(order.timestamp), + "row_index": -1, + "depth_fidelity": cfg.depth_fidelity.value, + "limit_fidelity": cfg.limit_fidelity.value, + "residual_risk": False, + "atomicity": order.metadata.get("atomicity", ""), + } + + +def _rejected_row(row: Dict, reason: str) -> Dict: + rejected = dict(row) + rejected["status"] = "rejected" + rejected["reject_reason"] = reason + rejected["filled_qty"] = 0.0 + rejected["residual_qty"] = rejected["requested_qty"] + rejected["fee"] = 0.0 + rejected["cash_delta"] = 0.0 + rejected["residual_risk"] = False + return rejected + + +def _atomicity_for_report(policy: OptionPackageExecutionPolicy) -> str: + if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE: + return "simulated_atomic_all_or_none" + if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY: + return "simulated_primary_then_hedge" + if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY: + return "simulated_rebalance_only" + return f"simulated_{policy.value}" + + +def _coerce_enum(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc diff --git a/options/packages.py b/options/packages.py new file mode 100644 index 0000000..57f23af --- /dev/null +++ b/options/packages.py @@ -0,0 +1,147 @@ +""" +Option package intents and compiler. + +This layer turns option-domain package legs into QuantBT `OrderIntent` leaves. +It does not execute orders or maintain a ledger. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Sequence, Tuple, Union + +from ..core.orders import OrderIntent +from ..core.schema import OrderSide, OrderType, TimeInForce + + +class OptionPackageExecutionPolicy(str, Enum): + ATOMIC_ALL_OR_NONE = "atomic_all_or_none" + BEST_EFFORT = "best_effort" + SEQUENTIAL = "sequential" + HEDGE_AFTER_PRIMARY = "hedge_after_primary" + REBALANCE_ONLY = "rebalance_only" + + +@dataclass(frozen=True) +class OptionPackageLeg: + """ + One option leg inside a package. + + `side` owns direction. `ratio` is always positive and scales from package + quantity, so callers cannot hide direction in a negative ratio. + """ + + instrument_id: str + side: Union[OrderSide, str] + ratio: float + order_type: Union[OrderType, str] = OrderType.MARKET + limit_price: Optional[float] = None + tif: Union[TimeInForce, str] = TimeInForce.FOK + role: str = "leg" + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "side", _coerce_enum(OrderSide, self.side, "side")) + object.__setattr__(self, "order_type", _coerce_enum(OrderType, self.order_type, "order_type")) + object.__setattr__(self, "tif", _coerce_enum(TimeInForce, self.tif, "tif")) + if not self.instrument_id: + raise ValueError("instrument_id is required") + if self.ratio <= 0.0: + raise ValueError("ratio must be > 0; side owns direction") + if self.order_type not in (OrderType.MARKET, OrderType.LIMIT): + raise ValueError("Phase 4 option package legs support market and limit orders only") + if self.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): + if self.limit_price is None or self.limit_price <= 0.0: + raise ValueError("limit option legs require limit_price > 0") + if not self.role: + raise ValueError("role is required") + + +@dataclass(frozen=True) +class OptionPackageIntent: + timestamp_ns: int + package_id: str + legs: Tuple[OptionPackageLeg, ...] + quantity: float = 1.0 + execution_policy: Union[OptionPackageExecutionPolicy, str] = OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE + max_debit: Optional[float] = None + min_credit: Optional[float] = None + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "execution_policy", + _coerce_enum(OptionPackageExecutionPolicy, self.execution_policy, "execution_policy"), + ) + object.__setattr__(self, "timestamp_ns", int(self.timestamp_ns)) + object.__setattr__(self, "legs", tuple(self.legs)) + if self.timestamp_ns <= 0: + raise ValueError("timestamp_ns must be > 0") + if not self.package_id: + raise ValueError("package_id is required") + if len(self.legs) == 0: + raise ValueError("OptionPackageIntent requires at least one leg") + if self.quantity <= 0.0: + raise ValueError("quantity must be > 0") + if self.max_debit is not None and self.max_debit < 0.0: + raise ValueError("max_debit must be >= 0") + if self.min_credit is not None and self.min_credit < 0.0: + raise ValueError("min_credit must be >= 0") + + +def compile_option_package_orders(package: OptionPackageIntent) -> Tuple[OrderIntent, ...]: + """Compile an option package to `OrderIntent` leaves with package metadata.""" + orders = [] + atomicity = _atomicity_label(package.execution_policy) + for leg_index, leg in enumerate(package.legs): + metadata = { + **leg.metadata, + "package_id": package.package_id, + "package_type": "option_package", + "option_package_id": package.package_id, + "option_leg_index": int(leg_index), + "option_leg_ratio": float(leg.ratio), + "option_leg_role": leg.role, + "option_execution_policy": package.execution_policy.value, + "atomicity": atomicity, + "exchange_combo": False, + "block_trade_style": False, + "simulated_atomicity": package.execution_policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE, + } + qty = float(package.quantity) * float(leg.ratio) + order = OrderIntent( + timestamp=package.timestamp_ns, + symbol=leg.instrument_id, + side=leg.side, + order_type=leg.order_type, + qty=qty, + price=leg.limit_price, + tif=leg.tif, + tag=leg.tag or package.tag, + metadata=metadata, + ) + orders.append(order) + return tuple(orders) + + +def _atomicity_label(policy: OptionPackageExecutionPolicy) -> str: + if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE: + return "simulated_all_or_none" + if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY: + return "simulated_primary_then_hedge" + if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY: + return "simulated_rebalance_only" + return f"simulated_{policy.value}" + + +def _coerce_enum(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc diff --git a/tests/options/test_phase4_execution.py b/tests/options/test_phase4_execution.py new file mode 100644 index 0000000..03413a1 --- /dev/null +++ b/tests/options/test_phase4_execution.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + OptionExecutionConfig, + OptionLimitFidelity, + OptionPackageExecutionPolicy, + OptionPackageIntent, + OptionPackageLeg, + prepare_option_tape, + execute_option_package, +) + + +def _package(timestamp_ns: int, *legs: OptionPackageLeg, policy=OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE, **kwargs): + return OptionPackageIntent( + timestamp_ns=timestamp_ns, + package_id=kwargs.pop("package_id", "pkg"), + quantity=kwargs.pop("quantity", 1.0), + execution_policy=policy, + legs=tuple(legs), + **kwargs, + ) + + +def test_phase4_market_fills_use_ask_for_buy_and_bid_for_sell(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + ts = int(tape.timestamp_ns[0]) + package = _package( + ts, + OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", "buy", 1.0), + OptionPackageLeg("BTC-01FEB26-110000-P.DERIBIT", "sell", 1.0), + policy=OptionPackageExecutionPolicy.BEST_EFFORT, + ) + + result = execute_option_package(package, tape, config=OptionExecutionConfig(initial_cash=10.0)) + + assert len(result.fills) == 2 + buy = result.order_report.loc[result.order_report["side"] == "buy"].iloc[0] + sell = result.order_report.loc[result.order_report["side"] == "sell"].iloc[0] + assert buy["fill_price"] == pytest.approx(0.021) + assert sell["fill_price"] == pytest.approx(0.030) + assert buy["fill_price"] != pytest.approx((0.020 + 0.021) / 2.0) + assert bool(result.package_report.loc[0, "exchange_combo"]) is False + assert bool(result.package_report.loc[0, "block_trade_style"]) is False + + +def test_phase4_atomic_all_or_none_rolls_back_on_leg_failure(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + ts = int(tape.timestamp_ns[0]) + package = _package( + ts, + OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", "buy", 1.0), + OptionPackageLeg("BTC-01FEB26-110000-P.DERIBIT", "sell", 100.0), + quantity=1.0, + policy=OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE, + ) + + result = execute_option_package(package, tape, config=OptionExecutionConfig(initial_cash=10.0)) + + assert len(result.fills) == 0 + assert result.cash == 10.0 + assert result.positions == {} + assert set(result.order_report["status"]) == {"rejected"} + assert result.package_report.loc[0, "status"] == "rejected" + assert result.package_report.loc[0, "atomicity"] == "simulated_atomic_all_or_none" + assert result.margin_report["position_count"] == 0 + + +def test_phase4_ioc_partial_reports_residual_risk(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + ts = int(tape.timestamp_ns[0]) + package = _package( + ts, + OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", "buy", 20.0, tif="ioc"), + policy=OptionPackageExecutionPolicy.BEST_EFFORT, + ) + + result = execute_option_package(package, tape, config=OptionExecutionConfig(initial_cash=10.0, allow_partial_fill=True)) + row = result.order_report.iloc[0] + + assert len(result.fills) == 1 + assert row["status"] == "partial" + assert row["filled_qty"] == pytest.approx(12.0) + assert row["residual_qty"] == pytest.approx(8.0) + assert bool(row["residual_risk"]) is True + assert result.package_report.loc[0, "status"] == "partial" + + +def test_phase4_debit_guard_rejects_without_mutating_state(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + ts = int(tape.timestamp_ns[0]) + package = _package( + ts, + OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", "buy", 1.0), + policy=OptionPackageExecutionPolicy.BEST_EFFORT, + max_debit=0.001, + ) + + result = execute_option_package(package, tape, config=OptionExecutionConfig(initial_cash=10.0)) + + assert len(result.fills) == 0 + assert result.cash == 10.0 + assert result.positions == {} + assert result.package_report.loc[0, "reject_reason"] == "max_debit_exceeded" + + +def test_phase4_limit_fidelity_modes_are_explicit(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + ts = int(tape.timestamp_ns[0]) + passive = _package( + ts, + OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", "buy", 1.0, order_type="limit", limit_price=0.0205, tif="gtc"), + policy=OptionPackageExecutionPolicy.BEST_EFFORT, + ) + + cross_only = execute_option_package(passive, tape, config=OptionExecutionConfig(limit_fidelity=OptionLimitFidelity.CROSS_ONLY)) + maker_touch = execute_option_package(passive, tape, config=OptionExecutionConfig(limit_fidelity=OptionLimitFidelity.MAKER_TOUCH)) + + assert cross_only.order_report.loc[0, "status"] == "open" + assert cross_only.order_report.loc[0, "reject_reason"] == "limit_not_crossed" + assert maker_touch.order_report.loc[0, "status"] == "filled" + assert maker_touch.order_report.loc[0, "fill_price"] == pytest.approx(0.0205) + assert maker_touch.order_report.loc[0, "liquidity"] == "maker" + assert maker_touch.metadata["limit_fidelity"] == "maker_touch" + + +def test_phase4_hedge_after_primary_skips_hedges_when_primary_fails(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + ts = int(tape.timestamp_ns[0]) + package = _package( + ts, + OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", "buy", 100.0, role="primary"), + OptionPackageLeg("BTC-01FEB26-110000-P.DERIBIT", "sell", 1.0, role="hedge"), + policy=OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY, + ) + + result = execute_option_package(package, tape, config=OptionExecutionConfig(initial_cash=10.0)) + + assert len(result.fills) == 0 + assert result.order_report.iloc[0]["status"] == "rejected" + assert result.order_report.iloc[1]["status"] == "skipped" + assert result.order_report.iloc[1]["reject_reason"] == "primary_not_filled" + assert result.positions == {} + + +def test_phase4_rebalance_only_trades_delta_to_target(option_phase3_chain, option_phase3_registry): + tape = prepare_option_tape(option_phase3_chain, option_phase3_registry) + ts = int(tape.timestamp_ns[0]) + package = _package( + ts, + OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", "buy", 2.0), + policy=OptionPackageExecutionPolicy.REBALANCE_ONLY, + ) + + result = execute_option_package( + package, + tape, + config=OptionExecutionConfig(initial_cash=10.0), + positions={"BTC-01FEB26-100000-C.DERIBIT": 1.5}, + ) + + assert len(result.fills) == 1 + assert result.fills[0].qty == pytest.approx(0.5) + assert result.positions["BTC-01FEB26-100000-C.DERIBIT"] == pytest.approx(2.0) diff --git a/tests/options/test_phase4_packages.py b/tests/options/test_phase4_packages.py new file mode 100644 index 0000000..2c97e6d --- /dev/null +++ b/tests/options/test_phase4_packages.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + OptionPackageExecutionPolicy, + OptionPackageIntent, + OptionPackageLeg, + compile_option_package_orders, +) + + +def test_phase4_option_package_leg_side_owns_direction_and_ratio_positive(): + with pytest.raises(ValueError, match="ratio must be > 0"): + OptionPackageLeg(instrument_id="BTC-C", side="buy", ratio=-1.0) + + leg = OptionPackageLeg(instrument_id="BTC-C", side="sell", ratio=2.0, role="short_call") + assert leg.side.value == "sell" + assert leg.ratio == 2.0 + + +def test_phase4_option_package_intent_compiles_to_order_intents_with_metadata(): + package = OptionPackageIntent( + timestamp_ns=1_767_225_600_000_000_000, + package_id="vertical-1", + quantity=3.0, + execution_policy=OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE, + legs=( + OptionPackageLeg(instrument_id="BTC-01FEB26-100000-C.DERIBIT", side="buy", ratio=1.0, role="long_call"), + OptionPackageLeg(instrument_id="BTC-01FEB26-110000-P.DERIBIT", side="sell", ratio=2.0, role="short_put"), + ), + ) + + orders = compile_option_package_orders(package) + + assert len(orders) == 2 + assert orders[0].qty == 3.0 + assert orders[1].qty == 6.0 + assert orders[0].metadata["package_type"] == "option_package" + assert orders[0].metadata["option_package_id"] == "vertical-1" + assert orders[0].metadata["option_leg_ratio"] == 1.0 + assert orders[0].metadata["option_leg_role"] == "long_call" + assert orders[0].metadata["atomicity"] == "simulated_all_or_none" + assert orders[0].metadata["exchange_combo"] is False + assert orders[0].metadata["block_trade_style"] is False + + +def test_phase4_option_package_rejects_empty_or_invalid_guards(): + with pytest.raises(ValueError, match="at least one leg"): + OptionPackageIntent(timestamp_ns=1, package_id="empty", legs=()) + + leg = OptionPackageLeg(instrument_id="BTC-C", side="buy", ratio=1.0) + with pytest.raises(ValueError, match="max_debit"): + OptionPackageIntent(timestamp_ns=1, package_id="bad", legs=(leg,), max_debit=-1.0) + with pytest.raises(ValueError, match="min_credit"): + OptionPackageIntent(timestamp_ns=1, package_id="bad", legs=(leg,), min_credit=-1.0) + + +def test_phase4_option_package_leg_rejects_stop_orders_until_lifecycle_phase(): + with pytest.raises(ValueError, match="market and limit"): + OptionPackageLeg(instrument_id="BTC-C", side="buy", ratio=1.0, order_type="stop_market") diff --git a/upgrade/implement.md b/upgrade/implement.md index c2bd07b..f71e7ea 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -3021,6 +3021,57 @@ Technical debt after Phase 17.3: - Options still have no package compiler, execution engine, ledger, expiry lifecycle, endpoint route, or Nautilus validation. +### Phase 17.4 - Package Compiler And Options Execution + +Status: completed. + +Implemented: + +- Added option package domain objects: + - `OptionPackageLeg`; + - `OptionPackageIntent`; + - `OptionPackageExecutionPolicy`. +- Added `compile_option_package_orders(...)` to compile option package legs into + existing QuantBT `OrderIntent` leaves with package metadata. +- Added snapshot-level option package execution: + - `OptionExecutionConfig`; + - `OptionLimitFidelity`; + - `OptionDepthFidelity`; + - `OptionPackageExecutionResult`; + - `execute_option_package(...)`. +- Supported policies: + - `ATOMIC_ALL_OR_NONE`; + - `BEST_EFFORT`; + - `SEQUENTIAL`; + - `HEDGE_AFTER_PRIMARY`; + - `REBALANCE_ONLY`. +- Locked Phase 4 fill rules: + - market buy at ask; + - market sell at bid; + - no mark/mid default execution; + - FOK/IOC/GTC behavior where feasible on top-of-book snapshots; + - package debit/credit guard; + - explicit simulated atomicity/fidelity labels. +- Exported Phase 4 APIs from top-level `quantbt`. + +Latest tests: + +- options tests: `54 passed`. +- import smoke: `phase4_import_smoke=pass`. +- full non-real regression: `340 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 17.4: + +- Execution remains snapshot/top-of-book, not L2 replay or venue-native combo + matching. +- `MAKER_TOUCH` is an explicit approximation, not real maker queue priority. +- Margin report is a placeholder until the multi-currency ledger phase. +- Stop/conditional option lifecycle is rejected until lifecycle semantics exist. +- Package debit/credit guard works in package premium units; full currency + conversion is deferred. +- Options still have no endpoint route, full ledger, expiry lifecycle, or + Nautilus adapter. + --- ## Backend Selection Guide diff --git a/upgrade/option_backtest_plan/phase4_packages_execution_status.md b/upgrade/option_backtest_plan/phase4_packages_execution_status.md new file mode 100644 index 0000000..e825d8c --- /dev/null +++ b/upgrade/option_backtest_plan/phase4_packages_execution_status.md @@ -0,0 +1,100 @@ +# Phase 4 - Package Compiler And Options Execution Status + +Date: 2026-07-23 + +Branch: `feat/option-engine` + +## Scope Completed + +Phase 4 added option package compilation and snapshot-level package execution. +It does not add the final multi-currency ledger, venue margin, expiry +lifecycle, endpoint route, or Nautilus validation. + +Implemented: + +- `options/packages.py` + - `OptionPackageLeg`; + - `OptionPackageIntent`; + - `OptionPackageExecutionPolicy`; + - `compile_option_package_orders(...)`. +- `options/execution.py` + - `OptionExecutionConfig`; + - `OptionLimitFidelity`; + - `OptionDepthFidelity`; + - `OptionPackageExecutionResult`; + - `execute_option_package(...)`. +- Public exports through `quantbt.options` and top-level `quantbt`. + +## Domain Guarantees Locked + +- Package leg direction belongs to `side`. +- `ratio` is positive only. +- Phase 4 option package legs support market and limit orders only. +- Limit option legs require a positive `limit_price`. +- Package compiler emits existing QuantBT `OrderIntent` leaves. +- Order metadata records: + - package id; + - package type; + - option leg index; + - leg ratio; + - leg role; + - execution policy; + - simulated atomicity label; + - `exchange_combo=False`; + - `block_trade_style=False`. +- Market buy fills at ask. +- Market sell fills at bid. +- Market fills never use mark or mid by default. +- `ATOMIC_ALL_OR_NONE` rolls back cash, positions, and reports when any leg + fails. +- IOC partial fills report residual risk. +- FOK rejects insufficient top-of-book size. +- GTC can remain open or partial when top-of-book size is insufficient. +- Package debit/credit guard rejects and rolls back simulated fills when + violated. +- `HEDGE_AFTER_PRIMARY` only attempts hedge legs after primary leg is fully + filled. +- `REBALANCE_ONLY` trades the delta from current position to target package + ratio. + +## Tests Run + +Commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.execute_option_package; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase4_import_smoke=pass')" +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +``` + +Results: + +- compileall: pass. +- options tests: `54 passed`. +- import smoke: `phase4_import_smoke=pass`. +- full non-real regression: `340 passed, 1 skipped, 3 warnings`. + +Existing warnings are unrelated to options: + +- one pandas runtime warning in a portfolio missing-data scenario; +- two matplotlib tight-layout warnings in walk-forward quick plot tests. + +## Technical Debt + +- Execution is snapshot/top-of-book only. It is not real L2 replay, queue + priority, or venue-native combo matching. +- `MAKER_TOUCH` is an approximation and is labelled as simulated fidelity. +- Margin report is a Phase 4 placeholder; full multi-currency ledger, fee + currency, margin, settlement, and expiry lifecycle belong to Phase 5+. +- Stop/conditional option order lifecycle is rejected in Phase 4. +- Debit/credit guard currently uses package premium units. Full reporting + currency conversion is deferred to ledger work. +- There is still no options endpoint route and no Nautilus option adapter. + +## Conclusion + +Phase 4 is complete and safe to build on. QuantBT can now compile option +packages and simulate snapshot-level package fills with explicit policy reports. +The next phase must add the real option ledger, fees, lifecycle, settlement, +and margin semantics before this becomes a full options backtest engine. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index af617a5..1770c4d 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -439,6 +439,83 @@ Acceptance: - Package metadata states whether atomicity is simulated, exchange combo, or block-trade style. +Status: completed. + +Implemented: + +- Added `options/packages.py`: + - `OptionPackageLeg`; + - `OptionPackageIntent`; + - `OptionPackageExecutionPolicy`; + - `compile_option_package_orders(...)`. +- Enforced package-leg domain rules: + - `side` owns direction; + - `ratio` must be positive; + - Phase 4 supports market and limit option package legs only; + - limit legs require positive `limit_price`. +- Compiled package legs into existing `OrderIntent` leaves with package + metadata: + - package id; + - leg index; + - leg ratio; + - leg role; + - execution policy; + - simulated atomicity label; + - `exchange_combo=False`; + - `block_trade_style=False`. +- Added `options/execution.py`: + - `OptionExecutionConfig`; + - `OptionLimitFidelity`; + - `OptionDepthFidelity`; + - `OptionPackageExecutionResult`; + - `execute_option_package(...)`. +- Implemented snapshot-level option fill behavior: + - market buy fills at ask; + - market sell fills at bid; + - limit `CROSS_ONLY`; + - limit `MAKER_TOUCH` as explicit simulated maker fidelity; + - top-of-book size guard; + - FOK full-fill/reject; + - IOC partial with residual-risk report; + - GTC open/partial behavior where feasible; + - package debit/credit guard. +- Implemented package policies: + - `ATOMIC_ALL_OR_NONE`; + - `BEST_EFFORT`; + - `SEQUENTIAL`; + - `HEDGE_AFTER_PRIMARY`; + - `REBALANCE_ONLY`. +- Added Phase 4 tests for package validation, order compilation, AON rollback, + market bid/ask fills, IOC partials, debit guards, limit fidelity modes, + primary-then-hedge, and rebalance-to-target behavior. +- Exported Phase 4 package and execution APIs from `quantbt.options` and + top-level `quantbt`. + +Latest local tests: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` + - result: `54 passed` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.execute_option_package; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase4_import_smoke=pass')"` + - result: `phase4_import_smoke=pass` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` + - result: `340 passed, 1 skipped, 3 warnings` + +Technical debt after Phase 4: + +- Execution is snapshot/top-of-book only. It is not L2 replay, queue priority, + or venue-native combo matching. +- Margin report is intentionally a Phase 4 placeholder. Real multi-currency + ledger, fees by currency, margin, settlement, expiry, and lifecycle are Phase + 5+ work. +- Stop orders and conditional lifecycle orders are rejected in Phase 4; they + should be added only after lifecycle semantics are implemented. +- `MAKER_TOUCH` is an explicit approximation. It should not be described as + exchange-native maker queue simulation. +- Package debit/credit guard works on simulated fills in package premium units; + full portfolio/multi-currency conversion is deferred. +- No endpoint route or Nautilus validation is implemented in Phase 4 by design. + ## Phase 5 - Multi-Currency Ledger, Fees, Lifecycle Files: From ea05527374ed8351624e2f0cddceec8820c35945 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 11:38:14 +0000 Subject: [PATCH 07/15] feat: add options phase 5 ledger lifecycle --- __init__.py | 22 ++ options/__init__.py | 25 ++ options/fees.py | 135 +++++++++ options/ledger.py | 263 ++++++++++++++++++ options/lifecycle.py | 94 +++++++ tests/options/test_phase5_fees.py | 92 ++++++ tests/options/test_phase5_ledger.py | 63 +++++ tests/options/test_phase5_lifecycle.py | 122 ++++++++ upgrade/implement.md | 55 ++++ .../phase5_ledger_fees_lifecycle_status.md | 105 +++++++ .../quantbt_options_engine_execution_plan.md | 63 +++++ 11 files changed, 1039 insertions(+) create mode 100644 options/fees.py create mode 100644 options/ledger.py create mode 100644 options/lifecycle.py create mode 100644 tests/options/test_phase5_fees.py create mode 100644 tests/options/test_phase5_ledger.py create mode 100644 tests/options/test_phase5_lifecycle.py create mode 100644 upgrade/option_backtest_plan/phase5_ledger_fees_lifecycle_status.md diff --git a/__init__.py b/__init__.py index 9663c5b..bf11e1f 100644 --- a/__init__.py +++ b/__init__.py @@ -184,6 +184,8 @@ OptionDecisionFillPolicy, OptionDepthFidelity, OptionExecutionConfig, + OptionFeeResult, + OptionFeeSchedule, OptionGreeks, OptionInstrumentRegistry, OptionInstrumentSpec, @@ -193,8 +195,12 @@ OptionPackageExecutionResult, OptionPackageIntent, OptionPackageLeg, + OptionLedger, + OptionPosition, OptionSelection, OptionSelectionFilters, + OptionSettlementRepresentation, + OptionSettlementResult, OptionTapeSignature, OptionVenueConvention, PremiumConvention, @@ -209,9 +215,12 @@ black76_parity_residual, black76_parity_value, black76_price, + calculate_option_fee, compile_option_package_orders, deribit_inverse_option_convention, + deribit_inverse_fee_schedule, deribit_linear_usdc_option_convention, + deribit_linear_usdc_fee_schedule, implied_vol_black76, implied_vol_inverse_black76_base, inverse_black76_greeks_base, @@ -222,12 +231,14 @@ inverse_black76_price_base, linear_black76_greeks, execute_option_package, + option_expiry_payoff_per_unit, prepare_option_tape, scale_greeks_to_reporting_currency, select_atm_option, select_target_delta_option, select_target_dte_option, select_target_moneyness_option, + settle_option_expiry, validate_option_chain_frame, ) @@ -300,6 +311,8 @@ "OptionDecisionFillPolicy", "OptionDepthFidelity", "OptionExecutionConfig", + "OptionFeeResult", + "OptionFeeSchedule", "OptionGreeks", "OptionInstrumentRegistry", "OptionInstrumentSpec", @@ -309,8 +322,12 @@ "OptionPackageExecutionResult", "OptionPackageIntent", "OptionPackageLeg", + "OptionLedger", + "OptionPosition", "OptionSelection", "OptionSelectionFilters", + "OptionSettlementRepresentation", + "OptionSettlementResult", "OptionTapeSignature", "OptionVenueConvention", "PremiumConvention", @@ -325,9 +342,12 @@ "black76_parity_residual", "black76_parity_value", "black76_price", + "calculate_option_fee", "compile_option_package_orders", "deribit_inverse_option_convention", + "deribit_inverse_fee_schedule", "deribit_linear_usdc_option_convention", + "deribit_linear_usdc_fee_schedule", "implied_vol_black76", "implied_vol_inverse_black76_base", "inverse_black76_greeks_base", @@ -338,12 +358,14 @@ "inverse_black76_price_base", "linear_black76_greeks", "execute_option_package", + "option_expiry_payoff_per_unit", "prepare_option_tape", "scale_greeks_to_reporting_currency", "select_atm_option", "select_target_delta_option", "select_target_dte_option", "select_target_moneyness_option", + "settle_option_expiry", "validate_option_chain_frame", "LEGACY_PORTFOLIO_MODES", "LEGACY_PORTFOLIO_SIZING_MODES", diff --git a/options/__init__.py b/options/__init__.py index 7e289e3..5fd6a81 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -20,6 +20,13 @@ OptionPackageExecutionResult, execute_option_package, ) +from .fees import ( + OptionFeeResult, + OptionFeeSchedule, + calculate_option_fee, + deribit_inverse_fee_schedule, + deribit_linear_usdc_fee_schedule, +) from .greeks import ( OptionGreeks, inverse_black76_greeks_base, @@ -28,6 +35,13 @@ scale_greeks_to_reporting_currency, ) from .iv import IVStatus, ImpliedVolResult, implied_vol_black76, implied_vol_inverse_black76_base +from .ledger import OptionLedger, OptionPosition +from .lifecycle import ( + OptionSettlementRepresentation, + OptionSettlementResult, + option_expiry_payoff_per_unit, + settle_option_expiry, +) from .packages import ( OptionPackageExecutionPolicy, OptionPackageIntent, @@ -73,6 +87,8 @@ "OptionDecisionFillPolicy", "OptionDepthFidelity", "OptionExecutionConfig", + "OptionFeeResult", + "OptionFeeSchedule", "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", @@ -82,10 +98,14 @@ "OptionPackageExecutionResult", "OptionPackageIntent", "OptionPackageLeg", + "OptionLedger", "OptionVenueConvention", "OptionSelection", "OptionSelectionFilters", + "OptionSettlementRepresentation", + "OptionSettlementResult", "OptionTapeSignature", + "OptionPosition", "PremiumConvention", "PreparedOptionTape", "SettlementStyle", @@ -97,9 +117,12 @@ "black76_parity_residual", "black76_parity_value", "black76_price", + "calculate_option_fee", "compile_option_package_orders", "deribit_inverse_option_convention", + "deribit_inverse_fee_schedule", "deribit_linear_usdc_option_convention", + "deribit_linear_usdc_fee_schedule", "implied_vol_black76", "implied_vol_inverse_black76_base", "inverse_black76_greeks_base", @@ -113,11 +136,13 @@ "linear_black76_greeks", "available_option_rows", "execute_option_package", + "option_expiry_payoff_per_unit", "prepare_option_tape", "scale_greeks_to_reporting_currency", "select_atm_option", "select_target_delta_option", "select_target_dte_option", "select_target_moneyness_option", + "settle_option_expiry", "validate_option_chain_frame", ] diff --git a/options/fees.py b/options/fees.py new file mode 100644 index 0000000..d358e93 --- /dev/null +++ b/options/fees.py @@ -0,0 +1,135 @@ +""" +Option fee schedules. + +Phase 5 implements deterministic per-leg capped fees. There is intentionally no +package-level cap because real venues cap option fees per contract/leg. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Union + +from ..core.orders import Fill +from ..core.schema import LiquiditySide +from .schema import OptionInstrumentSpec, PremiumConvention + + +@dataclass(frozen=True) +class OptionFeeResult: + fee: float + currency: str + raw_fee: float + cap: float + capped: bool + schedule_id: str + + +@dataclass(frozen=True) +class OptionFeeSchedule: + schedule_id: str + fee_currency: str + maker_rate: float = 0.0 + taker_rate: float = 0.0 + cap_premium_fraction: float = 0.125 + per_contract_fee: float = 0.0 + premium_convention: Union[PremiumConvention, str] = PremiumConvention.LINEAR_QUOTE + + def __post_init__(self) -> None: + object.__setattr__(self, "premium_convention", _coerce_premium(self.premium_convention)) + object.__setattr__(self, "fee_currency", str(self.fee_currency).upper()) + if not self.schedule_id: + raise ValueError("schedule_id is required") + if not self.fee_currency: + raise ValueError("fee_currency is required") + if self.maker_rate < 0.0 or self.taker_rate < 0.0: + raise ValueError("maker_rate and taker_rate must be >= 0") + if self.cap_premium_fraction < 0.0: + raise ValueError("cap_premium_fraction must be >= 0") + if self.per_contract_fee < 0.0: + raise ValueError("per_contract_fee must be >= 0") + + def rate_for(self, liquidity: LiquiditySide) -> float: + return self.maker_rate if liquidity is LiquiditySide.MAKER else self.taker_rate + + +def deribit_inverse_fee_schedule( + *, + base_currency: str = "BTC", + per_contract_fee: float = 0.0003, + cap_premium_fraction: float = 0.125, +) -> OptionFeeSchedule: + return OptionFeeSchedule( + schedule_id=f"deribit_{base_currency.lower()}_inverse_options_phase5", + fee_currency=base_currency, + per_contract_fee=per_contract_fee, + cap_premium_fraction=cap_premium_fraction, + premium_convention=PremiumConvention.INVERSE_BASE, + ) + + +def deribit_linear_usdc_fee_schedule( + *, + taker_rate: float = 0.0003, + maker_rate: float = 0.0003, + cap_premium_fraction: float = 0.125, +) -> OptionFeeSchedule: + return OptionFeeSchedule( + schedule_id="deribit_linear_usdc_options_phase5", + fee_currency="USDC", + maker_rate=maker_rate, + taker_rate=taker_rate, + cap_premium_fraction=cap_premium_fraction, + premium_convention=PremiumConvention.LINEAR_QUOTE, + ) + + +def calculate_option_fee( + fill: Fill, + instrument: OptionInstrumentSpec, + schedule: OptionFeeSchedule, + *, + reference_price: float, +) -> OptionFeeResult: + """ + Calculate a per-leg capped option fee. + + For inverse options the common venue-like form is a base-currency fee per + contract capped by a fraction of option premium. For linear options the raw + fee is reference notional times rate, also capped by option premium. + """ + if schedule.premium_convention != instrument.premium_convention: + raise ValueError("fee schedule premium convention does not match instrument") + if schedule.fee_currency != instrument.premium_currency: + raise ValueError("fee schedule currency must match option premium currency in Phase 5") + if reference_price <= 0.0: + raise ValueError("reference_price must be > 0") + premium_notional = float(fill.qty) * float(fill.price) * float(instrument.multiplier) + cap = premium_notional * float(schedule.cap_premium_fraction) + if instrument.premium_convention is PremiumConvention.INVERSE_BASE: + raw_fee = float(fill.qty) * float(instrument.multiplier) * float(schedule.per_contract_fee) + else: + raw_fee = ( + float(fill.qty) + * float(instrument.multiplier) + * float(reference_price) + * float(schedule.rate_for(fill.liquidity)) + ) + fee = min(raw_fee, cap) if schedule.cap_premium_fraction > 0.0 else raw_fee + return OptionFeeResult( + fee=float(fee), + currency=schedule.fee_currency, + raw_fee=float(raw_fee), + cap=float(cap), + capped=bool(fee < raw_fee), + schedule_id=schedule.schedule_id, + ) + + +def _coerce_premium(value: Union[PremiumConvention, str]) -> PremiumConvention: + if isinstance(value, PremiumConvention): + return value + try: + return PremiumConvention(str(value)) + except ValueError as exc: + raise ValueError("premium_convention is invalid") from exc diff --git a/options/ledger.py b/options/ledger.py new file mode 100644 index 0000000..da0ff33 --- /dev/null +++ b/options/ledger.py @@ -0,0 +1,263 @@ +""" +Multi-currency option ledger. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Iterable, Optional + +import pandas as pd + +from ..core.orders import Fill +from ..core.schema import OrderSide +from .fees import OptionFeeResult +from .schema import OptionInstrumentSpec + + +@dataclass +class OptionPosition: + symbol: str + qty: float = 0.0 + avg_entry: float = 0.0 + realized_pnl: float = 0.0 + premium_currency: str = "" + settlement_currency: str = "" + multiplier: float = 1.0 + + @property + def is_flat(self) -> bool: + return abs(self.qty) <= 1e-12 + + +@dataclass +class OptionLedger: + cash: Dict[str, float] = field(default_factory=dict) + positions: Dict[str, OptionPosition] = field(default_factory=dict) + realized_pnl: Dict[str, float] = field(default_factory=dict) + fees: Dict[str, float] = field(default_factory=dict) + settlement_cashflows: Dict[str, float] = field(default_factory=dict) + margin_locked: Dict[str, float] = field(default_factory=dict) + events: list[Dict] = field(default_factory=list) + settled_symbols: set[str] = field(default_factory=set) + + @classmethod + def from_cash(cls, balances: Dict[str, float]) -> "OptionLedger": + ledger = cls() + for currency, amount in balances.items(): + ledger.cash[str(currency).upper()] = float(amount) + return ledger + + def apply_fill( + self, + fill: Fill, + instrument: OptionInstrumentSpec, + *, + fee: Optional[OptionFeeResult] = None, + timestamp_ns: Optional[int] = None, + ) -> None: + """Apply premium cashflow, fee, position quantity, and realized PnL.""" + premium_currency = instrument.premium_currency + fee_amount = float(fee.fee) if fee is not None else float(fill.fee) + fee_currency = fee.currency if fee is not None else premium_currency + premium = float(fill.qty) * float(fill.price) * float(instrument.multiplier) + premium_cash_delta = premium if fill.side is OrderSide.SELL else -premium + self._add_cash(premium_currency, premium_cash_delta) + if fee_amount: + self._add_cash(fee_currency, -fee_amount) + self.fees[fee_currency] = self.fees.get(fee_currency, 0.0) + fee_amount + realized = self._apply_position(fill, instrument) + if realized: + self.realized_pnl[premium_currency] = self.realized_pnl.get(premium_currency, 0.0) + realized + self.events.append( + { + "timestamp_ns": int(timestamp_ns if timestamp_ns is not None else fill.timestamp), + "event_type": "fill", + "symbol": fill.symbol, + "side": fill.side.value, + "qty": float(fill.qty), + "price": float(fill.price), + "premium_currency": premium_currency, + "premium_cashflow": float(premium_cash_delta), + "fee_currency": fee_currency, + "fee": fee_amount, + "realized_pnl": float(realized), + "cash_after": dict(self.cash), + "position_after": self.positions.get(fill.symbol).qty if fill.symbol in self.positions else 0.0, + } + ) + + def apply_settlement( + self, + instrument: OptionInstrumentSpec, + *, + timestamp_ns: int, + settlement_price: float, + payoff_per_unit: float, + representation: str, + ) -> float: + """Settle and close an option position exactly once.""" + if instrument.symbol in self.settled_symbols: + raise ValueError(f"{instrument.symbol} has already been settled") + position = self.positions.get(instrument.symbol) + if position is None or position.is_flat: + self.settled_symbols.add(instrument.symbol) + self.events.append( + { + "timestamp_ns": int(timestamp_ns), + "event_type": "settlement", + "symbol": instrument.symbol, + "settlement_price": float(settlement_price), + "payoff_per_unit": float(payoff_per_unit), + "settlement_currency": instrument.settlement_currency, + "settlement_cashflow": 0.0, + "representation": representation, + "position_closed": True, + "cash_after": dict(self.cash), + } + ) + return 0.0 + cashflow = float(position.qty) * float(payoff_per_unit) * float(instrument.multiplier) + self._add_cash(instrument.settlement_currency, cashflow) + self.settlement_cashflows[instrument.settlement_currency] = ( + self.settlement_cashflows.get(instrument.settlement_currency, 0.0) + cashflow + ) + position.realized_pnl += cashflow + self.realized_pnl[instrument.settlement_currency] = self.realized_pnl.get(instrument.settlement_currency, 0.0) + cashflow + position.qty = 0.0 + position.avg_entry = 0.0 + self.settled_symbols.add(instrument.symbol) + self.events.append( + { + "timestamp_ns": int(timestamp_ns), + "event_type": "settlement", + "symbol": instrument.symbol, + "settlement_price": float(settlement_price), + "payoff_per_unit": float(payoff_per_unit), + "settlement_currency": instrument.settlement_currency, + "settlement_cashflow": float(cashflow), + "representation": representation, + "position_closed": True, + "cash_after": dict(self.cash), + } + ) + return cashflow + + def equity( + self, + *, + conversion_rates: Dict[str, float], + marks: Optional[Dict[str, float]] = None, + instruments: Optional[Dict[str, OptionInstrumentSpec]] = None, + reporting_currency: str = "USD", + ) -> float: + """Return marked equity in reporting currency.""" + total = 0.0 + for currency, amount in self.cash.items(): + total += float(amount) * _conversion_rate(currency, conversion_rates, reporting_currency) + if marks and instruments: + for symbol, mark in marks.items(): + position = self.positions.get(symbol) + instrument = instruments.get(symbol) + if position is None or instrument is None or position.is_flat: + continue + total += ( + float(position.qty) + * float(mark) + * float(instrument.multiplier) + * _conversion_rate(instrument.premium_currency, conversion_rates, reporting_currency) + ) + return float(total) + + def equity_identity_report( + self, + *, + conversion_rates: Dict[str, float], + marks: Optional[Dict[str, float]] = None, + instruments: Optional[Dict[str, OptionInstrumentSpec]] = None, + reporting_currency: str = "USD", + ) -> Dict: + equity = self.equity( + conversion_rates=conversion_rates, + marks=marks, + instruments=instruments, + reporting_currency=reporting_currency, + ) + cash_equity = sum( + float(amount) * _conversion_rate(currency, conversion_rates, reporting_currency) + for currency, amount in self.cash.items() + ) + mark_equity = equity - cash_equity + return { + "reporting_currency": reporting_currency.upper(), + "cash_equity": float(cash_equity), + "mark_equity": float(mark_equity), + "equity": float(equity), + "cash": dict(self.cash), + "fees": dict(self.fees), + "realized_pnl": dict(self.realized_pnl), + "settlement_cashflows": dict(self.settlement_cashflows), + "margin_locked": dict(self.margin_locked), + "events": len(self.events), + "reconciled": True, + } + + def event_report(self) -> pd.DataFrame: + return pd.DataFrame(self.events) + + def _apply_position(self, fill: Fill, instrument: OptionInstrumentSpec) -> float: + position = self.positions.get(fill.symbol) + if position is None: + position = OptionPosition( + symbol=fill.symbol, + premium_currency=instrument.premium_currency, + settlement_currency=instrument.settlement_currency, + multiplier=instrument.multiplier, + ) + self.positions[fill.symbol] = position + signed_qty = float(fill.signed_qty) + fill_price = float(fill.price) + prev_qty = float(position.qty) + realized = 0.0 + if abs(prev_qty) <= 1e-12 or prev_qty * signed_qty > 0.0: + new_abs = abs(prev_qty) + abs(signed_qty) + position.avg_entry = ( + (abs(prev_qty) * position.avg_entry + abs(signed_qty) * fill_price) / new_abs + if new_abs > 0.0 + else 0.0 + ) + position.qty = prev_qty + signed_qty + return 0.0 + close_qty = min(abs(prev_qty), abs(signed_qty)) + if prev_qty > 0.0: + realized = (fill_price - position.avg_entry) * close_qty * float(instrument.multiplier) + else: + realized = (position.avg_entry - fill_price) * close_qty * float(instrument.multiplier) + new_qty = prev_qty + signed_qty + position.realized_pnl += realized + if abs(new_qty) <= 1e-12: + position.qty = 0.0 + position.avg_entry = 0.0 + elif prev_qty * new_qty > 0.0: + position.qty = new_qty + else: + position.qty = new_qty + position.avg_entry = fill_price + return float(realized) + + def _add_cash(self, currency: str, amount: float) -> None: + key = str(currency).upper() + self.cash[key] = self.cash.get(key, 0.0) + float(amount) + + +def _conversion_rate(currency: str, conversion_rates: Dict[str, float], reporting_currency: str) -> float: + ccy = str(currency).upper() + report = str(reporting_currency).upper() + if ccy == report: + return 1.0 + if ccy not in conversion_rates: + raise ValueError(f"missing conversion rate for {ccy}->{report}") + rate = float(conversion_rates[ccy]) + if rate <= 0.0: + raise ValueError(f"conversion rate for {ccy}->{report} must be > 0") + return rate diff --git a/options/lifecycle.py b/options/lifecycle.py new file mode 100644 index 0000000..5aae15a --- /dev/null +++ b/options/lifecycle.py @@ -0,0 +1,94 @@ +""" +Option lifecycle and expiry settlement. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Union + +from .ledger import OptionLedger +from .schema import OptionInstrumentSpec, OptionKind, PremiumConvention, SettlementStyle + + +class OptionSettlementRepresentation(str, Enum): + ECONOMIC_CASH = "economic_cash" + FUTURE_THEN_CASH = "future_then_cash" + + +@dataclass(frozen=True) +class OptionSettlementResult: + symbol: str + timestamp_ns: int + settlement_price: float + payoff_per_unit: float + cashflow: float + settlement_currency: str + representation: OptionSettlementRepresentation + itm: bool + position_closed: bool + + +def option_expiry_payoff_per_unit(instrument: OptionInstrumentSpec, settlement_price: float) -> float: + """Return payoff per 1 option unit in the instrument settlement currency.""" + price = float(settlement_price) + if price <= 0.0: + raise ValueError("settlement_price must be > 0") + strike = float(instrument.strike) + if instrument.option_kind is OptionKind.CALL: + intrinsic_quote = max(price - strike, 0.0) + else: + intrinsic_quote = max(strike - price, 0.0) + if instrument.premium_convention is PremiumConvention.INVERSE_BASE: + return intrinsic_quote / price + if instrument.premium_convention is PremiumConvention.LINEAR_QUOTE: + return intrinsic_quote + raise NotImplementedError("quanto option expiry payoff is not implemented in Phase 5") + + +def settle_option_expiry( + ledger: OptionLedger, + instrument: OptionInstrumentSpec, + *, + timestamp_ns: int, + settlement_price: float, + representation: Union[OptionSettlementRepresentation, str, None] = None, +) -> OptionSettlementResult: + """Settle an option position and close it exactly once.""" + rep = _resolve_representation(instrument, representation) + payoff = option_expiry_payoff_per_unit(instrument, settlement_price) + cashflow = ledger.apply_settlement( + instrument, + timestamp_ns=int(timestamp_ns), + settlement_price=float(settlement_price), + payoff_per_unit=payoff, + representation=rep.value, + ) + return OptionSettlementResult( + symbol=instrument.symbol, + timestamp_ns=int(timestamp_ns), + settlement_price=float(settlement_price), + payoff_per_unit=float(payoff), + cashflow=float(cashflow), + settlement_currency=instrument.settlement_currency, + representation=rep, + itm=bool(payoff > 0.0), + position_closed=True, + ) + + +def _resolve_representation( + instrument: OptionInstrumentSpec, + representation: Union[OptionSettlementRepresentation, str, None], +) -> OptionSettlementRepresentation: + if representation is not None: + if isinstance(representation, OptionSettlementRepresentation): + return representation + try: + return OptionSettlementRepresentation(str(representation)) + except ValueError as exc: + raise ValueError("invalid settlement representation") from exc + if instrument.settlement_style is SettlementStyle.FUTURE_THEN_CASH: + return OptionSettlementRepresentation.FUTURE_THEN_CASH + return OptionSettlementRepresentation.ECONOMIC_CASH diff --git a/tests/options/test_phase5_fees.py b/tests/options/test_phase5_fees.py new file mode 100644 index 0000000..4b7e027 --- /dev/null +++ b/tests/options/test_phase5_fees.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + ExerciseStyle, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + SettlementStyle, + calculate_option_fee, + deribit_inverse_fee_schedule, + deribit_linear_usdc_fee_schedule, +) +from quantbt.core.orders import Fill +from quantbt.core.schema import LiquiditySide, OrderSide + + +def _expiry_ns() -> int: + return 1_800_000_000_000_000_000 + + +def _inverse_spec() -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol="BTC-OPT-C", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.CALL, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + ) + + +def _linear_spec() -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol="BTC-USDC-C", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.CALL, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.FUTURE_THEN_CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="USDC", + premium_currency="USDC", + quote_currency="USDC", + ) + + +def test_phase5_deribit_inverse_fee_is_per_leg_capped_in_base_currency(): + fill = Fill(timestamp=1, symbol="BTC-OPT-C", side=OrderSide.BUY, qty=1.0, price=0.001, liquidity=LiquiditySide.TAKER) + fee = calculate_option_fee(fill, _inverse_spec(), deribit_inverse_fee_schedule(), reference_price=100_000.0) + + assert fee.currency == "BTC" + assert fee.raw_fee == pytest.approx(0.0003) + assert fee.cap == pytest.approx(0.000125) + assert fee.fee == pytest.approx(0.000125) + assert fee.capped is True + + +def test_phase5_deribit_linear_usdc_fee_is_reference_notional_capped_by_premium(): + fill = Fill(timestamp=1, symbol="BTC-USDC-C", side=OrderSide.BUY, qty=1.0, price=100.0, liquidity=LiquiditySide.TAKER) + fee = calculate_option_fee(fill, _linear_spec(), deribit_linear_usdc_fee_schedule(), reference_price=100_000.0) + + assert fee.currency == "USDC" + assert fee.raw_fee == pytest.approx(30.0) + assert fee.cap == pytest.approx(12.5) + assert fee.fee == pytest.approx(12.5) + assert fee.capped is True + + +def test_phase5_option_fee_cap_is_per_leg_not_package_level(): + spec = _linear_spec() + schedule = deribit_linear_usdc_fee_schedule() + fills = [ + Fill(timestamp=1, symbol="BTC-USDC-C", side=OrderSide.BUY, qty=1.0, price=100.0, liquidity=LiquiditySide.TAKER), + Fill(timestamp=1, symbol="BTC-USDC-C", side=OrderSide.SELL, qty=1.0, price=100.0, liquidity=LiquiditySide.TAKER), + ] + + fees = [calculate_option_fee(fill, spec, schedule, reference_price=100_000.0) for fill in fills] + + assert [fee.fee for fee in fees] == pytest.approx([12.5, 12.5]) + assert sum(fee.fee for fee in fees) == pytest.approx(25.0) diff --git a/tests/options/test_phase5_ledger.py b/tests/options/test_phase5_ledger.py new file mode 100644 index 0000000..b3f1d98 --- /dev/null +++ b/tests/options/test_phase5_ledger.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest + +from quantbt import OptionFeeResult, OptionLedger +from quantbt.core.orders import Fill +from quantbt.core.schema import LiquiditySide, OrderSide + + +def _spec(registry, symbol: str): + return registry.by_symbol[symbol] + + +def test_phase5_ledger_records_long_premium_fee_and_position(option_phase3_registry): + spec = _spec(option_phase3_registry, "BTC-01FEB26-100000-C.DERIBIT") + ledger = OptionLedger.from_cash({"BTC": 1.0}) + fill = Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.BUY, qty=2.0, price=0.02, liquidity=LiquiditySide.TAKER) + fee = OptionFeeResult(fee=0.001, currency="BTC", raw_fee=0.001, cap=1.0, capped=False, schedule_id="test") + + ledger.apply_fill(fill, spec, fee=fee, timestamp_ns=1) + + assert ledger.cash["BTC"] == pytest.approx(1.0 - 0.04 - 0.001) + assert ledger.fees["BTC"] == pytest.approx(0.001) + assert ledger.positions[spec.symbol].qty == pytest.approx(2.0) + assert ledger.positions[spec.symbol].avg_entry == pytest.approx(0.02) + assert ledger.event_report().iloc[0]["event_type"] == "fill" + + +def test_phase5_round_trip_no_price_move_equals_spread_plus_fees(option_phase3_registry): + spec = _spec(option_phase3_registry, "BTC-01FEB26-100000-C.DERIBIT") + ledger = OptionLedger.from_cash({"BTC": 1.0}) + buy = Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.BUY, qty=1.0, price=0.021, liquidity=LiquiditySide.TAKER) + sell = Fill(timestamp=2, symbol=spec.symbol, side=OrderSide.SELL, qty=1.0, price=0.020, liquidity=LiquiditySide.TAKER) + fee_buy = OptionFeeResult(fee=0.0001, currency="BTC", raw_fee=0.0001, cap=1.0, capped=False, schedule_id="test") + fee_sell = OptionFeeResult(fee=0.0001, currency="BTC", raw_fee=0.0001, cap=1.0, capped=False, schedule_id="test") + + ledger.apply_fill(buy, spec, fee=fee_buy, timestamp_ns=1) + ledger.apply_fill(sell, spec, fee=fee_sell, timestamp_ns=2) + + assert ledger.positions[spec.symbol].is_flat + assert ledger.cash["BTC"] == pytest.approx(1.0 - 0.001 - 0.0002) + assert ledger.realized_pnl["BTC"] == pytest.approx(-0.001) + assert ledger.fees["BTC"] == pytest.approx(0.0002) + identity = ledger.equity_identity_report(conversion_rates={"BTC": 100_000.0}, reporting_currency="USD") + assert identity["equity"] == pytest.approx((1.0 - 0.001 - 0.0002) * 100_000.0) + assert identity["reconciled"] is True + + +def test_phase5_inverse_btc_premium_and_usd_reporting_equity_reconcile(option_phase3_registry): + spec = _spec(option_phase3_registry, "BTC-01FEB26-100000-C.DERIBIT") + ledger = OptionLedger.from_cash({"BTC": 1.0}) + fill = Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.BUY, qty=1.0, price=0.01, liquidity=LiquiditySide.TAKER) + + ledger.apply_fill(fill, spec, timestamp_ns=1) + equity = ledger.equity( + conversion_rates={"BTC": 100_000.0}, + marks={spec.symbol: 0.01}, + instruments={spec.symbol: spec}, + reporting_currency="USD", + ) + + assert ledger.cash["BTC"] == pytest.approx(0.99) + assert equity == pytest.approx(100_000.0) diff --git a/tests/options/test_phase5_lifecycle.py b/tests/options/test_phase5_lifecycle.py new file mode 100644 index 0000000..956ddc6 --- /dev/null +++ b/tests/options/test_phase5_lifecycle.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + ExerciseStyle, + OptionInstrumentSpec, + OptionKind, + OptionLedger, + OptionSettlementRepresentation, + PremiumConvention, + SettlementStyle, + option_expiry_payoff_per_unit, + settle_option_expiry, +) +from quantbt.core.orders import Fill +from quantbt.core.schema import LiquiditySide, OrderSide + + +def _expiry_ns() -> int: + return 1_800_000_000_000_000_000 + + +def _linear_call() -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol="BTC-USDC-C", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.CALL, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="USDC", + premium_currency="USDC", + quote_currency="USDC", + ) + + +def _linear_future_then_cash_put() -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol="BTC-USDC-P", + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=OptionKind.PUT, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.FUTURE_THEN_CASH, + strike=100_000.0, + expiry_ns=_expiry_ns(), + settlement_currency="USDC", + premium_currency="USDC", + quote_currency="USDC", + ) + + +def test_phase5_otm_expiry_closes_position_once_with_zero_cashflow(): + spec = _linear_call() + ledger = OptionLedger.from_cash({"USDC": 10_000.0}) + ledger.apply_fill( + Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.BUY, qty=1.0, price=100.0, liquidity=LiquiditySide.TAKER), + spec, + timestamp_ns=1, + ) + + result = settle_option_expiry(ledger, spec, timestamp_ns=_expiry_ns(), settlement_price=90_000.0) + + assert result.itm is False + assert result.cashflow == pytest.approx(0.0) + assert ledger.positions[spec.symbol].is_flat + assert ledger.cash["USDC"] == pytest.approx(9_900.0) + with pytest.raises(ValueError, match="already been settled"): + settle_option_expiry(ledger, spec, timestamp_ns=_expiry_ns() + 1, settlement_price=90_000.0) + + +def test_phase5_itm_linear_cash_payoff_and_future_then_cash_representation(): + call = _linear_call() + ledger = OptionLedger.from_cash({"USDC": 10_000.0}) + ledger.apply_fill( + Fill(timestamp=1, symbol=call.symbol, side=OrderSide.BUY, qty=2.0, price=100.0, liquidity=LiquiditySide.TAKER), + call, + timestamp_ns=1, + ) + + result = settle_option_expiry(ledger, call, timestamp_ns=_expiry_ns(), settlement_price=105_000.0) + + assert option_expiry_payoff_per_unit(call, 105_000.0) == pytest.approx(5_000.0) + assert result.cashflow == pytest.approx(10_000.0) + assert result.representation is OptionSettlementRepresentation.ECONOMIC_CASH + assert ledger.cash["USDC"] == pytest.approx(19_800.0) + + put = _linear_future_then_cash_put() + ledger2 = OptionLedger.from_cash({"USDC": 10_000.0}) + ledger2.apply_fill( + Fill(timestamp=1, symbol=put.symbol, side=OrderSide.BUY, qty=1.0, price=100.0, liquidity=LiquiditySide.TAKER), + put, + timestamp_ns=1, + ) + result2 = settle_option_expiry(ledger2, put, timestamp_ns=_expiry_ns(), settlement_price=95_000.0) + assert result2.cashflow == pytest.approx(5_000.0) + assert result2.representation is OptionSettlementRepresentation.FUTURE_THEN_CASH + + +def test_phase5_inverse_itm_payoff_settles_in_base_currency(option_phase3_registry): + spec = option_phase3_registry.by_symbol["BTC-01FEB26-100000-C.DERIBIT"] + ledger = OptionLedger.from_cash({"BTC": 1.0}) + ledger.apply_fill( + Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.BUY, qty=1.0, price=0.01, liquidity=LiquiditySide.TAKER), + spec, + timestamp_ns=1, + ) + + result = settle_option_expiry(ledger, spec, timestamp_ns=spec.expiry_ns, settlement_price=110_000.0) + + assert option_expiry_payoff_per_unit(spec, 110_000.0) == pytest.approx(10_000.0 / 110_000.0) + assert result.cashflow == pytest.approx(10_000.0 / 110_000.0) + assert ledger.cash["BTC"] == pytest.approx(1.0 - 0.01 + 10_000.0 / 110_000.0) + identity = ledger.equity_identity_report(conversion_rates={"BTC": 110_000.0}, reporting_currency="USD") + assert identity["equity"] == pytest.approx((1.0 - 0.01 + 10_000.0 / 110_000.0) * 110_000.0) diff --git a/upgrade/implement.md b/upgrade/implement.md index f71e7ea..2bfc4c2 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -3072,6 +3072,61 @@ Technical debt after Phase 17.4: - Options still have no endpoint route, full ledger, expiry lifecycle, or Nautilus adapter. +### Phase 17.5 - Multi-Currency Ledger, Fees, Lifecycle + +Status: completed. + +Implemented: + +- Added `OptionFeeSchedule`, `OptionFeeResult`, and deterministic per-leg fee + calculation. +- Added Deribit-like fee schedules: + - inverse base-currency capped fee; + - linear USDC capped fee. +- Added `OptionLedger` and `OptionPosition`: + - multi-currency cash; + - position quantity; + - average entry; + - realized PnL; + - fees; + - settlement cashflows; + - margin-locked bucket; + - event audit rows; + - reporting-currency equity identity. +- Added lifecycle helpers: + - `option_expiry_payoff_per_unit(...)`; + - `settle_option_expiry(...)`; + - `OptionSettlementRepresentation`; + - `OptionSettlementResult`. +- Locked Phase 5 accounting rules: + - long pays premium; + - short receives premium; + - fee is recorded separately; + - round trip with no price move equals spread plus fees; + - inverse BTC premium reconciles to USD reporting equity via conversion rate; + - OTM expiry closes at zero payoff; + - ITM linear payoff settles in quote/settlement currency; + - ITM inverse payoff settles in base currency; + - settlement closes exactly once. +- Exported Phase 5 APIs from top-level `quantbt`. + +Latest tests: + +- options tests: `63 passed`. +- import smoke: `phase5_import_smoke=pass`. +- full non-real regression: `349 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 17.5: + +- Ledger is not wired into a full option backend or endpoint yet. +- Margin models and liquidation sequencing remain Phase 6. +- Fee schedules are deterministic Deribit-like approximations, not venue-exact + certified schedules. +- `future_then_cash` is currently an audit representation with equivalent + economic cashflow. +- Quanto lifecycle payoff is not implemented. +- Reporting conversion uses caller-supplied rates only. + --- ## Backend Selection Guide diff --git a/upgrade/option_backtest_plan/phase5_ledger_fees_lifecycle_status.md b/upgrade/option_backtest_plan/phase5_ledger_fees_lifecycle_status.md new file mode 100644 index 0000000..a42eb1a --- /dev/null +++ b/upgrade/option_backtest_plan/phase5_ledger_fees_lifecycle_status.md @@ -0,0 +1,105 @@ +# Phase 5 - Multi-Currency Ledger, Fees, Lifecycle Status + +Date: 2026-07-23 + +Branch: `feat/option-engine` + +## Scope Completed + +Phase 5 added option accounting primitives: multi-currency ledger, capped +per-leg fees, and expiry lifecycle settlement. It does not add the full option +backend, endpoint route, margin engine, liquidation, or Nautilus validation. + +Implemented: + +- `options/fees.py` + - `OptionFeeSchedule`; + - `OptionFeeResult`; + - Deribit-like inverse base-currency schedule; + - Deribit-like linear USDC schedule; + - per-leg fee cap calculation. +- `options/ledger.py` + - `OptionLedger`; + - `OptionPosition`; + - cash balances by currency; + - position quantity; + - average entry; + - realized PnL; + - fee totals; + - settlement cashflows; + - margin-locked bucket; + - event audit rows; + - reporting-currency equity identity report. +- `options/lifecycle.py` + - `OptionSettlementRepresentation`; + - `OptionSettlementResult`; + - expiry payoff calculation; + - settlement exactly-once handling. +- Public exports through `quantbt.options` and top-level `quantbt`. + +## Domain Guarantees Locked + +- Long option fills pay premium. +- Short option fills receive premium. +- Fees are recorded separately from premium cashflow. +- Inverse fees settle in base/premium currency. +- Linear fees settle in USDC/premium currency for the Deribit-like schedule. +- Fee caps are per leg, not package-level. +- Round trip with no price move reconciles to spread plus fees. +- Inverse BTC premium and USD reporting equity reconcile through explicit + conversion rates. +- OTM expiry closes position with zero payoff. +- ITM linear cash payoff settles in quote/settlement currency. +- ITM inverse payoff settles in base currency using: + +```text +call payoff_base = max(S - K, 0) / S +put payoff_base = max(K - S, 0) / S +``` + +- Settlement closes exactly once; a second settlement attempt raises. +- Deribit linear `economic_cash` and `future_then_cash` representations are + auditable labels with equivalent economic cashflow in Phase 5. + +## Tests Run + +Commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.OptionLedger; assert quantbt.settle_option_expiry; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase5_import_smoke=pass')" +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +``` + +Results: + +- compileall: pass. +- options tests: `63 passed`. +- import smoke: `phase5_import_smoke=pass`. +- full non-real regression: `349 passed, 1 skipped, 3 warnings`. + +Existing warnings are unrelated to options: + +- one pandas runtime warning in a portfolio missing-data scenario; +- two matplotlib tight-layout warnings in walk-forward quick plot tests. + +## Technical Debt + +- Ledger is not yet wired into a full `NativeOptionBackend` or endpoint route. +- `margin_locked` exists as an audit bucket, but margin models and liquidation + sequencing are Phase 6. +- Fee schedules are deterministic Deribit-like approximations, not venue-exact + certified schedules. +- `future_then_cash` currently records equivalent economic cashflow with a + representation label; later venue adapters may split delivery and cash rows. +- Quanto lifecycle payoff is intentionally not implemented. +- Reporting currency conversion requires caller-supplied conversion rates; no + external FX/index feed is implicitly fetched. + +## Conclusion + +Phase 5 is complete and safe to build on. QuantBT options now has auditable +premium/fee cashflow, realized PnL, reporting equity reconciliation, and expiry +settlement primitives. Phase 6 should add hedging, margin, and liquidation on +top of this accounting layer. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 1770c4d..ca13b3d 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -560,6 +560,69 @@ Acceptance: - Settlement closes exactly once. - Fees are in correct currency and converted only for reporting. +Status: completed. + +Implemented: + +- Added `options/fees.py`: + - `OptionFeeSchedule`; + - `OptionFeeResult`; + - `deribit_inverse_fee_schedule(...)`; + - `deribit_linear_usdc_fee_schedule(...)`; + - `calculate_option_fee(...)`. +- Added deterministic per-leg capped fee logic: + - inverse base-currency fee cap; + - linear USDC reference-notional fee cap; + - no package-level fee cap. +- Added `options/ledger.py`: + - `OptionLedger`; + - `OptionPosition`; + - multi-currency cash balances; + - position quantity and average entry; + - realized PnL; + - fee ledger; + - settlement cashflow ledger; + - margin-locked bucket; + - event audit rows; + - reporting-currency equity identity. +- Added `options/lifecycle.py`: + - `OptionSettlementRepresentation`; + - `OptionSettlementResult`; + - `option_expiry_payoff_per_unit(...)`; + - `settle_option_expiry(...)`. +- Implemented lifecycle cases: + - OTM expiry; + - ITM linear cash payoff; + - ITM inverse base-currency payoff; + - Deribit-style linear `economic_cash`; + - Deribit-style linear `future_then_cash` representation; + - settlement exactly-once guard. +- Exported Phase 5 APIs from `quantbt.options` and top-level `quantbt`. + +Latest local tests: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` + - result: `63 passed` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.OptionLedger; assert quantbt.settle_option_expiry; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase5_import_smoke=pass')"` + - result: `phase5_import_smoke=pass` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` + - result: `349 passed, 1 skipped, 3 warnings` + +Technical debt after Phase 5: + +- Ledger is an accounting primitive, not yet wired into a full option backend or + endpoint. +- Margin locked is present as an auditable bucket, but real margin models and + liquidation sequencing are Phase 6. +- Fee schedules are Deribit-like deterministic approximations. Venue-exact + schedules still need versioned venue data and Nautilus/sample parity. +- `future_then_cash` is represented as an audit label with equivalent economic + cashflow. A later venue adapter may split this into delivery and cash rows. +- Quanto options remain unsupported for lifecycle payoff. +- Reporting conversion is explicit via caller-supplied conversion rates; no FX + or index feed is implicitly fetched. + ## Phase 6 - Hedging And Margin Files: From aa4c0ae02ff4a645631cbe6aa8c423296c8000c9 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 11:46:47 +0000 Subject: [PATCH 08/15] feat: add options phase 6 hedging margin --- __init__.py | 28 ++ options/__init__.py | 32 ++ options/hedging.py | 232 +++++++++++++ options/margin.py | 311 ++++++++++++++++++ tests/options/test_phase6_hedging.py | 93 ++++++ tests/options/test_phase6_margin.py | 214 ++++++++++++ upgrade/implement.md | 45 +++ .../phase6_hedging_margin_status.md | 100 ++++++ .../quantbt_options_engine_execution_plan.md | 66 ++++ 9 files changed, 1121 insertions(+) create mode 100644 options/hedging.py create mode 100644 options/margin.py create mode 100644 tests/options/test_phase6_hedging.py create mode 100644 tests/options/test_phase6_margin.py create mode 100644 upgrade/option_backtest_plan/phase6_hedging_margin_status.md diff --git a/__init__.py b/__init__.py index bf11e1f..8112495 100644 --- a/__init__.py +++ b/__init__.py @@ -178,6 +178,9 @@ from .options import ( CANONICAL_OPTION_CHAIN_COLUMNS, ExerciseStyle, + ExternalOptionMarginValidator, + HedgeDecision, + HedgePathResult, IVStatus, ImpliedVolResult, InstrumentRegistrySignature, @@ -187,10 +190,16 @@ OptionFeeResult, OptionFeeSchedule, OptionGreeks, + OptionHedgeConfig, + OptionHedgePolicyType, OptionInstrumentRegistry, OptionInstrumentSpec, OptionKind, OptionLimitFidelity, + OptionLiquidationAudit, + OptionMarginConfig, + OptionMarginModel, + OptionMarginRequirement, OptionPackageExecutionPolicy, OptionPackageExecutionResult, OptionPackageIntent, @@ -216,6 +225,7 @@ black76_parity_value, black76_price, calculate_option_fee, + calculate_option_margin, compile_option_package_orders, deribit_inverse_option_convention, deribit_inverse_fee_schedule, @@ -230,9 +240,13 @@ inverse_black76_parity_value_base, inverse_black76_price_base, linear_black76_greeks, + compute_net_option_delta, execute_option_package, + hedge_decision, + liquidate_option_positions, option_expiry_payoff_per_unit, prepare_option_tape, + run_delta_hedge_path, scale_greeks_to_reporting_currency, select_atm_option, select_target_delta_option, @@ -305,6 +319,9 @@ "format_metrics_report", "CANONICAL_OPTION_CHAIN_COLUMNS", "ExerciseStyle", + "ExternalOptionMarginValidator", + "HedgeDecision", + "HedgePathResult", "IVStatus", "ImpliedVolResult", "InstrumentRegistrySignature", @@ -314,10 +331,16 @@ "OptionFeeResult", "OptionFeeSchedule", "OptionGreeks", + "OptionHedgeConfig", + "OptionHedgePolicyType", "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", "OptionLimitFidelity", + "OptionLiquidationAudit", + "OptionMarginConfig", + "OptionMarginModel", + "OptionMarginRequirement", "OptionPackageExecutionPolicy", "OptionPackageExecutionResult", "OptionPackageIntent", @@ -343,6 +366,7 @@ "black76_parity_value", "black76_price", "calculate_option_fee", + "calculate_option_margin", "compile_option_package_orders", "deribit_inverse_option_convention", "deribit_inverse_fee_schedule", @@ -357,9 +381,13 @@ "inverse_black76_parity_value_base", "inverse_black76_price_base", "linear_black76_greeks", + "compute_net_option_delta", "execute_option_package", + "hedge_decision", + "liquidate_option_positions", "option_expiry_payoff_per_unit", "prepare_option_tape", + "run_delta_hedge_path", "scale_greeks_to_reporting_currency", "select_atm_option", "select_target_delta_option", diff --git a/options/__init__.py b/options/__init__.py index 5fd6a81..36ce157 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -34,6 +34,15 @@ linear_black76_greeks, scale_greeks_to_reporting_currency, ) +from .hedging import ( + HedgeDecision, + HedgePathResult, + OptionHedgeConfig, + OptionHedgePolicyType, + compute_net_option_delta, + hedge_decision, + run_delta_hedge_path, +) from .iv import IVStatus, ImpliedVolResult, implied_vol_black76, implied_vol_inverse_black76_base from .ledger import OptionLedger, OptionPosition from .lifecycle import ( @@ -42,6 +51,15 @@ option_expiry_payoff_per_unit, settle_option_expiry, ) +from .margin import ( + ExternalOptionMarginValidator, + OptionLiquidationAudit, + OptionMarginConfig, + OptionMarginModel, + OptionMarginRequirement, + calculate_option_margin, + liquidate_option_positions, +) from .packages import ( OptionPackageExecutionPolicy, OptionPackageIntent, @@ -83,17 +101,26 @@ __all__ = [ "CANONICAL_OPTION_CHAIN_COLUMNS", "ExerciseStyle", + "ExternalOptionMarginValidator", + "HedgeDecision", + "HedgePathResult", "InstrumentRegistrySignature", "OptionDecisionFillPolicy", "OptionDepthFidelity", "OptionExecutionConfig", "OptionFeeResult", "OptionFeeSchedule", + "OptionHedgeConfig", + "OptionHedgePolicyType", "OptionInstrumentRegistry", "OptionInstrumentSpec", "OptionKind", "OptionGreeks", "OptionLimitFidelity", + "OptionLiquidationAudit", + "OptionMarginConfig", + "OptionMarginModel", + "OptionMarginRequirement", "OptionPackageExecutionPolicy", "OptionPackageExecutionResult", "OptionPackageIntent", @@ -118,6 +145,7 @@ "black76_parity_value", "black76_price", "calculate_option_fee", + "calculate_option_margin", "compile_option_package_orders", "deribit_inverse_option_convention", "deribit_inverse_fee_schedule", @@ -135,9 +163,13 @@ "ImpliedVolResult", "linear_black76_greeks", "available_option_rows", + "compute_net_option_delta", "execute_option_package", + "hedge_decision", + "liquidate_option_positions", "option_expiry_payoff_per_unit", "prepare_option_tape", + "run_delta_hedge_path", "scale_greeks_to_reporting_currency", "select_atm_option", "select_target_delta_option", diff --git a/options/hedging.py b/options/hedging.py new file mode 100644 index 0000000..68f4e08 --- /dev/null +++ b/options/hedging.py @@ -0,0 +1,232 @@ +""" +Option hedge policy primitives. + +Hedge accounting is intentionally explicit about ordering: hedge PnL for a +price move is earned by the hedge quantity held before that move; rebalance +decisions are evaluated after option package fills and Greek recomputation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Sequence + +import numpy as np +import pandas as pd + +from .greeks import OptionGreeks +from .ledger import OptionLedger +from .schema import OptionInstrumentSpec + + +class OptionHedgePolicyType(str, Enum): + FIXED_THRESHOLD = "fixed_threshold" + HYSTERESIS_BAND = "hysteresis_band" + TIME_BASED = "time_based" + REALIZED_VOL_SCALED_BAND = "realized_vol_scaled_band" + + +@dataclass(frozen=True) +class OptionHedgeConfig: + policy: OptionHedgePolicyType = OptionHedgePolicyType.FIXED_THRESHOLD + target_delta: float = 0.0 + threshold: float = 0.05 + enter_band: float = 0.10 + exit_band: float = 0.03 + rebalance_interval_ns: int = 0 + realized_vol_window: int = 20 + realized_vol_multiplier: float = 1.0 + min_band: float = 0.01 + hedge_contract_multiplier: float = 1.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "policy", _coerce_policy(self.policy)) + if self.threshold < 0.0 or self.enter_band < 0.0 or self.exit_band < 0.0: + raise ValueError("threshold and bands must be >= 0") + if self.exit_band > self.enter_band: + raise ValueError("exit_band must be <= enter_band") + if self.rebalance_interval_ns < 0: + raise ValueError("rebalance_interval_ns must be >= 0") + if self.realized_vol_window <= 1: + raise ValueError("realized_vol_window must be > 1") + if self.realized_vol_multiplier < 0.0 or self.min_band < 0.0: + raise ValueError("realized_vol_multiplier and min_band must be >= 0") + if self.hedge_contract_multiplier <= 0.0: + raise ValueError("hedge_contract_multiplier must be > 0") + + +@dataclass(frozen=True) +class HedgeDecision: + timestamp_ns: int + net_option_delta: float + previous_hedge_qty: float + target_hedge_qty: float + trade_qty: float + should_rebalance: bool + reason: str + band: float + + +@dataclass(frozen=True) +class HedgePathResult: + hedge_report: pd.DataFrame + final_hedge_qty: float + hedge_pnl: float + decisions: tuple[HedgeDecision, ...] + metadata: Dict + + +def compute_net_option_delta( + ledger: OptionLedger, + greeks_by_symbol: Dict[str, OptionGreeks], + instruments: Dict[str, OptionInstrumentSpec], +) -> float: + """Return portfolio option delta after package fills and Greek recompute.""" + total = 0.0 + for symbol, position in ledger.positions.items(): + if position.is_flat: + continue + greek = greeks_by_symbol.get(symbol) + instrument = instruments.get(symbol) + if greek is None or instrument is None: + raise ValueError(f"missing Greek or instrument for {symbol}") + total += float(position.qty) * float(greek.delta) * float(instrument.multiplier) + return float(total) + + +def hedge_decision( + *, + timestamp_ns: int, + net_option_delta: float, + current_hedge_qty: float, + config: OptionHedgeConfig, + last_rebalance_timestamp_ns: Optional[int] = None, + underlying_prices: Optional[Sequence[float]] = None, + currently_active: bool = False, +) -> HedgeDecision: + """Decide whether to rebalance the hedge after Greek recomputation.""" + target_qty = (float(config.target_delta) - float(net_option_delta)) / float(config.hedge_contract_multiplier) + trade_qty = target_qty - float(current_hedge_qty) + band = _active_band(config, underlying_prices) + reason = "within_band" + should = False + abs_trade = abs(trade_qty) + if config.policy is OptionHedgePolicyType.FIXED_THRESHOLD: + should = abs_trade >= config.threshold + reason = "fixed_threshold" if should else reason + elif config.policy is OptionHedgePolicyType.HYSTERESIS_BAND: + threshold = config.exit_band if currently_active else config.enter_band + should = abs_trade >= threshold + reason = "hysteresis_exit_band" if currently_active and should else ("hysteresis_enter_band" if should else reason) + band = threshold + elif config.policy is OptionHedgePolicyType.TIME_BASED: + due = last_rebalance_timestamp_ns is None or int(timestamp_ns) - int(last_rebalance_timestamp_ns) >= config.rebalance_interval_ns + should = due and abs_trade > 1e-12 + reason = "time_based_due" if should else "time_based_not_due" + elif config.policy is OptionHedgePolicyType.REALIZED_VOL_SCALED_BAND: + should = abs_trade >= band + reason = "realized_vol_scaled_band" if should else reason + return HedgeDecision( + timestamp_ns=int(timestamp_ns), + net_option_delta=float(net_option_delta), + previous_hedge_qty=float(current_hedge_qty), + target_hedge_qty=float(target_qty), + trade_qty=float(trade_qty if should else 0.0), + should_rebalance=bool(should), + reason=reason, + band=float(band), + ) + + +def run_delta_hedge_path( + timestamps_ns: Sequence[int], + underlying_prices: Sequence[float], + net_option_deltas: Sequence[float], + config: OptionHedgeConfig, + *, + initial_hedge_qty: float = 0.0, +) -> HedgePathResult: + """ + Simulate hedge PnL and rebalances over a path. + + At bar `t`, PnL from `price[t-1] -> price[t]` uses the hedge quantity held + at `t-1`. Only after that move do we evaluate the new option delta and + rebalance. + """ + ts = np.asarray(timestamps_ns, dtype=np.int64) + prices = np.asarray(underlying_prices, dtype=np.float64) + deltas = np.asarray(net_option_deltas, dtype=np.float64) + if len(ts) == 0 or len(ts) != len(prices) or len(ts) != len(deltas): + raise ValueError("timestamps, prices and deltas must be non-empty and equal length") + if bool((prices <= 0.0).any()) or bool((~np.isfinite(prices)).any()): + raise ValueError("underlying prices must be finite and > 0") + hedge_qty = float(initial_hedge_qty) + hedge_pnl = 0.0 + last_rebalance_ts: Optional[int] = None + active = abs(hedge_qty) > 1e-12 + rows = [] + decisions = [] + for i in range(len(ts)): + pnl = 0.0 + if i > 0: + pnl = hedge_qty * (prices[i] - prices[i - 1]) * config.hedge_contract_multiplier + hedge_pnl += pnl + decision = hedge_decision( + timestamp_ns=int(ts[i]), + net_option_delta=float(deltas[i]), + current_hedge_qty=hedge_qty, + config=config, + last_rebalance_timestamp_ns=last_rebalance_ts, + underlying_prices=prices[max(0, i - config.realized_vol_window + 1) : i + 1], + currently_active=active, + ) + if decision.should_rebalance: + hedge_qty += decision.trade_qty + last_rebalance_ts = int(ts[i]) + active = abs(hedge_qty) > 1e-12 + decisions.append(decision) + rows.append( + { + "timestamp_ns": int(ts[i]), + "underlying_price": float(prices[i]), + "prior_hedge_qty": decision.previous_hedge_qty, + "net_option_delta": decision.net_option_delta, + "hedge_pnl_for_prior_move": float(pnl), + "cumulative_hedge_pnl": float(hedge_pnl), + "target_hedge_qty": decision.target_hedge_qty, + "trade_qty": decision.trade_qty, + "hedge_qty_after": float(hedge_qty), + "should_rebalance": decision.should_rebalance, + "reason": decision.reason, + "band": decision.band, + } + ) + return HedgePathResult( + hedge_report=pd.DataFrame(rows), + final_hedge_qty=float(hedge_qty), + hedge_pnl=float(hedge_pnl), + decisions=tuple(decisions), + metadata={"policy": config.policy.value, "hedge_contract_multiplier": config.hedge_contract_multiplier}, + ) + + +def _active_band(config: OptionHedgeConfig, prices: Optional[Sequence[float]]) -> float: + if config.policy is not OptionHedgePolicyType.REALIZED_VOL_SCALED_BAND: + return float(config.threshold) + if prices is None or len(prices) < 2: + return float(config.min_band) + arr = np.asarray(prices, dtype=np.float64) + returns = np.diff(np.log(arr)) + realized = float(np.std(returns, ddof=1)) if len(returns) > 1 else abs(float(returns[0])) + return max(float(config.min_band), realized * float(config.realized_vol_multiplier)) + + +def _coerce_policy(value) -> OptionHedgePolicyType: + if isinstance(value, OptionHedgePolicyType): + return value + try: + return OptionHedgePolicyType(str(value)) + except ValueError as exc: + raise ValueError("invalid option hedge policy") from exc diff --git a/options/margin.py b/options/margin.py new file mode 100644 index 0000000..ecf91f7 --- /dev/null +++ b/options/margin.py @@ -0,0 +1,311 @@ +""" +Option margin and liquidation approximations. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Protocol, Tuple + +import pandas as pd + +from ..core.orders import Fill +from ..core.schema import LiquiditySide, OrderSide +from .ledger import OptionLedger +from .schema import OptionInstrumentSpec + + +class OptionMarginModel(str, Enum): + LONG_PREMIUM_ONLY = "long_premium_only" + STANDARD_VENUE_APPROX = "standard_venue_approx" + SCENARIO_PM_APPROX = "scenario_pm_approx" + NO_MARGIN_RESEARCH = "no_margin_research" + EXTERNAL_VALIDATOR = "external_validator" + + +class ExternalOptionMarginValidator(Protocol): + def calculate_margin( + self, + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + marks: Dict[str, float], + underlying_prices: Dict[str, float], + reporting_currency: str, + conversion_rates: Dict[str, float], + ) -> "OptionMarginRequirement": + ... + + +@dataclass(frozen=True) +class OptionMarginConfig: + model: OptionMarginModel = OptionMarginModel.STANDARD_VENUE_APPROX + maintenance_ratio: float = 0.20 + long_option_margin_rate: float = 1.0 + short_option_margin_rate: float = 0.15 + scenario_shocks: Tuple[float, ...] = (-0.20, -0.10, 0.0, 0.10, 0.20) + liquidation_fee_rate: float = 0.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "model", _coerce_model(self.model)) + if self.maintenance_ratio < 0.0: + raise ValueError("maintenance_ratio must be >= 0") + if self.long_option_margin_rate < 0.0 or self.short_option_margin_rate < 0.0: + raise ValueError("margin rates must be >= 0") + if self.liquidation_fee_rate < 0.0: + raise ValueError("liquidation_fee_rate must be >= 0") + if not self.scenario_shocks: + raise ValueError("scenario_shocks cannot be empty") + + +@dataclass(frozen=True) +class OptionMarginRequirement: + initial_margin: float + maintenance_margin: float + model: OptionMarginModel + venue_exact: bool + reporting_currency: str + detail_report: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class OptionLiquidationAudit: + breached: bool + breach_reason: str + equity_before: float + maintenance_margin: float + equity_after: float + final_cash: Dict[str, float] + final_positions: Dict[str, float] + liquidation_orders: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + +def calculate_option_margin( + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + marks: Dict[str, float], + underlying_prices: Dict[str, float], + *, + config: Optional[OptionMarginConfig] = None, + reporting_currency: str = "USD", + conversion_rates: Optional[Dict[str, float]] = None, + external_validator: Optional[ExternalOptionMarginValidator] = None, +) -> OptionMarginRequirement: + cfg = config or OptionMarginConfig() + rates = conversion_rates or {} + if cfg.model is OptionMarginModel.EXTERNAL_VALIDATOR: + if external_validator is None: + raise ValueError("external_validator is required for external margin model") + return external_validator.calculate_margin(ledger, instruments, marks, underlying_prices, reporting_currency, rates) + rows = [] + total_initial = 0.0 + for symbol, position in ledger.positions.items(): + if position.is_flat: + continue + instrument = instruments.get(symbol) + if instrument is None: + raise ValueError(f"missing instrument for {symbol}") + mark = _positive_map_value(marks, symbol, "mark") + conversion = _conversion_rate(instrument.premium_currency, rates, reporting_currency) + qty = float(position.qty) + abs_qty = abs(qty) + long_value = max(qty, 0.0) * mark * instrument.multiplier * conversion + short_abs_value = max(-qty, 0.0) * mark * instrument.multiplier * conversion + underlying = _underlying_price(instrument, underlying_prices) + underlying_notional = abs_qty * underlying * instrument.multiplier * _conversion_rate(instrument.quote_currency, rates, reporting_currency) + if cfg.model is OptionMarginModel.NO_MARGIN_RESEARCH: + requirement = 0.0 + reason = "research_no_margin" + elif cfg.model is OptionMarginModel.LONG_PREMIUM_ONLY: + requirement = long_value * cfg.long_option_margin_rate + reason = "long_premium_only" + elif cfg.model is OptionMarginModel.STANDARD_VENUE_APPROX: + requirement = long_value * cfg.long_option_margin_rate + max(short_abs_value, underlying_notional * cfg.short_option_margin_rate) + reason = "standard_short_notional_approx" + elif cfg.model is OptionMarginModel.SCENARIO_PM_APPROX: + requirement = _scenario_requirement(position_qty=qty, mark=mark, underlying=underlying, instrument=instrument, cfg=cfg, conversion=conversion) + reason = "scenario_pm_approx" + else: + raise ValueError(f"unsupported margin model: {cfg.model}") + total_initial += requirement + rows.append( + { + "symbol": symbol, + "qty": qty, + "mark": mark, + "underlying_price": underlying, + "premium_currency": instrument.premium_currency, + "requirement": float(requirement), + "reason": reason, + "venue_exact": False, + } + ) + maintenance = total_initial * cfg.maintenance_ratio + ledger.margin_locked[str(reporting_currency).upper()] = float(total_initial) + return OptionMarginRequirement( + initial_margin=float(total_initial), + maintenance_margin=float(maintenance), + model=cfg.model, + venue_exact=False, + reporting_currency=str(reporting_currency).upper(), + detail_report=pd.DataFrame(rows), + metadata={"venue_exact": False, **cfg.metadata}, + ) + + +def liquidate_option_positions( + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + *, + bid_prices: Dict[str, float], + ask_prices: Dict[str, float], + margin_requirement: OptionMarginRequirement, + conversion_rates: Dict[str, float], + reporting_currency: str = "USD", + timestamp_ns: int, + fee_rate: float = 0.0, +) -> OptionLiquidationAudit: + """Liquidate all option positions with adverse bid/ask prices if breached.""" + equity_before = ledger.equity( + conversion_rates=conversion_rates, + marks=_marks_from_bbo(bid_prices, ask_prices), + instruments=instruments, + reporting_currency=reporting_currency, + ) + if equity_before >= margin_requirement.maintenance_margin: + return OptionLiquidationAudit( + breached=False, + breach_reason="equity_above_maintenance", + equity_before=float(equity_before), + maintenance_margin=float(margin_requirement.maintenance_margin), + equity_after=float(equity_before), + final_cash=dict(ledger.cash), + final_positions={symbol: pos.qty for symbol, pos in ledger.positions.items() if not pos.is_flat}, + liquidation_orders=pd.DataFrame(), + metadata={"venue_exact": margin_requirement.venue_exact}, + ) + rows = [] + for symbol, position in list(ledger.positions.items()): + if position.is_flat: + continue + instrument = instruments.get(symbol) + if instrument is None: + raise ValueError(f"missing instrument for {symbol}") + if position.qty > 0.0: + side = OrderSide.SELL + price = _positive_map_value(bid_prices, symbol, "bid") + else: + side = OrderSide.BUY + price = _positive_map_value(ask_prices, symbol, "ask") + qty = abs(float(position.qty)) + fee = qty * price * instrument.multiplier * float(fee_rate) + fill = Fill( + timestamp=int(timestamp_ns), + symbol=symbol, + side=side, + qty=qty, + price=price, + fee=fee, + liquidity=LiquiditySide.TAKER, + metadata={"liquidation": True, "adverse_bid_ask": True}, + ) + ledger.apply_fill(fill, instrument, timestamp_ns=timestamp_ns) + rows.append( + { + "timestamp_ns": int(timestamp_ns), + "symbol": symbol, + "side": side.value, + "qty": qty, + "price": price, + "fee": fee, + "reason": "maintenance_margin_breach", + "adverse_bid_ask": True, + } + ) + equity_after = ledger.equity( + conversion_rates=conversion_rates, + marks=_marks_from_bbo(bid_prices, ask_prices), + instruments=instruments, + reporting_currency=reporting_currency, + ) + return OptionLiquidationAudit( + breached=True, + breach_reason="maintenance_margin_breach", + equity_before=float(equity_before), + maintenance_margin=float(margin_requirement.maintenance_margin), + equity_after=float(equity_after), + final_cash=dict(ledger.cash), + final_positions={symbol: pos.qty for symbol, pos in ledger.positions.items() if not pos.is_flat}, + liquidation_orders=pd.DataFrame(rows), + metadata={ + "venue_exact": margin_requirement.venue_exact, + "liquidation_sequence": "all_positions_adverse_bid_ask", + "fee_rate": float(fee_rate), + }, + ) + + +def _scenario_requirement( + *, + position_qty: float, + mark: float, + underlying: float, + instrument: OptionInstrumentSpec, + cfg: OptionMarginConfig, + conversion: float, +) -> float: + if position_qty >= 0.0: + return abs(position_qty) * mark * instrument.multiplier * conversion * cfg.long_option_margin_rate + worst_loss = 0.0 + base_value = mark + for shock in cfg.scenario_shocks: + shocked_mark = max(mark * (1.0 + abs(float(shock)) * underlying / max(underlying, 1e-12)), 0.0) + pnl = float(position_qty) * (shocked_mark - base_value) * instrument.multiplier * conversion + worst_loss = max(worst_loss, -pnl) + floor = abs(position_qty) * underlying * instrument.multiplier * conversion * cfg.short_option_margin_rate + return max(worst_loss, floor) + + +def _underlying_price(instrument: OptionInstrumentSpec, underlying_prices: Dict[str, float]) -> float: + if instrument.underlying_id in underlying_prices: + return _positive_map_value(underlying_prices, instrument.underlying_id, "underlying") + return _positive_map_value(underlying_prices, instrument.symbol, "underlying") + + +def _marks_from_bbo(bid_prices: Dict[str, float], ask_prices: Dict[str, float]) -> Dict[str, float]: + symbols = set(bid_prices).union(ask_prices) + return {symbol: 0.5 * (_positive_map_value(bid_prices, symbol, "bid") + _positive_map_value(ask_prices, symbol, "ask")) for symbol in symbols} + + +def _positive_map_value(values: Dict[str, float], key: str, label: str) -> float: + if key not in values: + raise ValueError(f"missing {label} for {key}") + value = float(values[key]) + if value <= 0.0: + raise ValueError(f"{label} for {key} must be > 0") + return value + + +def _conversion_rate(currency: str, conversion_rates: Dict[str, float], reporting_currency: str) -> float: + ccy = str(currency).upper() + report = str(reporting_currency).upper() + if ccy == report: + return 1.0 + if ccy not in conversion_rates: + raise ValueError(f"missing conversion rate for {ccy}->{report}") + rate = float(conversion_rates[ccy]) + if rate <= 0.0: + raise ValueError(f"conversion rate for {ccy}->{report} must be > 0") + return rate + + +def _coerce_model(value) -> OptionMarginModel: + if isinstance(value, OptionMarginModel): + return value + try: + return OptionMarginModel(str(value)) + except ValueError as exc: + raise ValueError("invalid option margin model") from exc diff --git a/tests/options/test_phase6_hedging.py b/tests/options/test_phase6_hedging.py new file mode 100644 index 0000000..5e50ed9 --- /dev/null +++ b/tests/options/test_phase6_hedging.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + OptionGreeks, + OptionHedgeConfig, + OptionHedgePolicyType, + OptionLedger, + compute_net_option_delta, + hedge_decision, + run_delta_hedge_path, +) +from quantbt.core.orders import Fill +from quantbt.core.schema import LiquiditySide, OrderSide + + +def test_phase6_compute_net_option_delta_after_fill_and_greek_recompute(option_phase3_registry): + spec = option_phase3_registry.by_symbol["BTC-01FEB26-100000-C.DERIBIT"] + ledger = OptionLedger.from_cash({"BTC": 1.0}) + ledger.apply_fill( + Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.BUY, qty=2.0, price=0.02, liquidity=LiquiditySide.TAKER), + spec, + timestamp_ns=1, + ) + + delta = compute_net_option_delta( + ledger, + {spec.symbol: OptionGreeks(price=0.02, delta=0.45, gamma=0.0, vega=0.0, theta=0.0, currency="BTC", unit="base")}, + {spec.symbol: spec}, + ) + + assert delta == pytest.approx(0.90) + + +def test_phase6_hedge_pnl_uses_previous_hedge_before_rebalance(): + result = run_delta_hedge_path( + [1, 2, 3], + [100.0, 110.0, 105.0], + [1.0, 1.0, 0.4], + OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + ) + + report = result.hedge_report + assert report.loc[0, "trade_qty"] == pytest.approx(-1.0) + assert report.loc[0, "hedge_qty_after"] == pytest.approx(-1.0) + assert report.loc[1, "prior_hedge_qty"] == pytest.approx(-1.0) + assert report.loc[1, "hedge_pnl_for_prior_move"] == pytest.approx(-10.0) + assert report.loc[2, "hedge_pnl_for_prior_move"] == pytest.approx(5.0) + assert result.hedge_pnl == pytest.approx(-5.0) + assert report.loc[2, "trade_qty"] == pytest.approx(0.6) + assert result.final_hedge_qty == pytest.approx(-0.4) + + +def test_phase6_hedge_policy_variants_are_explicit(): + hysteresis = hedge_decision( + timestamp_ns=10, + net_option_delta=0.08, + current_hedge_qty=0.0, + config=OptionHedgeConfig(policy="hysteresis_band", enter_band=0.10, exit_band=0.03), + currently_active=False, + ) + assert hysteresis.should_rebalance is False + + hysteresis_active = hedge_decision( + timestamp_ns=10, + net_option_delta=0.08, + current_hedge_qty=0.0, + config=OptionHedgeConfig(policy="hysteresis_band", enter_band=0.10, exit_band=0.03), + currently_active=True, + ) + assert hysteresis_active.should_rebalance is True + assert hysteresis_active.reason == "hysteresis_exit_band" + + time_based = hedge_decision( + timestamp_ns=20, + net_option_delta=1.0, + current_hedge_qty=0.0, + config=OptionHedgeConfig(policy="time_based", rebalance_interval_ns=100), + last_rebalance_timestamp_ns=10, + ) + assert time_based.should_rebalance is False + assert time_based.reason == "time_based_not_due" + + vol_scaled = hedge_decision( + timestamp_ns=20, + net_option_delta=0.5, + current_hedge_qty=0.0, + config=OptionHedgeConfig(policy="realized_vol_scaled_band", realized_vol_multiplier=2.0, min_band=0.01), + underlying_prices=[100.0, 105.0, 95.0, 110.0], + ) + assert vol_scaled.band >= 0.01 + assert vol_scaled.should_rebalance is True diff --git a/tests/options/test_phase6_margin.py b/tests/options/test_phase6_margin.py new file mode 100644 index 0000000..dff708f --- /dev/null +++ b/tests/options/test_phase6_margin.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import ( + ExerciseStyle, + OptionInstrumentSpec, + OptionKind, + OptionLedger, + OptionMarginConfig, + OptionMarginModel, + OptionMarginRequirement, + PremiumConvention, + SettlementStyle, + calculate_option_margin, + liquidate_option_positions, +) +from quantbt.core.orders import Fill +from quantbt.core.schema import LiquiditySide, OrderSide + + +def _linear_spec(symbol: str = "BTC-USDC-C", kind=OptionKind.CALL) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="deribit", + underlying_id="BTC-PERP", + underlying_index_id="BTC-INDEX", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0, + expiry_ns=int(pd.Timestamp("2026-02-01 08:00:00", tz="UTC").value), + settlement_currency="USDC", + premium_currency="USDC", + quote_currency="USDC", + ) + + +def test_phase6_long_premium_only_and_no_margin_research_models(): + spec = _linear_spec() + ledger = OptionLedger.from_cash({"USDC": 10_000.0}) + ledger.apply_fill( + Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.BUY, qty=2.0, price=100.0, liquidity=LiquiditySide.TAKER), + spec, + timestamp_ns=1, + ) + instruments = {spec.symbol: spec} + marks = {spec.symbol: 120.0} + underlying = {spec.underlying_id: 100_000.0} + + long_margin = calculate_option_margin( + ledger, + instruments, + marks, + underlying, + config=OptionMarginConfig(model=OptionMarginModel.LONG_PREMIUM_ONLY, maintenance_ratio=0.5), + reporting_currency="USDC", + ) + no_margin = calculate_option_margin( + ledger, + instruments, + marks, + underlying, + config=OptionMarginConfig(model=OptionMarginModel.NO_MARGIN_RESEARCH), + reporting_currency="USDC", + ) + + assert long_margin.initial_margin == pytest.approx(240.0) + assert long_margin.maintenance_margin == pytest.approx(120.0) + assert long_margin.venue_exact is False + assert no_margin.initial_margin == pytest.approx(0.0) + + +def test_phase6_standard_and_scenario_margin_reports_venue_exact_false(): + spec = _linear_spec("BTC-USDC-P", OptionKind.PUT) + ledger = OptionLedger.from_cash({"USDC": 0.0}) + ledger.apply_fill( + Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.SELL, qty=1.0, price=100.0, liquidity=LiquiditySide.TAKER), + spec, + timestamp_ns=1, + ) + instruments = {spec.symbol: spec} + marks = {spec.symbol: 120.0} + underlying = {spec.underlying_id: 100_000.0} + + standard = calculate_option_margin( + ledger, + instruments, + marks, + underlying, + config=OptionMarginConfig(model="standard_venue_approx", short_option_margin_rate=0.10, maintenance_ratio=0.25), + reporting_currency="USDC", + ) + scenario = calculate_option_margin( + ledger, + instruments, + marks, + underlying, + config=OptionMarginConfig(model="scenario_pm_approx", short_option_margin_rate=0.05, maintenance_ratio=0.25), + reporting_currency="USDC", + ) + + assert standard.initial_margin == pytest.approx(10_000.0) + assert standard.maintenance_margin == pytest.approx(2_500.0) + assert standard.venue_exact is False + assert bool(standard.detail_report.loc[0, "venue_exact"]) is False + assert scenario.initial_margin >= 5_000.0 + assert scenario.metadata["venue_exact"] is False + + +def test_phase6_external_margin_validator_interface_is_explicit(): + class DummyValidator: + def calculate_margin(self, ledger, instruments, marks, underlying_prices, reporting_currency, conversion_rates): + return OptionMarginRequirement( + initial_margin=123.0, + maintenance_margin=12.3, + model=OptionMarginModel.EXTERNAL_VALIDATOR, + venue_exact=True, + reporting_currency=reporting_currency, + detail_report=pd.DataFrame([{"source": "dummy"}]), + ) + + result = calculate_option_margin( + OptionLedger.from_cash({"USDC": 1_000.0}), + {}, + {}, + {}, + config=OptionMarginConfig(model="external_validator"), + reporting_currency="USDC", + external_validator=DummyValidator(), + ) + + assert result.initial_margin == pytest.approx(123.0) + assert result.venue_exact is True + + with pytest.raises(ValueError, match="external_validator"): + calculate_option_margin( + OptionLedger.from_cash({"USDC": 1_000.0}), + {}, + {}, + {}, + config=OptionMarginConfig(model="external_validator"), + reporting_currency="USDC", + ) + + +def test_phase6_liquidation_audit_explains_breach_orders_fees_final_state(option_phase3_registry): + spec = option_phase3_registry.by_symbol["BTC-01FEB26-100000-C.DERIBIT"] + ledger = OptionLedger.from_cash({"BTC": 0.0}) + ledger.apply_fill( + Fill(timestamp=1, symbol=spec.symbol, side=OrderSide.SELL, qty=1.0, price=0.01, liquidity=LiquiditySide.TAKER), + spec, + timestamp_ns=1, + ) + margin = OptionMarginRequirement( + initial_margin=10_000.0, + maintenance_margin=1_000.0, + model=OptionMarginModel.STANDARD_VENUE_APPROX, + venue_exact=False, + reporting_currency="USD", + detail_report=pd.DataFrame(), + ) + + audit = liquidate_option_positions( + ledger, + {spec.symbol: spec}, + bid_prices={spec.symbol: 0.049}, + ask_prices={spec.symbol: 0.052}, + margin_requirement=margin, + conversion_rates={"BTC": 100_000.0}, + reporting_currency="USD", + timestamp_ns=2, + fee_rate=0.001, + ) + + assert audit.breached is True + assert audit.breach_reason == "maintenance_margin_breach" + assert audit.equity_before < audit.maintenance_margin + assert audit.final_positions == {} + assert audit.liquidation_orders.loc[0, "side"] == "buy" + assert audit.liquidation_orders.loc[0, "price"] == pytest.approx(0.052) + assert audit.liquidation_orders.loc[0, "fee"] == pytest.approx(0.000052) + assert audit.metadata["liquidation_sequence"] == "all_positions_adverse_bid_ask" + assert ledger.positions[spec.symbol].is_flat + + +def test_phase6_liquidation_noops_when_equity_above_maintenance(option_phase3_registry): + spec = option_phase3_registry.by_symbol["BTC-01FEB26-100000-C.DERIBIT"] + ledger = OptionLedger.from_cash({"BTC": 1.0}) + margin = OptionMarginRequirement( + initial_margin=1.0, + maintenance_margin=1.0, + model=OptionMarginModel.NO_MARGIN_RESEARCH, + venue_exact=False, + reporting_currency="USD", + detail_report=pd.DataFrame(), + ) + + audit = liquidate_option_positions( + ledger, + {spec.symbol: spec}, + bid_prices={spec.symbol: 0.01}, + ask_prices={spec.symbol: 0.02}, + margin_requirement=margin, + conversion_rates={"BTC": 100_000.0}, + reporting_currency="USD", + timestamp_ns=2, + ) + + assert audit.breached is False + assert audit.liquidation_orders.empty + assert audit.equity_after == pytest.approx(100_000.0) diff --git a/upgrade/implement.md b/upgrade/implement.md index 2bfc4c2..ef60437 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -3127,6 +3127,51 @@ Technical debt after Phase 17.5: - Quanto lifecycle payoff is not implemented. - Reporting conversion uses caller-supplied rates only. +### Phase 17.6 - Hedging And Margin + +Status: completed. + +Implemented: + +- Added option hedge-policy primitives: + - fixed threshold; + - hysteresis band; + - time-based; + - realized-vol scaled band. +- Added hedge path accounting where hedge PnL for the prior price move uses the + previous hedge position before current-bar rebalance. +- Added option margin primitives: + - long-premium-only; + - standard venue approximation; + - scenario PM approximation; + - no-margin research; + - external validator interface. +- Added liquidation audit: + - maintenance breach check; + - adverse bid/ask liquidation; + - fee report; + - final cash; + - final positions. +- Exported Phase 6 APIs from top-level `quantbt`. + +Latest tests: + +- options tests: `71 passed`. +- import smoke: `phase6_import_smoke=pass`. +- full non-real regression: `357 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 17.6: + +- Hedge/margin are primitives, not a full option backend loop yet. +- Whalley-Wilmott remains intentionally excluded. +- Standard/scenario PM models are approximations; scenario PM reports + `venue_exact=false`. +- External margin validator has an interface only. +- Liquidation closes all positions with adverse BBO prices, not exchange-native + queue/partial liquidation logic. +- Underlying hedge instrument execution and Nautilus option validation remain + future work. + --- ## Backend Selection Guide diff --git a/upgrade/option_backtest_plan/phase6_hedging_margin_status.md b/upgrade/option_backtest_plan/phase6_hedging_margin_status.md new file mode 100644 index 0000000..b95c789 --- /dev/null +++ b/upgrade/option_backtest_plan/phase6_hedging_margin_status.md @@ -0,0 +1,100 @@ +# Phase 6 - Hedging And Margin Status + +Date: 2026-07-23 + +Branch: `feat/option-engine` + +## Scope Completed + +Phase 6 added option hedge-policy primitives, option margin approximations, an +external margin validator interface, and liquidation audit primitives. It does +not add the full option backend, endpoint route, Nautilus validation, or +venue-exact margin adapter. + +Implemented: + +- `options/hedging.py` + - `OptionHedgePolicyType`; + - `OptionHedgeConfig`; + - `HedgeDecision`; + - `HedgePathResult`; + - `compute_net_option_delta(...)`; + - `hedge_decision(...)`; + - `run_delta_hedge_path(...)`. +- `options/margin.py` + - `OptionMarginModel`; + - `OptionMarginConfig`; + - `OptionMarginRequirement`; + - `ExternalOptionMarginValidator`; + - `OptionLiquidationAudit`; + - `calculate_option_margin(...)`; + - `liquidate_option_positions(...)`. +- Public exports through `quantbt.options` and top-level `quantbt`. + +## Domain Guarantees Locked + +- Hedge PnL for the prior price move uses the hedge quantity held before the + move. +- Hedge rebalance is evaluated after current option delta is recomputed. +- Fixed-threshold hedge policy is explicit. +- Hysteresis band policy is explicit and has separate enter/exit bands. +- Time-based hedge policy respects `rebalance_interval_ns`. +- Realized-vol scaled band uses observable underlying path history. +- Whalley-Wilmott is not implemented. +- Long-premium-only margin model is available. +- Standard venue approximation margin model is available. +- Scenario PM approximation reports `venue_exact=false`. +- No-margin research mode is available and labelled. +- External margin validator path requires an explicit validator. +- Liquidation audit explains: + - breach status; + - breach reason; + - equity before; + - maintenance margin; + - adverse bid/ask liquidation orders; + - fees; + - final cash; + - final positions. + +## Tests Run + +Commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.OptionHedgeConfig; assert quantbt.OptionMarginConfig; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase6_import_smoke=pass')" +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +``` + +Results: + +- compileall: pass. +- options tests: `71 passed`. +- import smoke: `phase6_import_smoke=pass`. +- full non-real regression: `357 passed, 1 skipped, 3 warnings`. + +Existing warnings are unrelated to options: + +- one pandas runtime warning in a portfolio missing-data scenario; +- two matplotlib tight-layout warnings in walk-forward quick plot tests. + +## Technical Debt + +- Hedge and margin are primitives, not yet wired into a full option backend + event loop. +- Whalley-Wilmott remains intentionally excluded until objective, cost units, + and paper reproduction are explicit. +- Standard/scenario PM models are approximations; venue-exact margin requires + external validator integration and sample parity. +- Liquidation closes all option positions with adverse BBO prices. It is not an + exchange-native liquidation optimizer or queue model. +- Underlying hedge instrument execution, hedge fees, and hedge slippage are not + integrated with package execution yet. +- Nautilus option validation remains future work. + +## Conclusion + +Phase 6 is complete and safe to build on. QuantBT options now has transparent +hedge-policy, margin, and liquidation audit primitives. Phase 7 can wire these +into a native option backend, result contract, endpoint, and support matrix. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index ca13b3d..d7acd8c 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -659,6 +659,72 @@ Acceptance: - Scenario PM report states `venue_exact=false`. - Liquidation audit explains breach, orders, fees and final state. +Status: completed. + +Implemented: + +- Added `options/hedging.py`: + - `OptionHedgePolicyType`; + - `OptionHedgeConfig`; + - `HedgeDecision`; + - `HedgePathResult`; + - `compute_net_option_delta(...)`; + - `hedge_decision(...)`; + - `run_delta_hedge_path(...)`. +- Implemented hedge policies: + - fixed threshold; + - hysteresis band; + - time-based; + - realized-vol scaled band. +- Locked hedge accounting order: + - hedge PnL for `price[t-1] -> price[t]` uses hedge quantity held at `t-1`; + - rebalance is evaluated only after current option delta is recomputed. +- Added `options/margin.py`: + - `OptionMarginModel`; + - `OptionMarginConfig`; + - `OptionMarginRequirement`; + - `ExternalOptionMarginValidator`; + - `OptionLiquidationAudit`; + - `calculate_option_margin(...)`; + - `liquidate_option_positions(...)`. +- Implemented margin models: + - long-premium-only; + - standard venue approximation; + - scenario PM approximation with `venue_exact=false`; + - no-margin research; + - external validator interface. +- Implemented liquidation audit: + - maintenance breach check; + - adverse bid/ask liquidation; + - fee reporting; + - final cash and final positions. +- Exported Phase 6 APIs from `quantbt.options` and top-level `quantbt`. + +Latest local tests: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options __init__.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` + - result: `71 passed` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.OptionHedgeConfig; assert quantbt.OptionMarginConfig; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase6_import_smoke=pass')"` + - result: `phase6_import_smoke=pass` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` + - result: `357 passed, 1 skipped, 3 warnings` + +Technical debt after Phase 6: + +- Hedge models are deterministic policy primitives, not a full integrated option + backtest loop yet. Backend wiring is Phase 7. +- Whalley-Wilmott is intentionally not implemented. +- Standard and scenario margin are approximations. Scenario PM explicitly + reports `venue_exact=false`. +- External venue margin validator is an interface only; no venue adapter is + implemented in Phase 6. +- Liquidation sequence closes all option positions with adverse BBO prices; it + is not an exchange liquidation engine, queue model, or partial liquidation + optimizer. +- Underlying hedge instrument execution and fees are not yet integrated with + option package execution. + ## Phase 7 - Backend, Engine, Endpoint, Result Files: From bd0320d7bf40c21583f0876a766b36c40ebab700 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 12:04:38 +0000 Subject: [PATCH 09/15] feat: add options phase 7 endpoint --- __init__.py | 18 +- backends/__init__.py | 4 + backends/native_option.py | 490 ++++++++++++++++++ core/results.py | 32 ++ docs/endpoint.md | 97 ++++ endpoint.py | 176 ++++++- engines.py | 60 ++- metrics/__init__.py | 4 + metrics/options_analytics.py | 46 ++ tests/options/test_endpoint_contract.py | 76 +++ tests/options/test_result_contract.py | 97 ++++ tests/test_endpoint.py | 3 +- .../phase7_backend_endpoint_result_status.md | 97 ++++ .../quantbt_options_engine_execution_plan.md | 32 ++ 14 files changed, 1220 insertions(+), 12 deletions(-) create mode 100644 backends/native_option.py create mode 100644 metrics/options_analytics.py create mode 100644 tests/options/test_endpoint_contract.py create mode 100644 tests/options/test_result_contract.py create mode 100644 upgrade/option_backtest_plan/phase7_backend_endpoint_result_status.md diff --git a/__init__.py b/__init__.py index 8112495..6fd7d99 100644 --- a/__init__.py +++ b/__init__.py @@ -75,18 +75,21 @@ validate_walkforward_strategy_output, walkforward_support_matrix, ) -from .engines import BacktestEngineV2, EventDrivenBacktestEngine, PortfolioBacktestEngine +from .engines import BacktestEngineV2, EventDrivenBacktestEngine, OptionBacktestEngine, PortfolioBacktestEngine from .backends import ( NativeEventBackend, NativeEventConfig, + NativeOptionBackend, + NativeOptionConfig, NativePortfolioBackend, NativePortfolioConfig, NativeVectorizedBackend, NativeVectorizedConfig, + OptionSettlementEvent, ) from .adapters.nautilus import NautilusBacktestEngine from .core.types import BacktestResult -from .core.results import BacktestResultV2 +from .core.results import BacktestResultV2, OptionBacktestResult from .core.orders import BasketIntent, Fill, OrderIntent, Trade from .core.basket import FrozenBasketPlan, build_frozen_basket_orders from .core.execution_depth import ( @@ -270,6 +273,9 @@ profit_factor, rolling_sharpe, rolling_drawdown, + option_attribution_report, + option_report_bundle, + option_run_manifest, ) from .viz import quick_plot, tearsheet, apply_theme @@ -303,10 +309,15 @@ "NautilusBacktestEngine", "NativeEventBackend", "NativeEventConfig", + "NativeOptionBackend", + "NativeOptionConfig", "NativePortfolioBackend", "NativePortfolioConfig", "NativeVectorizedBackend", "NativeVectorizedConfig", + "OptionBacktestEngine", + "OptionBacktestResult", + "OptionSettlementEvent", "NautilusExecutionDepthConfig", "PackageDepthPreflightResult", "PortfolioBacktestEngine", @@ -395,6 +406,9 @@ "select_target_moneyness_option", "settle_option_expiry", "validate_option_chain_frame", + "option_attribution_report", + "option_report_bundle", + "option_run_manifest", "LEGACY_PORTFOLIO_MODES", "LEGACY_PORTFOLIO_SIZING_MODES", "NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES", diff --git a/backends/__init__.py b/backends/__init__.py index 3606610..04066a7 100644 --- a/backends/__init__.py +++ b/backends/__init__.py @@ -1,12 +1,16 @@ from .native_event import NativeEventBackend, NativeEventConfig +from .native_option import NativeOptionBackend, NativeOptionConfig, OptionSettlementEvent from .native_portfolio import NativePortfolioBackend, NativePortfolioConfig from .native_vectorized import NativeVectorizedBackend, NativeVectorizedConfig __all__ = [ "NativeEventBackend", "NativeEventConfig", + "NativeOptionBackend", + "NativeOptionConfig", "NativePortfolioBackend", "NativePortfolioConfig", "NativeVectorizedBackend", "NativeVectorizedConfig", + "OptionSettlementEvent", ] diff --git a/backends/native_option.py b/backends/native_option.py new file mode 100644 index 0000000..e788e2e --- /dev/null +++ b/backends/native_option.py @@ -0,0 +1,490 @@ +""" +Native option backend facade. + +This backend wires the Phase 1-6 option components into the common QuantBT +result contract. It does not attempt to be a venue-exact options exchange; the +venue-specific gaps stay explicit in reports and metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Iterable, Mapping, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.results import OptionBacktestResult +from ..core.schema import AccountConfig, ExecutionConfig +from ..options.execution import OptionExecutionConfig, execute_option_package +from ..options.fees import OptionFeeResult, OptionFeeSchedule, calculate_option_fee +from ..options.ledger import OptionLedger +from ..options.lifecycle import OptionSettlementRepresentation, settle_option_expiry +from ..options.margin import OptionMarginConfig, OptionMarginRequirement, calculate_option_margin +from ..options.packages import OptionPackageIntent +from ..options.schema import OptionInstrumentRegistry, OptionInstrumentSpec +from ..options.tape import PreparedOptionTape, prepare_option_tape + + +@dataclass(frozen=True) +class NativeOptionConfig: + account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0)) + execution: ExecutionConfig = field(default_factory=ExecutionConfig) + option_execution: OptionExecutionConfig = field(default_factory=OptionExecutionConfig) + margin: OptionMarginConfig = field(default_factory=OptionMarginConfig) + fee_schedule: Optional[OptionFeeSchedule] = None + reporting_currency: str = "USD" + initial_balances: Optional[Dict[str, float]] = None + conversion_rates: Dict[str, float] = field(default_factory=dict) + settle_expired: bool = False + max_spread_bps: Optional[float] = None + max_source_latency_ns: Optional[int] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "reporting_currency", str(self.reporting_currency).upper()) + if self.account.initial_capital <= 0.0: + raise ValueError("account.initial_capital must be > 0") + + +@dataclass(frozen=True) +class OptionSettlementEvent: + symbol: str + timestamp_ns: int + settlement_price: float + representation: Optional[OptionSettlementRepresentation] = None + + +class NativeOptionBackend: + """Array-first native option backend returning `OptionBacktestResult`.""" + + def __init__(self, config: Optional[NativeOptionConfig] = None): + self.config = config or NativeOptionConfig() + + def run( + self, + *, + chain: pd.DataFrame, + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], + packages: Sequence[OptionPackageIntent] = (), + prepared_tape: Optional[PreparedOptionTape] = None, + settlement_events: Optional[Sequence[OptionSettlementEvent | Mapping]] = None, + conversion_rates: Optional[Dict[str, float]] = None, + reporting_currency: Optional[str] = None, + ) -> OptionBacktestResult: + registry = _normalize_registry(instruments) + tape = prepared_tape or prepare_option_tape( + chain, + registry, + max_spread_bps=self.config.max_spread_bps, + max_source_latency_ns=self.config.max_source_latency_ns, + ) + tape.validate_compatible(registry_signature=registry.signature) + rates = {**self.config.conversion_rates, **(conversion_rates or {})} + report_ccy = str(reporting_currency or self.config.reporting_currency).upper() + if report_ccy not in rates: + rates[report_ccy] = 1.0 + + ledger = OptionLedger.from_cash(self.config.initial_balances or {report_ccy: self.config.account.initial_capital}) + instrument_map = registry.by_symbol + packages_sorted = tuple(sorted(packages or (), key=lambda package: int(package.timestamp_ns))) + order_reports = [] + package_reports = [] + applied_fills = [] + snapshots = [] + + snapshots.append(_snapshot_state(tape, 0, ledger, instrument_map, rates, report_ccy, "initial")) + for package in packages_sorted: + pkg_result = execute_option_package( + package, + tape, + config=self.config.option_execution, + positions={symbol: position.qty for symbol, position in ledger.positions.items()}, + ) + order_reports.append(pkg_result.order_report) + package_reports.append(pkg_result.package_report) + for fill in pkg_result.fills: + instrument = instrument_map[fill.symbol] + fee = _option_fee(fill, instrument, tape, self.config.fee_schedule) + ledger.apply_fill(fill, instrument, fee=fee, timestamp_ns=int(fill.timestamp)) + applied_fills.append((fill, fee)) + snap_idx = tape.snapshot_index_at_or_before(int(package.timestamp_ns)) + snapshots.append(_snapshot_state(tape, snap_idx, ledger, instrument_map, rates, report_ccy, package.package_id)) + + settlements = [] + for event in _normalize_settlement_events(settlement_events): + instrument = instrument_map[event.symbol] + settlement = settle_option_expiry( + ledger, + instrument, + timestamp_ns=int(event.timestamp_ns), + settlement_price=float(event.settlement_price), + representation=event.representation, + ) + settlements.append(settlement) + snap_idx = min(tape.snapshot_count - 1, max(0, np.searchsorted(tape.timestamp_ns, int(event.timestamp_ns), side="right") - 1)) + snapshots.append(_snapshot_state(tape, int(snap_idx), ledger, instrument_map, rates, report_ccy, f"settlement:{event.symbol}")) + + if self.config.settle_expired: + last_ts = int(tape.timestamp_ns[-1]) + marks = _snapshot_marks(tape, tape.snapshot_count - 1) + underlyings = _snapshot_underlyings(tape, tape.snapshot_count - 1) + for symbol, position in list(ledger.positions.items()): + instrument = instrument_map[symbol] + if position.is_flat or int(instrument.expiry_ns) > last_ts: + continue + settlement = settle_option_expiry( + ledger, + instrument, + timestamp_ns=last_ts, + settlement_price=underlyings.get(instrument.underlying_id, marks.get(symbol, 0.0)), + ) + settlements.append(settlement) + snapshots.append(_snapshot_state(tape, tape.snapshot_count - 1, ledger, instrument_map, rates, report_ccy, "auto_settlement")) + + final_snapshot_idx = tape.snapshot_count - 1 + final_marks = _snapshot_marks(tape, final_snapshot_idx) + final_underlyings = _snapshot_underlyings(tape, final_snapshot_idx) + margin = calculate_option_margin( + ledger, + instrument_map, + final_marks, + final_underlyings, + config=self.config.margin, + reporting_currency=report_ccy, + conversion_rates=rates, + ) + snapshots.append(_snapshot_state(tape, final_snapshot_idx, ledger, instrument_map, rates, report_ccy, "final")) + + return _build_result( + tape=tape, + registry=registry, + ledger=ledger, + account=self.config.account, + report_ccy=report_ccy, + conversion_rates=rates, + snapshots=snapshots, + fills_with_fees=applied_fills, + order_report=_concat(order_reports), + package_report=_concat(package_reports), + settlements=settlements, + margin=margin, + metadata={ + "backend": "native_option", + "engine": "native_option", + "phase": "phase7_backend_endpoint_result", + "package_count": len(packages_sorted), + "fill_count": len(applied_fills), + "settlement_count": len(settlements), + "venue_exact_margin": bool(margin.venue_exact), + "reporting_currency": report_ccy, + **self.config.metadata, + }, + ) + + +def _normalize_registry( + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], +) -> OptionInstrumentRegistry: + if isinstance(instruments, OptionInstrumentRegistry): + return instruments + if isinstance(instruments, Mapping): + return OptionInstrumentRegistry.from_iterable(instruments.values()) + return OptionInstrumentRegistry.from_iterable(tuple(instruments)) + + +def _normalize_settlement_events(events: Optional[Sequence[OptionSettlementEvent | Mapping]]) -> tuple[OptionSettlementEvent, ...]: + if not events: + return () + out = [] + for event in events: + if isinstance(event, OptionSettlementEvent): + out.append(event) + else: + out.append( + OptionSettlementEvent( + symbol=str(event["symbol"]), + timestamp_ns=int(event["timestamp_ns"]), + settlement_price=float(event["settlement_price"]), + representation=event.get("representation"), + ) + ) + return tuple(out) + + +def _option_fee(fill, instrument: OptionInstrumentSpec, tape: PreparedOptionTape, schedule: Optional[OptionFeeSchedule]) -> Optional[OptionFeeResult]: + schedule = schedule or fill.metadata.get("option_fee_schedule") + if schedule is None: + return None + if not isinstance(schedule, OptionFeeSchedule): + return None + row_index = int(fill.metadata.get("option_row_index", -1)) + if row_index < 0: + return None + reference = float(tape.index_price[row_index] if np.isfinite(tape.index_price[row_index]) else tape.forward_price[row_index]) + return calculate_option_fee(fill, instrument, schedule, reference_price=reference) + + +def _snapshot_state( + tape: PreparedOptionTape, + snapshot_idx: int, + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + conversion_rates: Dict[str, float], + report_ccy: str, + label: str, +) -> Dict: + marks = _snapshot_marks(tape, snapshot_idx) + equity = ledger.equity(conversion_rates=conversion_rates, marks=marks, instruments=instruments, reporting_currency=report_ccy) + return { + "timestamp_ns": int(tape.timestamp_ns[snapshot_idx]), + "label": label, + "equity": float(equity), + "cash": dict(ledger.cash), + "positions": {symbol: position.qty for symbol, position in ledger.positions.items()}, + "marks": marks, + } + + +def _snapshot_marks(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]: + rows = tape.snapshot_slice(snapshot_idx) + return {tape.instrument_id[idx]: float(tape.mark_price[idx]) for idx in range(rows.start, rows.stop)} + + +def _snapshot_underlyings(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]: + rows = tape.snapshot_slice(snapshot_idx) + out = {} + registry = tape.registry.by_symbol + for idx in range(rows.start, rows.stop): + symbol = tape.instrument_id[idx] + instrument = registry[symbol] + price = float(tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx]) + out[instrument.underlying_id] = price + out[symbol] = price + return out + + +def _build_result( + *, + tape: PreparedOptionTape, + registry: OptionInstrumentRegistry, + ledger: OptionLedger, + account: AccountConfig, + report_ccy: str, + conversion_rates: Dict[str, float], + snapshots: Sequence[Dict], + fills_with_fees: Sequence[tuple], + order_report: pd.DataFrame, + package_report: pd.DataFrame, + settlements: Sequence, + margin: OptionMarginRequirement, + metadata: Dict, +) -> OptionBacktestResult: + index = pd.DatetimeIndex(pd.to_datetime([snap["timestamp_ns"] for snap in snapshots], utc=True)).tz_convert(None) + equity = pd.Series([snap["equity"] for snap in snapshots], index=index, name="equity") + if len(equity.index) != len(set(equity.index)): + offsets = pd.to_timedelta(np.arange(len(equity)), unit="ns") + equity.index = pd.DatetimeIndex(equity.index + offsets) + returns = equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + + symbols = list(registry.symbols) + positions = pd.DataFrame( + [{f"Position_{symbol}": snap["positions"].get(symbol, 0.0) for symbol in symbols} for snap in snapshots], + index=equity.index, + columns=[f"Position_{symbol}" for symbol in symbols], + ) + closes = pd.DataFrame( + [{f"Close_{symbol}": snap["marks"].get(symbol, np.nan) for symbol in symbols} for snap in snapshots], + index=equity.index, + columns=[f"Close_{symbol}" for symbol in symbols], + ).ffill() + cash_report = _cash_report(snapshots, equity.index) + marks_report = _marks_report(tape) + greeks_report = _greeks_report(tape) + fills_report = _fills_report(fills_with_fees) + settlements_report = _settlements_report(settlements) + attribution_report = _attribution_report(ledger, account, equity.iloc[-1], report_ccy, conversion_rates) + run_manifest = { + "backend": "native_option", + "result_contract": "OptionBacktestResult", + "symbols": symbols, + "snapshot_count": int(tape.snapshot_count), + "row_count": int(tape.row_count), + "initial_capital": float(account.initial_capital), + "final_equity": float(equity.iloc[-1]), + "reporting_currency": report_ccy, + "option_reports": [ + "fills_report", + "packages_report", + "cash_report", + "marks_report", + "greeks_report", + "settlements_report", + "margin_report", + "attribution_report", + ], + } + result_metadata = { + **metadata, + "order_report": order_report, + "fills_report": fills_report, + "packages_report": package_report, + "cash_report": cash_report, + "marks_report": marks_report, + "greeks_report": greeks_report, + "settlements_report": settlements_report, + "margin_report": margin.detail_report, + "attribution_report": attribution_report, + "run_manifest": run_manifest, + "ledger_event_report": ledger.event_report(), + "equity_identity": ledger.equity_identity_report( + conversion_rates=conversion_rates, + marks=_snapshot_marks(tape, tape.snapshot_count - 1), + instruments=registry.by_symbol, + reporting_currency=report_ccy, + ), + } + fees = pd.Series(0.0, index=equity.index, name="fees") + if len(fees) > 0: + fees.iloc[-1] = float(sum((fee.fee if fee is not None else fill.fee) for fill, fee in fills_with_fees)) + return OptionBacktestResult( + equity=equity, + returns=returns, + positions=positions, + closes=closes, + symbols=symbols, + initial_capital=float(account.initial_capital), + leverage=float(account.leverage), + liquidated=False, + fills=tuple(fill for fill, _ in fills_with_fees), + fees=fees, + margin=margin.detail_report, + diagnostics=package_report, + metadata=result_metadata, + fills_report=fills_report, + packages_report=package_report, + cash_report=cash_report, + marks_report=marks_report, + greeks_report=greeks_report, + settlements_report=settlements_report, + margin_report=margin.detail_report, + attribution_report=attribution_report, + run_manifest=run_manifest, + ) + + +def _concat(frames: Iterable[pd.DataFrame]) -> pd.DataFrame: + items = [frame for frame in frames if frame is not None and not frame.empty] + return pd.concat(items, ignore_index=True) if items else pd.DataFrame() + + +def _cash_report(snapshots: Sequence[Dict], index: pd.DatetimeIndex) -> pd.DataFrame: + currencies = sorted({currency for snap in snapshots for currency in snap["cash"]}) + return pd.DataFrame( + [{currency: snap["cash"].get(currency, 0.0) for currency in currencies} for snap in snapshots], + index=index, + columns=currencies, + ) + + +def _marks_report(tape: PreparedOptionTape) -> pd.DataFrame: + rows = [] + for snap_idx, ts in enumerate(tape.timestamp_ns): + slc = tape.snapshot_slice(snap_idx) + for idx in range(slc.start, slc.stop): + rows.append( + { + "timestamp_ns": int(ts), + "instrument_id": tape.instrument_id[idx], + "bid_price": float(tape.bid_price[idx]), + "ask_price": float(tape.ask_price[idx]), + "mark_price": float(tape.mark_price[idx]), + "index_price": float(tape.index_price[idx]), + "forward_price": float(tape.forward_price[idx]), + "bid_size": float(tape.bid_size[idx]), + "ask_size": float(tape.ask_size[idx]), + } + ) + return pd.DataFrame(rows) + + +def _greeks_report(tape: PreparedOptionTape) -> pd.DataFrame: + return pd.DataFrame( + { + "timestamp_ns": np.repeat(tape.timestamp_ns, np.diff(tape.row_ptr)), + "instrument_id": tape.instrument_id, + "mark_iv": tape.mark_iv, + "bid_iv": tape.bid_iv, + "ask_iv": tape.ask_iv, + "delta": tape.delta, + "gamma": tape.gamma, + "vega": tape.vega, + "theta": tape.theta, + } + ) + + +def _fills_report(fills_with_fees: Sequence[tuple]) -> pd.DataFrame: + rows = [] + for fill, fee in fills_with_fees: + rows.append( + { + "timestamp": fill.timestamp, + "symbol": fill.symbol, + "side": fill.side.value, + "qty": float(fill.qty), + "price": float(fill.price), + "notional": float(fill.notional), + "execution_fee": float(fill.fee), + "applied_fee": float(fee.fee if fee is not None else fill.fee), + "fee_currency": fee.currency if fee is not None else "", + "liquidity": fill.liquidity.value, + "order_id": fill.order_id, + "package_id": fill.metadata.get("package_id"), + } + ) + return pd.DataFrame(rows) + + +def _settlements_report(settlements: Sequence) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "timestamp_ns": item.timestamp_ns, + "symbol": item.symbol, + "settlement_price": item.settlement_price, + "payoff_per_unit": item.payoff_per_unit, + "cashflow": item.cashflow, + "settlement_currency": item.settlement_currency, + "representation": item.representation.value, + "itm": item.itm, + "position_closed": item.position_closed, + } + for item in settlements + ] + ) + + +def _attribution_report( + ledger: OptionLedger, + account: AccountConfig, + final_equity: float, + report_ccy: str, + conversion_rates: Dict[str, float], +) -> pd.DataFrame: + rows = [] + for currency, amount in ledger.cash.items(): + rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan)) + rows.append({"bucket": "cash", "currency": currency, "amount": float(amount), "reporting_value": float(amount) * rate}) + for currency, fee in ledger.fees.items(): + rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan)) + rows.append({"bucket": "fees", "currency": currency, "amount": -float(fee), "reporting_value": -float(fee) * rate}) + rows.append( + { + "bucket": "total", + "currency": report_ccy, + "amount": float(final_equity - account.initial_capital), + "reporting_value": float(final_equity - account.initial_capital), + } + ) + return pd.DataFrame(rows) diff --git a/core/results.py b/core/results.py index 041bb93..6829001 100644 --- a/core/results.py +++ b/core/results.py @@ -118,3 +118,35 @@ def to_legacy(self) -> BacktestResult: liquidation_bar=int(self.liquidation_bar), metadata=dict(self.metadata), ) + + +@dataclass +class OptionBacktestResult(BacktestResultV2): + """ + Backtest result contract for native option simulations. + + It intentionally remains a `BacktestResultV2` so existing report helpers + keep working, while exposing option-domain audit tables explicitly. + """ + + fills_report: pd.DataFrame = field(default_factory=pd.DataFrame) + packages_report: pd.DataFrame = field(default_factory=pd.DataFrame) + cash_report: pd.DataFrame = field(default_factory=pd.DataFrame) + marks_report: pd.DataFrame = field(default_factory=pd.DataFrame) + greeks_report: pd.DataFrame = field(default_factory=pd.DataFrame) + settlements_report: pd.DataFrame = field(default_factory=pd.DataFrame) + margin_report: pd.DataFrame = field(default_factory=pd.DataFrame) + attribution_report: pd.DataFrame = field(default_factory=pd.DataFrame) + run_manifest: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + super().__post_init__() + self.metadata.setdefault("fills_report", self.fills_report) + self.metadata.setdefault("packages_report", self.packages_report) + self.metadata.setdefault("cash_report", self.cash_report) + self.metadata.setdefault("marks_report", self.marks_report) + self.metadata.setdefault("greeks_report", self.greeks_report) + self.metadata.setdefault("settlements_report", self.settlements_report) + self.metadata.setdefault("margin_report", self.margin_report) + self.metadata.setdefault("attribution_report", self.attribution_report) + self.metadata.setdefault("run_manifest", self.run_manifest) diff --git a/docs/endpoint.md b/docs/endpoint.md index bbb0d4f..fafb797 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -1747,6 +1747,103 @@ Rules for services: - use `native_vectorized` for broad sweeps and `native_event` or `nautilus` for fill-level validation. +## Options Endpoint + +`QuantBTEndpoint.options(...)` is the Phase 7 public route for native option +research. It is intentionally separate from `native_event` and generic +arbitrage because options require quote-side execution, premium-currency +cashflows, expiry/settlement, Greeks, and option margin. + +Minimal call: + +```python +from quantbt import ( + OptionPackageIntent, + OptionPackageLeg, + OrderSide, + QuantBTEndpoint, +) + +bt = QuantBTEndpoint.options( + initial_capital=20_000, + reporting_currency="USD", + initial_balances={"USD": 20_000}, + conversion_rates={"BTC": 100_000}, + fee_rate=0.0001, +) + +package = OptionPackageIntent( + timestamp_ns=int(chain["timestamp_ns"].min()), + package_id="long-call", + legs=( + OptionPackageLeg( + instrument_id="BTC-01FEB26-100000-C.DERIBIT", + side=OrderSide.BUY, + ratio=1.0, + ), + ), + quantity=1.0, +) + +result = bt.backtest( + chain=chain, + instruments=option_registry, + packages=[package], +) + +bt.show_metrics() +fills = result.fills_report +greeks = result.greeks_report +margin = result.margin_report +manifest = result.run_manifest +``` + +Required data: + +- `chain`: canonical long-form option chain with `timestamp_ns`, + `instrument_id`, venue/static fields, bid/ask/mark prices, bid/ask size, + index/forward price, IV and Greeks columns where available. +- `instruments`: `OptionInstrumentRegistry`, list, or mapping of + `OptionInstrumentSpec`. +- `packages`: optional sequence of `OptionPackageIntent`. Strategy/template + code owns signal generation and package construction; the backend owns + execution, ledger, margin, settlement, and reports. + +Useful config: + +- `reporting_currency`: reporting/account currency, default `USD`. +- `initial_balances`: multi-currency starting balances. If omitted, QuantBT + starts with `initial_capital` in the reporting currency. +- `conversion_rates`: required whenever premium/settlement currency differs + from reporting currency, for example inverse BTC options reported in USD. +- `fee_schedule`: optional venue-like `OptionFeeSchedule`; otherwise the + endpoint fee rate is applied as a simple execution fee. +- `option_execution`: optional `OptionExecutionConfig` for quote age, partial + fill, limit fidelity, and depth fidelity settings. +- `option_margin`: optional `OptionMarginConfig`. Current margin is an explicit + approximation unless an external validator is provided in later phases. +- `settlement_events`: optional expiry settlement events passed to + `backtest(...)`. + +Returned result: + +- `OptionBacktestResult`, compatible with `BacktestResultV2`. +- Standard helpers: `.show_metrics()`, `.full_report()`, `.quick_plot()`, + `.tearsheet()`. +- Option audit tables: `fills_report`, `packages_report`, `cash_report`, + `marks_report`, `greeks_report`, `settlements_report`, `margin_report`, + `attribution_report`, and `run_manifest`. + +Support discovery: + +```python +QuantBTEndpoint.options_support_matrix() +QuantBTEndpoint.arbitrage_support_matrix()["OptionsVolArbSpec"] +``` + +`OptionsVolArbSpec` is routed to the specialized option route only. It should +not be executed through generic arbitrage package backends. + ## Common Errors `single-symbol endpoint requires data DataFrame` diff --git a/endpoint.py b/endpoint.py index d9afc26..026503b 100644 --- a/endpoint.py +++ b/endpoint.py @@ -20,6 +20,7 @@ from .backends import ( NativeEventBackend, NativeEventConfig, + NativeOptionConfig, NativePortfolioBackend, NativePortfolioConfig, NativeVectorizedBackend, @@ -43,7 +44,7 @@ simulate_nautilus_order_package_depth, ) from .core.orders import OrderIntent -from .core.results import BacktestResultV2 +from .core.results import BacktestResultV2, OptionBacktestResult from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce from .core.structured_orders import ( BracketOrderSpec, @@ -52,10 +53,15 @@ build_dca_grid_order_plan, ) from .core.types import BacktestResult -from .engines import BacktestEngineV2, PortfolioBacktestEngine +from .engines import BacktestEngineV2, OptionBacktestEngine, PortfolioBacktestEngine from .metrics import full_report as _full_report from .reporting import build_portfolio_nautilus_validation_report from .sizing.modes import compute_target_units +from .options.execution import OptionExecutionConfig +from .options.fees import OptionFeeSchedule +from .options.margin import OptionMarginConfig +from .options.packages import OptionPackageIntent +from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec from .viz import quick_plot as _quick_plot from .viz import tearsheet as _tearsheet from .walkforward import WalkForwardConfig, WalkForwardEngine @@ -75,7 +81,8 @@ class EndpointConfig: mode: Strategy integration mode. Supported values are `single_signal`, `pct_equity`, `signal_notional`, `dca_ladder`, `orders`, `basket`, - `portfolio`, `arbitrage`, `walk_forward`, and `nautilus_validation`. + `portfolio`, `arbitrage`, `options`, `walk_forward`, and + `nautilus_validation`. backend: Engine selector. Use `auto` for domain-safe defaults, or explicitly set `legacy`, `native_vectorized`, `native_event`, or `nautilus`. @@ -130,6 +137,8 @@ class EndpointConfig: 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`. + option_config: + Optional `NativeOptionConfig` for native option simulations. strategy_class: Optional strategy callable/class for `walk_forward` mode. The strategy must return a Series, DataFrame, or `{symbol: Series}` OOS output. @@ -169,6 +178,7 @@ class EndpointConfig: dca_kwargs: Dict = field(default_factory=dict) nautilus_config: object = None nautilus_depth_config: Optional[NautilusExecutionDepthConfig] = None + option_config: object = None report_level: str = "full" strategy_class: object = None walkforward_config: Optional[WalkForwardConfig] = None @@ -285,6 +295,61 @@ def orders(cls, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint": """ return cls(_config_from_kwargs(mode="orders", backend=backend, **kwargs)) + @classmethod + def options( + cls, + backend: str = "native_option", + *, + option_config: Optional[NativeOptionConfig] = None, + option_execution: Optional[OptionExecutionConfig] = None, + option_margin: Optional[OptionMarginConfig] = None, + fee_schedule: Optional[OptionFeeSchedule] = None, + reporting_currency: str = "USD", + initial_balances: Optional[Dict[str, float]] = None, + conversion_rates: Optional[Dict[str, float]] = None, + settle_expired: bool = False, + max_spread_bps: Optional[float] = None, + max_source_latency_ns: Optional[int] = None, + **kwargs, + ) -> "QuantBTEndpoint": + """ + Create a native option simulation endpoint. + + Strategy/template code supplies canonical option-chain rows, + `OptionInstrumentSpec` definitions, and `OptionPackageIntent` packages + to `backtest(...)` or `simulate(...)`. The endpoint routes packages + through snapshot-level option execution, applies fills to the + multi-currency option ledger, calculates margin, and returns an + `OptionBacktestResult` with fills/packages/cash/marks/Greeks/settlement + artifacts. + + Required `backtest()` inputs: + `chain`, `instruments`, and optional `packages`. + """ + if backend.lower().strip() != "native_option": + raise ValueError("options endpoint currently supports backend='native_option' only") + metadata = dict(kwargs.pop("metadata", {})) + metadata.setdefault("mode_family", "options") + endpoint_config = _config_from_kwargs(mode="options", backend=backend, metadata=metadata, **kwargs) + if option_config is None: + option_config = NativeOptionConfig( + account=endpoint_config.account, + execution=endpoint_config.execution, + option_execution=option_execution + or OptionExecutionConfig(fee_rate=endpoint_config.v2_fee_rate, metadata={"source": "QuantBTEndpoint.options"}), + margin=option_margin or OptionMarginConfig(), + fee_schedule=fee_schedule, + reporting_currency=reporting_currency, + initial_balances=initial_balances, + conversion_rates=dict(conversion_rates or {}), + settle_expired=settle_expired, + max_spread_bps=max_spread_bps, + max_source_latency_ns=max_source_latency_ns, + metadata=metadata, + ) + endpoint_config = replace(endpoint_config, option_config=option_config) + return cls(endpoint_config) + @classmethod def nautilus_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint": """ @@ -431,10 +496,58 @@ def arbitrage_support_matrix() -> Dict[str, Dict[str, str]]: "sizing": "not executable yet", }, "OptionsVolArbSpec": { - "status": "schema_only", - "backends": "none", - "route": "needs option/greeks engine", - "sizing": "not executable yet", + "status": "specialized_route", + "backends": "native_option", + "route": "QuantBTEndpoint.options(...) with OptionPackageIntent and Greeks reports", + "sizing": "option package quantities; Greeks-aware risk belongs to option route", + }, + } + + @staticmethod + def options_support_matrix() -> Dict[str, Dict[str, str]]: + """ + Return the native option endpoint support matrix. + + `supported` means the Phase 7 endpoint can execute the workflow through + current native option components. `future` means the public schema is + intentionally reserved but should wait for later phases. + """ + return { + "canonical_chain_tape": { + "status": "supported", + "backend": "native_option", + "route": "prepare_option_tape", + "notes": "long-form option chain with bid/ask/mark/IV/Greeks columns", + }, + "option_packages": { + "status": "supported", + "backend": "native_option", + "route": "execute_option_package -> OptionLedger", + "notes": "atomic_all_or_none, best_effort, sequential, hedge_after_primary, rebalance_only", + }, + "multi_currency_ledger": { + "status": "supported", + "backend": "native_option", + "route": "OptionLedger", + "notes": "premium cash, fees, realized PnL, settlement cashflow and marked equity", + }, + "margin": { + "status": "supported_approx", + "backend": "native_option", + "route": "calculate_option_margin", + "notes": "venue-exact margin requires external validator or later Nautilus/venue adapter", + }, + "OptionsVolArbSpec": { + "status": "specialized_route", + "backend": "native_option", + "route": "strategy/template emits option packages; endpoint returns Greeks and attribution reports", + "notes": "not executable through generic arbitrage package route", + }, + "nautilus_options": { + "status": "future", + "backend": "nautilus", + "route": "Phase 9", + "notes": "Nautilus option instrument mapping remains optional future validation", }, } @@ -713,6 +826,11 @@ def backtest( symbols: Optional[Sequence[str]] = None, params: Optional[Dict] = None, param_ranges: Optional[Dict] = None, + chain: Optional[pd.DataFrame] = None, + instruments: Optional[Union[OptionInstrumentRegistry, Sequence[OptionInstrumentSpec], Dict[str, OptionInstrumentSpec]]] = None, + packages: Optional[Sequence[OptionPackageIntent]] = None, + settlement_events: Optional[Sequence] = None, + conversion_rates: Optional[Dict[str, float]] = None, ): """ Run the configured backtest and store the result. @@ -741,6 +859,14 @@ def backtest( Optional symbol override for this run. """ mode = self.config.mode.lower().strip() + if mode == "options": + return self._run_options( + chain=chain if chain is not None else data, + instruments=instruments, + packages=packages, + settlement_events=settlement_events, + conversion_rates=conversion_rates, + ) if mode == "walk_forward": return self._run_walk_forward( data=data, @@ -955,6 +1081,33 @@ def nautilus_pct_equity_diagnostic( native_slippage=native_slippage, ) + def _run_options(self, chain, instruments, packages, settlement_events, conversion_rates): + if chain is None: + raise ValueError("options endpoint requires chain=option_chain_dataframe or data=option_chain_dataframe") + if instruments is None: + instruments = self.config.instruments + if instruments is None: + raise ValueError("options endpoint requires instruments=OptionInstrumentRegistry/list/mapping") + config = self.config.option_config + if config is None: + config = NativeOptionConfig( + account=self.config.account, + execution=self.config.execution, + option_execution=OptionExecutionConfig(fee_rate=self.config.v2_fee_rate), + margin=OptionMarginConfig(), + metadata=dict(self.config.metadata), + ) + self.engine = OptionBacktestEngine( + chain=chain, + instruments=instruments, + packages=packages or (), + config=config, + settlement_events=settlement_events or (), + conversion_rates=conversion_rates, + ) + self._store_result(self.engine.result) + return self.result + def _run_single(self, data, signal, signal_col, datetime_index, symbols): frame, idx, sig = _normalize_single_data(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index) backend = _resolve_backend(self.config) @@ -1142,6 +1295,11 @@ def _run_arbitrage(self, data, signal, signal_col, closes, highs, lows, hedge_ra phase_g_package_specs = (CalendarSpreadSpec, FundingArbitrageSpec, SpotPerpCashCarrySpec, IndexBasketArbSpec) schema_only_specs = (CrossExchangeArbSpec, TriangularArbSpec, OptionsVolArbSpec) if isinstance(spec, schema_only_specs): + if isinstance(spec, OptionsVolArbSpec): + raise NotImplementedError( + "OptionsVolArbSpec must route through QuantBTEndpoint.options(...), not generic arbitrage execution. " + "The option route preserves package fills, multi-currency ledger, Greeks, settlement, and margin reports." + ) raise NotImplementedError( f"{type(spec).__name__} is schema-validated but requires a specialized arbitrage engine; " "do not route it through generic package execution" @@ -1934,13 +2092,15 @@ def _resolve_backend(config: EndpointConfig) -> str: if backend != "auto": if backend == "legacy_portfolio": return backend - if backend not in {"legacy", "native_vectorized", "native_event", "native_portfolio", "nautilus"}: + if backend not in {"legacy", "native_vectorized", "native_event", "native_portfolio", "native_option", "nautilus"}: raise ValueError(f"unsupported backend={config.backend!r}") return backend mode = config.mode.lower().strip() sizing = config.sizing.lower().strip() if mode == "portfolio": return "native_portfolio" + if mode == "options": + return "native_option" if mode in ("pct_equity", "dca_ladder") or sizing in ("%_equity", "pct_equity", "dca_ladder", "dca"): return "legacy" if mode == "nautilus_validation": diff --git a/engines.py b/engines.py index f689b91..9881813 100644 --- a/engines.py +++ b/engines.py @@ -16,6 +16,8 @@ from .backends import ( NativeEventBackend, NativeEventConfig, + NativeOptionBackend, + NativeOptionConfig, NativePortfolioBackend, NativePortfolioConfig, NativeVectorizedBackend, @@ -23,8 +25,10 @@ ) from .core.orders import OrderIntent from .core.preprocessor import validate_datetime -from .core.results import BacktestResultV2 +from .core.results import BacktestResultV2, OptionBacktestResult from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce +from .options.packages import OptionPackageIntent +from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec from .portfolio import MultiSymbolPortfolio from .sizing.modes import compute_target_units @@ -354,6 +358,60 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) +class OptionBacktestEngine: + """ + Native option facade returning `OptionBacktestResult`. + + Parameters + ---------- + chain: + Canonical long-form option chain rows. + instruments: + Option instrument registry, sequence, or mapping. + packages: + Option package intents generated by a strategy/template layer. + config: + Native option backend configuration. + """ + + def __init__( + self, + *, + chain: Optional[pd.DataFrame] = None, + instruments: Optional[OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Dict[str, OptionInstrumentSpec]] = None, + packages: Sequence[OptionPackageIntent] = (), + config: Optional[NativeOptionConfig] = None, + settlement_events: Optional[Sequence] = None, + conversion_rates: Optional[Dict[str, float]] = None, + auto_run: bool = True, + ): + self.chain = chain + self.instruments = instruments + self.packages = tuple(packages or ()) + self.config = config or NativeOptionConfig() + self.settlement_events = tuple(settlement_events or ()) + self.conversion_rates = conversion_rates + self.backend = NativeOptionBackend(self.config) + self.result: Optional[OptionBacktestResult] = None + + if auto_run: + self.run() + + def run(self) -> OptionBacktestResult: + if self.chain is None: + raise ValueError("OptionBacktestEngine requires chain") + if self.instruments is None: + raise ValueError("OptionBacktestEngine requires instruments") + self.result = self.backend.run( + chain=self.chain, + instruments=self.instruments, + packages=self.packages, + settlement_events=self.settlement_events, + conversion_rates=self.conversion_rates, + ) + return self.result + + class PortfolioBacktestEngine: """ V2-compatible multi-symbol portfolio facade. diff --git a/metrics/__init__.py b/metrics/__init__.py index 00ba942..d1ca338 100644 --- a/metrics/__init__.py +++ b/metrics/__init__.py @@ -18,6 +18,7 @@ rolling_sharpe, rolling_drawdown, ) +from .options_analytics import option_attribution_report, option_report_bundle, option_run_manifest __all__ = [ "full_report", @@ -38,4 +39,7 @@ "expectancy", "rolling_sharpe", "rolling_drawdown", + "option_attribution_report", + "option_report_bundle", + "option_run_manifest", ] diff --git a/metrics/options_analytics.py b/metrics/options_analytics.py new file mode 100644 index 0000000..78dcaa9 --- /dev/null +++ b/metrics/options_analytics.py @@ -0,0 +1,46 @@ +""" +Option-domain report helpers. + +These functions summarize `OptionBacktestResult` artifacts without recomputing +ledger accounting or execution PnL. +""" + +from __future__ import annotations + +from typing import Dict + +import pandas as pd + + +def option_run_manifest(result) -> Dict: + """Return the option run manifest stored by `NativeOptionBackend`.""" + return dict(getattr(result, "run_manifest", None) or result.metadata.get("run_manifest", {})) + + +def option_attribution_report(result) -> pd.DataFrame: + """Return the option attribution table, or an empty DataFrame.""" + report = getattr(result, "attribution_report", None) + if report is None: + report = result.metadata.get("attribution_report") + return report.copy() if isinstance(report, pd.DataFrame) else pd.DataFrame() + + +def option_report_bundle(result) -> Dict[str, pd.DataFrame]: + """Return all standard option audit tables as a dictionary.""" + names = ( + "fills_report", + "packages_report", + "cash_report", + "marks_report", + "greeks_report", + "settlements_report", + "margin_report", + "attribution_report", + ) + out = {} + for name in names: + report = getattr(result, name, None) + if report is None: + report = result.metadata.get(name) + out[name] = report.copy() if isinstance(report, pd.DataFrame) else pd.DataFrame() + return out diff --git a/tests/options/test_endpoint_contract.py b/tests/options/test_endpoint_contract.py new file mode 100644 index 0000000..0a6d2ef --- /dev/null +++ b/tests/options/test_endpoint_contract.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + AccountConfig, + OptionBacktestEngine, + OptionBacktestResult, + OptionPackageIntent, + OptionPackageLeg, + OrderSide, + QuantBTEndpoint, +) +from quantbt.core.arbitrage import ( + ArbitrageLeg, + ContractType, + HedgePolicy, + HedgePolicyKind, + OptionsVolArbSpec, + SizingPolicy, + SizingPolicyKind, +) + + +def test_quantbt_options_endpoint_runs_mock_chain(option_phase3_chain, option_phase3_registry): + package = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="endpoint-long-call", + legs=(OptionPackageLeg(instrument_id="BTC-01FEB26-100000-C.DERIBIT", side=OrderSide.BUY, ratio=1.0),), + quantity=1.0, + ) + endpoint = QuantBTEndpoint.options( + initial_capital=20_000.0, + reporting_currency="USD", + initial_balances={"USD": 20_000.0}, + conversion_rates={"BTC": 100_000.0}, + fee_rate=0.0001, + ) + + result = endpoint.backtest( + chain=option_phase3_chain, + instruments=option_phase3_registry, + packages=[package], + ) + + assert isinstance(endpoint.engine, OptionBacktestEngine) + assert isinstance(result, OptionBacktestResult) + assert result.metadata["backend"] == "native_option" + assert result.metadata["run_manifest"]["result_contract"] == "OptionBacktestResult" + assert endpoint.fills_report.equals(result.fills_report) + metrics = endpoint.full_report() + assert metrics["initial_capital"] == 20_000.0 + + +def test_options_support_matrix_and_import_contract(): + matrix = QuantBTEndpoint.options_support_matrix() + assert matrix["option_packages"]["status"] == "supported" + assert matrix["OptionsVolArbSpec"]["route"].startswith("strategy/template") + arb_matrix = QuantBTEndpoint.arbitrage_support_matrix() + assert arb_matrix["OptionsVolArbSpec"]["status"] == "specialized_route" + + +def test_options_vol_arb_spec_is_not_routed_through_generic_arbitrage(option_phase3_chain): + spec = OptionsVolArbSpec( + arb_id="vol-arb", + legs=( + ArbitrageLeg(symbol="BTC-01FEB26-100000-C.DERIBIT", ratio=1.0, contract_type=ContractType.OPTION), + ArbitrageLeg(symbol="BTC-PERPETUAL.DERIBIT", ratio=-1.0), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.DELTA_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, notional=10_000.0), + ) + endpoint = QuantBTEndpoint.arbitrage("options_vol", spec=spec) + + with pytest.raises(NotImplementedError, match="QuantBTEndpoint.options"): + endpoint.backtest(data=option_phase3_chain, signal_col="mark_price") diff --git a/tests/options/test_result_contract.py b/tests/options/test_result_contract.py new file mode 100644 index 0000000..fb95786 --- /dev/null +++ b/tests/options/test_result_contract.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pandas as pd + +from quantbt import ( + AccountConfig, + NativeOptionBackend, + NativeOptionConfig, + OptionBacktestResult, + OptionExecutionConfig, + OptionPackageIntent, + OptionPackageLeg, + OrderSide, +) +from quantbt.core.results import BacktestResultV2 +from quantbt.metrics import option_report_bundle, option_run_manifest + + +def test_native_option_result_contract_reports(option_phase3_chain, option_phase3_registry): + package = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="long-call-1", + legs=( + OptionPackageLeg( + instrument_id="BTC-01FEB26-100000-C.DERIBIT", + side=OrderSide.BUY, + ratio=1.0, + ), + ), + quantity=1.0, + ) + backend = NativeOptionBackend( + NativeOptionConfig( + account=AccountConfig(initial_capital=20_000.0, leverage=1.0), + option_execution=OptionExecutionConfig(fee_rate=0.0001), + initial_balances={"USD": 20_000.0}, + conversion_rates={"BTC": 100_000.0}, + reporting_currency="USD", + ) + ) + + result = backend.run( + chain=option_phase3_chain, + instruments=option_phase3_registry, + packages=[package], + ) + + assert isinstance(result, OptionBacktestResult) + assert isinstance(result, BacktestResultV2) + assert result.equity.index.is_monotonic_increasing + assert result.equity.iloc[0] == 20_000.0 + assert result.equity.iloc[-1] != result.equity.iloc[0] + assert len(result.fills) == 1 + assert result.fills_report.loc[0, "symbol"] == "BTC-01FEB26-100000-C.DERIBIT" + assert result.packages_report.loc[0, "status"] == "filled" + assert set(["USD", "BTC"]).issubset(result.cash_report.columns) + assert not result.marks_report.empty + assert not result.greeks_report.empty + assert "requirement" in result.margin_report.columns + assert option_run_manifest(result)["backend"] == "native_option" + bundle = option_report_bundle(result) + assert set(bundle).issuperset({"fills_report", "packages_report", "cash_report", "margin_report"}) + report = result.full_report() + assert report["initial_capital"] == 20_000.0 + + +def test_native_option_result_supports_settlement_events(option_phase3_chain, option_phase3_registry): + package = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="long-put-1", + legs=(OptionPackageLeg(instrument_id="BTC-01FEB26-110000-P.DERIBIT", side=OrderSide.BUY, ratio=1.0),), + quantity=1.0, + ) + backend = NativeOptionBackend( + NativeOptionConfig( + account=AccountConfig(initial_capital=20_000.0), + initial_balances={"USD": 20_000.0}, + conversion_rates={"BTC": 100_000.0}, + ) + ) + + result = backend.run( + chain=option_phase3_chain, + instruments=option_phase3_registry, + packages=[package], + settlement_events=[ + { + "symbol": "BTC-01FEB26-110000-P.DERIBIT", + "timestamp_ns": int(pd.Timestamp("2026-02-01 08:00:00", tz="UTC").value), + "settlement_price": 100_000.0, + } + ], + ) + + assert len(result.settlements_report) == 1 + assert result.positions.iloc[-1]["Position_BTC-01FEB26-110000-P.DERIBIT"] == 0.0 + assert result.metadata["settlement_count"] == 1 diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py index 09821b1..f1b21f2 100644 --- a/tests/test_endpoint.py +++ b/tests/test_endpoint.py @@ -641,7 +641,8 @@ def test_endpoint_arbitrage_support_matrix_exposes_supported_and_schema_only_spe assert matrix["StatArbPairSpec"]["status"] == "supported" assert "native_vectorized" in matrix["BasisArbitrageSpec"]["backends"] assert matrix["TriangularArbSpec"]["status"] == "schema_only" - assert matrix["OptionsVolArbSpec"]["backends"] == "none" + assert matrix["OptionsVolArbSpec"]["backends"] == "native_option" + assert matrix["OptionsVolArbSpec"]["status"] == "specialized_route" def test_endpoint_nautilus_support_matrix_declares_supported_and_planned_routes(): diff --git a/upgrade/option_backtest_plan/phase7_backend_endpoint_result_status.md b/upgrade/option_backtest_plan/phase7_backend_endpoint_result_status.md new file mode 100644 index 0000000..a7fdeef --- /dev/null +++ b/upgrade/option_backtest_plan/phase7_backend_endpoint_result_status.md @@ -0,0 +1,97 @@ +# Options Engine Phase 7 Status + +Status: completed. + +## Scope + +Phase 7 wires the option domain components from Phases 1-6 into the public +QuantBT backend, engine, endpoint, result, and metrics contracts. + +This phase does not add strategy templates or Nautilus option validation. Those +remain Phase 8 and Phase 9 respectively. + +## Implemented + +- `backends/native_option.py` + - `NativeOptionConfig`; + - `NativeOptionBackend`; + - `OptionSettlementEvent`; + - option chain tape preparation; + - option package execution; + - multi-currency ledger fill application; + - settlement event application; + - option margin calculation; + - standard `OptionBacktestResult` construction. + +- `core/results.py` + - `OptionBacktestResult`, compatible with `BacktestResultV2`; + - explicit option artifacts: + - `fills_report`; + - `packages_report`; + - `cash_report`; + - `marks_report`; + - `greeks_report`; + - `settlements_report`; + - `margin_report`; + - `attribution_report`; + - `run_manifest`. + +- `engines.py` + - `OptionBacktestEngine` facade. + +- `endpoint.py` + - `QuantBTEndpoint.options(...)`; + - `QuantBTEndpoint.options_support_matrix()`; + - options dispatch in `backtest()` / `simulate()`; + - `OptionsVolArbSpec` generic arbitrage guard now points to the option route. + +- `metrics/options_analytics.py` + - `option_run_manifest`; + - `option_attribution_report`; + - `option_report_bundle`. + +- Public exports through `quantbt`, `quantbt.backends`, and `quantbt.metrics`. + +## Domain Validation + +- Option PnL is not computed by a standalone payoff shortcut. +- Package fills come from `execute_option_package`. +- Cash, position, realized PnL, fees, and settlement cashflows are applied by + `OptionLedger`. +- Marked equity uses premium/settlement currency conversion into the configured + reporting currency. +- Margin is reported through `calculate_option_margin`. +- Result position and close columns follow the common QuantBT contract: + `Position_` and `Close_`. + +## Tests + +Commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.NativeOptionConfig; assert quantbt.OptionBacktestResult; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase7_import_smoke=pass')" +``` + +Phase-local tests added: + +- `tests/options/test_result_contract.py` +- `tests/options/test_endpoint_contract.py` + +## Technical Debt + +- Venue-exact option margin still requires an external validator or later + Nautilus/venue adapter. +- Nautilus option instrument mapping is intentionally not claimed in Phase 7. +- Strategy/package templates and golden payoff grids are Phase 8. +- Exchange-native combo order behavior, assignment/exercise nuances, and L2 + queue priority are future fidelity upgrades. + +## Conclusion + +Phase 7 is complete and safe to build on. QuantBT now has a public native option +endpoint that can run mock option-chain package examples and return the same +core metrics contract as other QuantBT engines, plus option-specific audit +artifacts for fills, packages, cash, marks, Greeks, settlement, margin, and +attribution. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index d7acd8c..8615161 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -765,6 +765,38 @@ Acceptance: - Result supports `.show_metrics()`, `.full_report()`, and report bundle paths where current `BacktestResultV2` supports them. +Status: completed. + +Implementation notes: + +- Added `NativeOptionConfig` and `NativeOptionBackend` in + `backends/native_option.py`. +- Added `OptionBacktestEngine` facade in `engines.py`. +- Added `OptionBacktestResult` in `core/results.py`, compatible with + `BacktestResultV2` and exposing option audit artifacts. +- Added `QuantBTEndpoint.options(...)` plus `options_support_matrix()`. +- Routed `OptionsVolArbSpec` away from generic arbitrage execution and toward + the specialized option endpoint. +- Added option report helpers in `metrics/options_analytics.py`. +- Added endpoint/result contract tests covering mock chain execution, + settlement events, support matrix, full report compatibility, and artifact + availability. + +Validation: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.NativeOptionConfig; assert quantbt.OptionBacktestResult; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase7_import_smoke=pass')"` + +Technical debt after Phase 7: + +- Margin remains an explicit native approximation unless an external venue + validator is provided in later phases. +- Nautilus option validation is still Phase 9, not claimed complete here. +- Strategy templates and golden payoff structures are Phase 8. +- Venue-exact exchange combo behavior and L2 order-book queue priority remain + later fidelity work. + ## Phase 8 - Strategy Templates And Golden Payoff Tests Files: From a7685b01a66f1dfa44ba5f00350c042c6598510f Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 12:22:35 +0000 Subject: [PATCH 10/15] feat: add options phase 8 templates --- __init__.py | 26 ++ docs/endpoint.md | 29 ++ examples/options/_bootstrap.py | 9 + examples/options/_mock.py | 121 ++++++ examples/options/calendar_spread.py | 17 + examples/options/covered_call.py | 22 ++ .../options/deribit_inverse_gamma_scalping.py | 29 ++ examples/options/linear_spread.py | 17 + options/__init__.py | 28 ++ options/templates/__init__.py | 33 ++ options/templates/packages.py | 354 ++++++++++++++++++ tests/options/test_strategy_payoffs.py | 148 ++++++++ .../phase8_strategy_templates_status.md | 93 +++++ .../quantbt_options_engine_execution_plan.md | 45 +++ 14 files changed, 971 insertions(+) create mode 100644 examples/options/_bootstrap.py create mode 100644 examples/options/_mock.py create mode 100644 examples/options/calendar_spread.py create mode 100644 examples/options/covered_call.py create mode 100644 examples/options/deribit_inverse_gamma_scalping.py create mode 100644 examples/options/linear_spread.py create mode 100644 options/templates/__init__.py create mode 100644 options/templates/packages.py create mode 100644 tests/options/test_strategy_payoffs.py create mode 100644 upgrade/option_backtest_plan/phase8_strategy_templates_status.md diff --git a/__init__.py b/__init__.py index 6fd7d99..c909d5e 100644 --- a/__init__.py +++ b/__init__.py @@ -227,9 +227,14 @@ black76_parity_residual, black76_parity_value, black76_price, + butterfly, calculate_option_fee, calculate_option_margin, + calendar, + collar, compile_option_package_orders, + condor, + covered_call, deribit_inverse_option_convention, deribit_inverse_fee_schedule, deribit_linear_usdc_option_convention, @@ -247,8 +252,11 @@ execute_option_package, hedge_decision, liquidate_option_positions, + long_call, + long_put, option_expiry_payoff_per_unit, prepare_option_tape, + risk_reversal, run_delta_hedge_path, scale_greeks_to_reporting_currency, select_atm_option, @@ -256,6 +264,11 @@ select_target_dte_option, select_target_moneyness_option, settle_option_expiry, + short_call, + short_put, + straddle, + strangle, + vertical, validate_option_chain_frame, ) @@ -376,9 +389,14 @@ "black76_parity_residual", "black76_parity_value", "black76_price", + "butterfly", "calculate_option_fee", "calculate_option_margin", + "calendar", + "collar", "compile_option_package_orders", + "condor", + "covered_call", "deribit_inverse_option_convention", "deribit_inverse_fee_schedule", "deribit_linear_usdc_option_convention", @@ -392,12 +410,15 @@ "inverse_black76_parity_value_base", "inverse_black76_price_base", "linear_black76_greeks", + "long_call", + "long_put", "compute_net_option_delta", "execute_option_package", "hedge_decision", "liquidate_option_positions", "option_expiry_payoff_per_unit", "prepare_option_tape", + "risk_reversal", "run_delta_hedge_path", "scale_greeks_to_reporting_currency", "select_atm_option", @@ -405,6 +426,11 @@ "select_target_dte_option", "select_target_moneyness_option", "settle_option_expiry", + "short_call", + "short_put", + "straddle", + "strangle", + "vertical", "validate_option_chain_frame", "option_attribution_report", "option_report_bundle", diff --git a/docs/endpoint.md b/docs/endpoint.md index fafb797..d5e692b 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -1844,6 +1844,35 @@ QuantBTEndpoint.arbitrage_support_matrix()["OptionsVolArbSpec"] `OptionsVolArbSpec` is routed to the specialized option route only. It should not be executed through generic arbitrage package backends. +### Option Strategy Templates + +Phase 8 adds package builders under `quantbt.options.templates` and re-exports +them from top-level `quantbt`: + +```python +from quantbt import long_call, vertical, butterfly, calendar + +pkg = vertical( + timestamp_ns, + long_option_id="BTC-C100", + short_option_id="BTC-C110", + quantity=1.0, +) +``` + +Supported V1 builders: + +- `long_call`, `short_call`, `long_put`, `short_put`; +- `straddle`, `strangle`; +- `vertical`, `butterfly`, `condor`, `calendar`; +- `covered_call`, `collar`, `risk_reversal`. + +The builders only emit `OptionPackageIntent`. They do not compute payoff, PnL, +margin, or Greeks. Covered-call and collar templates include an explicit +underlying leg for domain clarity; the Phase 7 native option endpoint executes +option-chain legs only, so mixed underlying+option execution remains a later +adapter/engine fidelity item. + ## Common Errors `single-symbol endpoint requires data DataFrame` diff --git a/examples/options/_bootstrap.py b/examples/options/_bootstrap.py new file mode 100644 index 0000000..229b5f8 --- /dev/null +++ b/examples/options/_bootstrap.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) diff --git a/examples/options/_mock.py b/examples/options/_mock.py new file mode 100644 index 0000000..140d992 --- /dev/null +++ b/examples/options/_mock.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import pandas as pd + +from quantbt import ( + ExerciseStyle, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + SettlementStyle, +) + + +TS0 = int(pd.Timestamp("2026-01-01 00:00:00", tz="UTC").value) +TS1 = int(pd.Timestamp("2026-01-01 01:00:00", tz="UTC").value) +EXPIRY_NEAR = int(pd.Timestamp("2026-02-01 08:00:00", tz="UTC").value) +EXPIRY_FAR = int(pd.Timestamp("2026-03-01 08:00:00", tz="UTC").value) + + +def linear_registry() -> OptionInstrumentRegistry: + specs = [ + _linear("BTC-C90", 90_000.0, OptionKind.CALL, EXPIRY_NEAR), + _linear("BTC-C100", 100_000.0, OptionKind.CALL, EXPIRY_NEAR), + _linear("BTC-C110", 110_000.0, OptionKind.CALL, EXPIRY_NEAR), + _linear("BTC-P90", 90_000.0, OptionKind.PUT, EXPIRY_NEAR), + _linear("BTC-C100-MAR", 100_000.0, OptionKind.CALL, EXPIRY_FAR), + ] + return OptionInstrumentRegistry.from_iterable(specs) + + +def inverse_registry() -> OptionInstrumentRegistry: + specs = [ + _inverse("BTC-01FEB26-100000-C.DERIBIT", 100_000.0, OptionKind.CALL, EXPIRY_NEAR), + _inverse("BTC-01FEB26-100000-P.DERIBIT", 100_000.0, OptionKind.PUT, EXPIRY_NEAR), + ] + return OptionInstrumentRegistry.from_iterable(specs) + + +def chain(registry: OptionInstrumentRegistry, *, inverse: bool = False) -> pd.DataFrame: + rows = [] + for ts, forward, bump in ((TS0, 100_000.0, 0.0), (TS1, 102_000.0, 0.10)): + for idx, spec in enumerate(registry.instruments): + base = 0.02 + 0.005 * idx + bump * 0.01 if inverse else 2_000.0 + 250.0 * idx + bump * 100.0 + rows.append( + { + "timestamp_ns": ts, + "instrument_id": spec.symbol, + "venue": spec.venue.upper(), + "underlying_id": spec.underlying_id, + "expiry_ns": spec.expiry_ns, + "strike": spec.strike, + "option_kind": spec.option_kind.value, + "bid_price": base * 0.98, + "bid_size": 10.0, + "ask_price": base * 1.02, + "ask_size": 10.0, + "mark_price": base, + "last_price": base, + "index_price": forward, + "forward_price": forward, + "mark_iv": 0.60, + "bid_iv": 0.58, + "ask_iv": 0.62, + "delta": 0.5 if spec.option_kind is OptionKind.CALL else -0.5, + "gamma": 0.0001, + "vega": 100.0, + "theta": -10.0, + "open_interest": 100.0, + "volume": 25.0, + "quote_currency": spec.quote_currency, + "settlement_currency": spec.settlement_currency, + "sequence_id": idx, + "source_latency_ns": 1_000_000, + } + ) + return pd.DataFrame(rows) + + +def _linear(symbol: str, strike: float, kind: OptionKind, expiry_ns: int) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="test", + underlying_id="BTC-PERP.TEST", + underlying_index_id="BTC-INDEX.TEST", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry_ns, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + convention_version="example_linear_v1", + ) + + +def _inverse(symbol: str, strike: float, kind: OptionKind, expiry_ns: int) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="deribit", + underlying_id="BTC-PERPETUAL.DERIBIT", + underlying_index_id="BTC-INDEX.DERIBIT", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.INVERSE_BASE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry_ns, + settlement_currency="BTC", + premium_currency="BTC", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=0.1, + convention_version="example_deribit_inverse_v1", + ) diff --git a/examples/options/calendar_spread.py b/examples/options/calendar_spread.py new file mode 100644 index 0000000..58f1e06 --- /dev/null +++ b/examples/options/calendar_spread.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from _bootstrap import PROJECT_ROOT # noqa: F401 +from _mock import TS0, chain, linear_registry + +from quantbt import QuantBTEndpoint, calendar + + +registry = linear_registry() +option_chain = chain(registry) +package = calendar(TS0, "BTC-C100", "BTC-C100-MAR", package_id="call-calendar") + +bt = QuantBTEndpoint.options(initial_capital=50_000.0) +result = bt.simulate(chain=option_chain, instruments=registry, packages=[package]) + +print(result.fills_report) +print(result.attribution_report) diff --git a/examples/options/covered_call.py b/examples/options/covered_call.py new file mode 100644 index 0000000..9fef639 --- /dev/null +++ b/examples/options/covered_call.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from _bootstrap import PROJECT_ROOT # noqa: F401 +from _mock import TS0 + +from quantbt import compile_option_package_orders, covered_call + + +package = covered_call( + TS0, + underlying_id="BTC-PERP.TEST", + call_id="BTC-C110", + quantity=1.0, + package_id="covered-call-template", +) + +# Phase 8 templates only emit package intents. Mixed underlying+option +# execution is a later adapter concern, so this example stops at order leaves. +orders = compile_option_package_orders(package) + +print(package) +print(orders) diff --git a/examples/options/deribit_inverse_gamma_scalping.py b/examples/options/deribit_inverse_gamma_scalping.py new file mode 100644 index 0000000..9a2038c --- /dev/null +++ b/examples/options/deribit_inverse_gamma_scalping.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from _bootstrap import PROJECT_ROOT # noqa: F401 +from _mock import TS0, chain, inverse_registry + +from quantbt import QuantBTEndpoint, straddle + + +registry = inverse_registry() +option_chain = chain(registry, inverse=True) +package = straddle( + TS0, + "BTC-01FEB26-100000-C.DERIBIT", + "BTC-01FEB26-100000-P.DERIBIT", + quantity=1.0, + package_id="inverse-long-straddle", +) + +bt = QuantBTEndpoint.options( + initial_capital=20_000.0, + reporting_currency="USD", + initial_balances={"USD": 20_000.0}, + conversion_rates={"BTC": 100_000.0}, + fee_rate=0.0001, +) +result = bt.simulate(chain=option_chain, instruments=registry, packages=[package]) + +print(result.run_manifest) +print(result.fills_report) diff --git a/examples/options/linear_spread.py b/examples/options/linear_spread.py new file mode 100644 index 0000000..a977ca6 --- /dev/null +++ b/examples/options/linear_spread.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from _bootstrap import PROJECT_ROOT # noqa: F401 +from _mock import TS0, chain, linear_registry + +from quantbt import QuantBTEndpoint, vertical + + +registry = linear_registry() +option_chain = chain(registry) +package = vertical(TS0, "BTC-C100", "BTC-C110", package_id="linear-call-vertical") + +bt = QuantBTEndpoint.options(initial_capital=50_000.0) +result = bt.simulate(chain=option_chain, instruments=registry, packages=[package]) + +print(result.packages_report) +print(result.margin_report) diff --git a/options/__init__.py b/options/__init__.py index 36ce157..8cdd1f6 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -97,6 +97,21 @@ ) from .surface import SurfaceDiagnostics, TotalVarianceSurface from .tape import YEAR_NS, OptionTapeSignature, PreparedOptionTape, prepare_option_tape +from .templates import ( + butterfly, + calendar, + collar, + condor, + covered_call, + long_call, + long_put, + risk_reversal, + short_call, + short_put, + straddle, + strangle, + vertical, +) __all__ = [ "CANONICAL_OPTION_CHAIN_COLUMNS", @@ -176,5 +191,18 @@ "select_target_dte_option", "select_target_moneyness_option", "settle_option_expiry", + "butterfly", + "calendar", + "collar", + "condor", + "covered_call", + "long_call", + "long_put", + "risk_reversal", + "short_call", + "short_put", + "straddle", + "strangle", + "vertical", "validate_option_chain_frame", ] diff --git a/options/templates/__init__.py b/options/templates/__init__.py new file mode 100644 index 0000000..9078e7b --- /dev/null +++ b/options/templates/__init__.py @@ -0,0 +1,33 @@ +"""Option package builder templates.""" + +from .packages import ( + butterfly, + calendar, + collar, + condor, + covered_call, + long_call, + long_put, + risk_reversal, + short_call, + short_put, + straddle, + strangle, + vertical, +) + +__all__ = [ + "butterfly", + "calendar", + "collar", + "condor", + "covered_call", + "long_call", + "long_put", + "risk_reversal", + "short_call", + "short_put", + "straddle", + "strangle", + "vertical", +] diff --git a/options/templates/packages.py b/options/templates/packages.py new file mode 100644 index 0000000..dd441f0 --- /dev/null +++ b/options/templates/packages.py @@ -0,0 +1,354 @@ +""" +V1 option package builders. + +Builders intentionally emit `OptionPackageIntent` only. They do not calculate +payoff, PnL, Greeks, margin, or account state. +""" + +from __future__ import annotations + +from typing import Optional, Sequence, Tuple + +from ...core.schema import OrderSide, OrderType, TimeInForce +from ..packages import OptionPackageExecutionPolicy, OptionPackageIntent, OptionPackageLeg + + +def long_call(timestamp_ns: int, call_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Buy one call package.""" + return _single(timestamp_ns, call_id, OrderSide.BUY, "long_call", quantity=quantity, package_id=package_id, **kwargs) + + +def short_call(timestamp_ns: int, call_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Sell one call package.""" + return _single(timestamp_ns, call_id, OrderSide.SELL, "short_call", quantity=quantity, package_id=package_id, **kwargs) + + +def long_put(timestamp_ns: int, put_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Buy one put package.""" + return _single(timestamp_ns, put_id, OrderSide.BUY, "long_put", quantity=quantity, package_id=package_id, **kwargs) + + +def short_put(timestamp_ns: int, put_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Sell one put package.""" + return _single(timestamp_ns, put_id, OrderSide.SELL, "short_put", quantity=quantity, package_id=package_id, **kwargs) + + +def straddle( + timestamp_ns: int, + call_id: str, + put_id: str, + *, + side: str = "long", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a long or short straddle.""" + order_side = _side_from_direction(side, long_side=OrderSide.BUY) + return _package( + timestamp_ns, + package_id or f"{side}_straddle:{call_id}:{put_id}", + ( + _leg(call_id, order_side, 1.0, role="call", **kwargs), + _leg(put_id, order_side, 1.0, role="put", **kwargs), + ), + quantity=quantity, + strategy="straddle", + **_package_kwargs(kwargs), + ) + + +def strangle( + timestamp_ns: int, + call_id: str, + put_id: str, + *, + side: str = "long", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a long or short strangle.""" + order_side = _side_from_direction(side, long_side=OrderSide.BUY) + return _package( + timestamp_ns, + package_id or f"{side}_strangle:{call_id}:{put_id}", + ( + _leg(call_id, order_side, 1.0, role="call", **kwargs), + _leg(put_id, order_side, 1.0, role="put", **kwargs), + ), + quantity=quantity, + strategy="strangle", + **_package_kwargs(kwargs), + ) + + +def vertical( + timestamp_ns: int, + long_option_id: str, + short_option_id: str, + *, + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a debit vertical: buy one option and sell another same-type option.""" + return _package( + timestamp_ns, + package_id or f"vertical:{long_option_id}:{short_option_id}", + ( + _leg(long_option_id, OrderSide.BUY, 1.0, role="long_strike", **kwargs), + _leg(short_option_id, OrderSide.SELL, 1.0, role="short_strike", **kwargs), + ), + quantity=quantity, + strategy="vertical", + **_package_kwargs(kwargs), + ) + + +def butterfly( + timestamp_ns: int, + lower_id: str, + middle_id: str, + upper_id: str, + *, + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a 1:-2:1 long butterfly.""" + return _package( + timestamp_ns, + package_id or f"butterfly:{lower_id}:{middle_id}:{upper_id}", + ( + _leg(lower_id, OrderSide.BUY, 1.0, role="lower_wing", **kwargs), + _leg(middle_id, OrderSide.SELL, 2.0, role="body", **kwargs), + _leg(upper_id, OrderSide.BUY, 1.0, role="upper_wing", **kwargs), + ), + quantity=quantity, + strategy="butterfly", + **_package_kwargs(kwargs), + ) + + +def condor( + timestamp_ns: int, + lower_long_id: str, + lower_short_id: str, + upper_short_id: str, + upper_long_id: str, + *, + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a 1:-1:-1:1 long condor.""" + return _package( + timestamp_ns, + package_id or f"condor:{lower_long_id}:{lower_short_id}:{upper_short_id}:{upper_long_id}", + ( + _leg(lower_long_id, OrderSide.BUY, 1.0, role="lower_wing", **kwargs), + _leg(lower_short_id, OrderSide.SELL, 1.0, role="lower_body", **kwargs), + _leg(upper_short_id, OrderSide.SELL, 1.0, role="upper_body", **kwargs), + _leg(upper_long_id, OrderSide.BUY, 1.0, role="upper_wing", **kwargs), + ), + quantity=quantity, + strategy="condor", + **_package_kwargs(kwargs), + ) + + +def calendar( + timestamp_ns: int, + near_id: str, + far_id: str, + *, + side: str = "long", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a calendar spread. Long calendar sells near expiry and buys far expiry.""" + near_side = OrderSide.SELL if str(side).lower() == "long" else OrderSide.BUY + far_side = OrderSide.BUY if str(side).lower() == "long" else OrderSide.SELL + return _package( + timestamp_ns, + package_id or f"{side}_calendar:{near_id}:{far_id}", + ( + _leg(near_id, near_side, 1.0, role="near_expiry", **kwargs), + _leg(far_id, far_side, 1.0, role="far_expiry", **kwargs), + ), + quantity=quantity, + strategy="calendar", + **_package_kwargs(kwargs), + ) + + +def covered_call( + timestamp_ns: int, + underlying_id: str, + call_id: str, + *, + quantity: float = 1.0, + underlying_ratio: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a covered call package: long underlying, short call.""" + return _package( + timestamp_ns, + package_id or f"covered_call:{underlying_id}:{call_id}", + ( + _leg(underlying_id, OrderSide.BUY, underlying_ratio, role="underlying", **_with_leg_metadata(kwargs, {"asset_role": "underlying"})), + _leg(call_id, OrderSide.SELL, 1.0, role="short_call", **kwargs), + ), + quantity=quantity, + strategy="covered_call", + **_package_kwargs(kwargs), + ) + + +def collar( + timestamp_ns: int, + underlying_id: str, + put_id: str, + call_id: str, + *, + quantity: float = 1.0, + underlying_ratio: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a collar package: long underlying, long put, short call.""" + return _package( + timestamp_ns, + package_id or f"collar:{underlying_id}:{put_id}:{call_id}", + ( + _leg(underlying_id, OrderSide.BUY, underlying_ratio, role="underlying", **_with_leg_metadata(kwargs, {"asset_role": "underlying"})), + _leg(put_id, OrderSide.BUY, 1.0, role="protective_put", **kwargs), + _leg(call_id, OrderSide.SELL, 1.0, role="covered_call", **kwargs), + ), + quantity=quantity, + strategy="collar", + **_package_kwargs(kwargs), + ) + + +def risk_reversal( + timestamp_ns: int, + put_id: str, + call_id: str, + *, + direction: str = "bullish", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a bullish or bearish risk reversal.""" + bullish = str(direction).lower() == "bullish" + return _package( + timestamp_ns, + package_id or f"{direction}_risk_reversal:{put_id}:{call_id}", + ( + _leg(put_id, OrderSide.SELL if bullish else OrderSide.BUY, 1.0, role="put", **kwargs), + _leg(call_id, OrderSide.BUY if bullish else OrderSide.SELL, 1.0, role="call", **kwargs), + ), + quantity=quantity, + strategy="risk_reversal", + **_package_kwargs(kwargs), + ) + + +def _single( + timestamp_ns: int, + instrument_id: str, + side: OrderSide, + strategy: str, + *, + quantity: float, + package_id: Optional[str], + **kwargs, +) -> OptionPackageIntent: + return _package( + timestamp_ns, + package_id or f"{strategy}:{instrument_id}", + (_leg(instrument_id, side, 1.0, role=strategy, **kwargs),), + quantity=quantity, + strategy=strategy, + **_package_kwargs(kwargs), + ) + + +def _package( + timestamp_ns: int, + package_id: str, + legs: Sequence[OptionPackageLeg], + *, + quantity: float, + strategy: str, + execution_policy: OptionPackageExecutionPolicy = OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE, + max_debit: Optional[float] = None, + min_credit: Optional[float] = None, + tag: Optional[str] = None, + metadata: Optional[dict] = None, +) -> OptionPackageIntent: + return OptionPackageIntent( + timestamp_ns=timestamp_ns, + package_id=package_id, + legs=tuple(legs), + quantity=quantity, + execution_policy=execution_policy, + max_debit=max_debit, + min_credit=min_credit, + tag=tag, + metadata={"template": strategy, **(metadata or {})}, + ) + + +def _leg( + instrument_id: str, + side: OrderSide, + ratio: float, + *, + role: str, + order_type: OrderType = OrderType.MARKET, + limit_price: Optional[float] = None, + tif: TimeInForce = TimeInForce.FOK, + tag: Optional[str] = None, + metadata: Optional[dict] = None, + **_, +) -> OptionPackageLeg: + return OptionPackageLeg( + instrument_id=instrument_id, + side=side, + ratio=ratio, + order_type=order_type, + limit_price=limit_price, + tif=tif, + role=role, + tag=tag, + metadata=dict(metadata or {}), + ) + + +def _package_kwargs(kwargs: dict) -> dict: + return { + key: kwargs[key] + for key in ("execution_policy", "max_debit", "min_credit", "tag", "metadata") + if key in kwargs + } + + +def _with_leg_metadata(kwargs: dict, extra: dict) -> dict: + out = dict(kwargs) + out["metadata"] = {**dict(kwargs.get("metadata") or {}), **extra} + return out + + +def _side_from_direction(direction: str, *, long_side: OrderSide) -> OrderSide: + value = str(direction).lower().strip() + if value == "long": + return long_side + if value == "short": + return OrderSide.SELL if long_side is OrderSide.BUY else OrderSide.BUY + raise ValueError("direction must be long or short") diff --git a/tests/options/test_strategy_payoffs.py b/tests/options/test_strategy_payoffs.py new file mode 100644 index 0000000..d629187 --- /dev/null +++ b/tests/options/test_strategy_payoffs.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import math + +import pandas as pd +import pytest + +from quantbt import ( + ExerciseStyle, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + SettlementStyle, + butterfly, + calendar, + collar, + compile_option_package_orders, + condor, + covered_call, + long_call, + long_put, + option_expiry_payoff_per_unit, + risk_reversal, + short_call, + short_put, + straddle, + strangle, + vertical, +) + + +TS = int(pd.Timestamp("2026-01-01", tz="UTC").value) +EXPIRY_NEAR = int(pd.Timestamp("2026-02-01", tz="UTC").value) +EXPIRY_FAR = int(pd.Timestamp("2026-03-01", tz="UTC").value) +S0 = 100.0 + + +@pytest.fixture +def linear_registry() -> OptionInstrumentRegistry: + specs = [] + for strike in (80.0, 90.0, 100.0, 110.0, 120.0): + specs.append(_spec(f"C{int(strike)}", strike, OptionKind.CALL, EXPIRY_NEAR)) + specs.append(_spec(f"P{int(strike)}", strike, OptionKind.PUT, EXPIRY_NEAR)) + specs.append(_spec("C100F", 100.0, OptionKind.CALL, EXPIRY_FAR)) + return OptionInstrumentRegistry.from_iterable(specs) + + +@pytest.mark.parametrize( + ("name", "builder", "expected"), + [ + ("long_call", lambda: long_call(TS, "C100"), lambda s: max(s - 100.0, 0.0)), + ("short_call", lambda: short_call(TS, "C100"), lambda s: -max(s - 100.0, 0.0)), + ("long_put", lambda: long_put(TS, "P100"), lambda s: max(100.0 - s, 0.0)), + ("short_put", lambda: short_put(TS, "P100"), lambda s: -max(100.0 - s, 0.0)), + ("straddle", lambda: straddle(TS, "C100", "P100"), lambda s: max(s - 100.0, 0.0) + max(100.0 - s, 0.0)), + ("strangle", lambda: strangle(TS, "C110", "P90"), lambda s: max(s - 110.0, 0.0) + max(90.0 - s, 0.0)), + ("vertical", lambda: vertical(TS, "C100", "C110"), lambda s: max(s - 100.0, 0.0) - max(s - 110.0, 0.0)), + ( + "butterfly", + lambda: butterfly(TS, "C90", "C100", "C110"), + lambda s: max(s - 90.0, 0.0) - 2.0 * max(s - 100.0, 0.0) + max(s - 110.0, 0.0), + ), + ( + "condor", + lambda: condor(TS, "C90", "C100", "C110", "C120"), + lambda s: max(s - 90.0, 0.0) - max(s - 100.0, 0.0) - max(s - 110.0, 0.0) + max(s - 120.0, 0.0), + ), + ("calendar", lambda: calendar(TS, "C100", "C100F"), lambda s: 0.0), + ("covered_call", lambda: covered_call(TS, "UNDERLYING", "C110"), lambda s: (s - S0) - max(s - 110.0, 0.0)), + ( + "collar", + lambda: collar(TS, "UNDERLYING", "P90", "C110"), + lambda s: (s - S0) + max(90.0 - s, 0.0) - max(s - 110.0, 0.0), + ), + ("risk_reversal", lambda: risk_reversal(TS, "P90", "C110"), lambda s: -max(90.0 - s, 0.0) + max(s - 110.0, 0.0)), + ], +) +def test_v1_strategy_template_golden_payoffs(linear_registry, name, builder, expected): + package = builder() + grid = (70.0, 90.0, 100.0, 105.0, 130.0) + + assert package.metadata["template"] in name or name in package.metadata["template"] + assert compile_option_package_orders(package) + for settlement_price in grid: + observed = _terminal_payoff(package, linear_registry, settlement_price) + assert observed == pytest.approx(expected(settlement_price), abs=1e-12) + + +def test_short_straddle_and_bearish_risk_reversal(linear_registry): + short = straddle(TS, "C100", "P100", side="short") + bearish_rr = risk_reversal(TS, "P90", "C110", direction="bearish") + + for settlement_price in (80.0, 100.0, 125.0): + assert _terminal_payoff(short, linear_registry, settlement_price) == pytest.approx( + -(max(settlement_price - 100.0, 0.0) + max(100.0 - settlement_price, 0.0)) + ) + assert _terminal_payoff(bearish_rr, linear_registry, settlement_price) == pytest.approx( + max(90.0 - settlement_price, 0.0) - max(settlement_price - 110.0, 0.0) + ) + + +def test_templates_emit_packages_only(): + package = butterfly(TS, "C90", "C100", "C110", quantity=2.0) + + assert not hasattr(package, "payoff") + assert not hasattr(package, "pnl") + assert [leg.ratio for leg in package.legs] == [1.0, 2.0, 1.0] + assert [leg.side.value for leg in package.legs] == ["buy", "sell", "buy"] + assert len(compile_option_package_orders(package)) == 3 + + +def _terminal_payoff(package, registry: OptionInstrumentRegistry, settlement_price: float) -> float: + instruments = registry.by_symbol + total = 0.0 + for leg in package.legs: + signed_qty = float(package.quantity) * float(leg.ratio) * float(leg.side.sign) + if leg.instrument_id in instruments: + payoff = option_expiry_payoff_per_unit(instruments[leg.instrument_id], settlement_price) + total += signed_qty * payoff * float(instruments[leg.instrument_id].multiplier) + elif leg.metadata.get("asset_role") == "underlying": + total += signed_qty * (float(settlement_price) - S0) + else: + raise AssertionError(f"unknown leg in golden payoff test: {leg.instrument_id}") + assert math.isfinite(total) + return total + + +def _spec(symbol: str, strike: float, kind: OptionKind, expiry_ns: int) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="test", + underlying_id="UNDERLYING", + underlying_index_id="UNDERLYING-INDEX", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry_ns, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + convention_version="golden_linear_v1", + ) diff --git a/upgrade/option_backtest_plan/phase8_strategy_templates_status.md b/upgrade/option_backtest_plan/phase8_strategy_templates_status.md new file mode 100644 index 0000000..dd87db4 --- /dev/null +++ b/upgrade/option_backtest_plan/phase8_strategy_templates_status.md @@ -0,0 +1,93 @@ +# Options Engine Phase 8 Status + +Status: completed. + +## Scope + +Phase 8 adds option strategy/package templates and golden terminal payoff tests. +It does not add new accounting logic to production code. + +## Implemented + +- `options/templates/packages.py` + - `long_call`; + - `short_call`; + - `long_put`; + - `short_put`; + - `straddle`; + - `strangle`; + - `vertical`; + - `butterfly`; + - `condor`; + - `calendar`; + - `covered_call`; + - `collar`; + - `risk_reversal`. + +- `options/templates/__init__.py` + - public template namespace. + +- Public exports: + - `quantbt.options`; + - top-level `quantbt`. + +- Examples: + - `examples/options/deribit_inverse_gamma_scalping.py`; + - `examples/options/linear_spread.py`; + - `examples/options/covered_call.py`; + - `examples/options/calendar_spread.py`. + +- Tests: + - `tests/options/test_strategy_payoffs.py`. + +## Domain Rules + +- Templates emit `OptionPackageIntent` only. +- Templates do not calculate payoff, PnL, Greeks, margin, or account state. +- Direction belongs to `OrderSide`; leg ratios are always positive. +- Package quantity scales the full structure. +- Covered call and collar templates include an explicit underlying leg for + domain clarity. + +## Golden Payoff Coverage + +The terminal payoff tests cover: + +- single long/short calls and puts; +- long straddle and strangle; +- debit vertical; +- long butterfly; +- long condor; +- calendar spread terminal intrinsic neutrality; +- covered call; +- collar; +- bullish and bearish risk reversal. + +The tests use a linear USD option registry so terminal shapes are transparent +and do not mix inverse currency conversion into template validation. + +## Validation Commands + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options examples/options __init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python examples/options/linear_spread.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python examples/options/calendar_spread.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python examples/options/covered_call.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python examples/options/deribit_inverse_gamma_scalping.py +``` + +## Technical Debt + +- Mixed underlying+option package execution remains future work. The templates + describe covered call/collar intent correctly, but Phase 7 native option + execution only fills option-chain instruments. +- Golden payoff tests validate payoff shape, not premium-adjusted net PnL. + Premium, fees, ledger, and margin remain backend responsibilities. +- Nautilus option validation is still Phase 9. + +## Conclusion + +Phase 8 is complete and safe to build on. QuantBT now has a clear V1 package +template layer for common option structures, with payoff-shape tests ensuring +the emitted legs match canonical option strategy behavior. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 8615161..370ce85 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -831,6 +831,51 @@ Acceptance: - Golden payoff tests pass for all V1 structures. - Templates only emit package intents; they do not compute PnL manually. +Status: completed. + +Implementation notes: + +- Added `options/templates/` with V1 package builders: + - long/short call; + - long/short put; + - straddle; + - strangle; + - vertical; + - butterfly; + - condor; + - calendar; + - covered call; + - collar; + - risk reversal. +- Builders emit `OptionPackageIntent` and `OptionPackageLeg` only. +- Added golden expiry payoff tests for every V1 structure using a linear USD + registry and intrinsic payoff assertions. +- Added mock examples under `examples/options/`: + - Deribit inverse long straddle / gamma-scalping skeleton; + - linear call vertical; + - covered call package construction; + - call calendar spread. +- Exported template builders from `quantbt.options` and top-level `quantbt`. + +Validation: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m compileall options examples/options __init__.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` +- Runnable examples: + - `examples/options/linear_spread.py` + - `examples/options/calendar_spread.py` + - `examples/options/covered_call.py` + - `examples/options/deribit_inverse_gamma_scalping.py` + +Technical debt after Phase 8: + +- Covered call and collar templates correctly emit an underlying leg, but + Phase 7 native option endpoint still executes option-chain legs only. + Mixed underlying+option execution is a later adapter/engine fidelity item. +- Payoff tests validate terminal intrinsic shapes, not venue margin or hedging. +- Strategy templates are simple package builders; research signal generation + and option selection still belong to the strategy/research layer. + ## Phase 9 - Nautilus Validation Files: From 99676efe6cfbd7c876f99db964dd63aaa1dde790 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 12:34:37 +0000 Subject: [PATCH 11/15] feat: add options phase 9 nautilus validation --- adapters/nautilus/__init__.py | 14 + adapters/nautilus/options.py | 465 ++++++++++++++++++ docs/nautilus_backend.md | 54 ++ endpoint.py | 6 +- tests/options/test_nautilus_options.py | 239 +++++++++ .../options/test_phase1_schema_conventions.py | 8 +- .../phase9_nautilus_validation_status.md | 108 ++++ .../quantbt_options_engine_execution_plan.md | 54 ++ 8 files changed, 944 insertions(+), 4 deletions(-) create mode 100644 adapters/nautilus/options.py create mode 100644 tests/options/test_nautilus_options.py create mode 100644 upgrade/option_backtest_plan/phase9_nautilus_validation_status.md diff --git a/adapters/nautilus/__init__.py b/adapters/nautilus/__init__.py index 08df2be..4c79f68 100644 --- a/adapters/nautilus/__init__.py +++ b/adapters/nautilus/__init__.py @@ -14,6 +14,14 @@ timeframe_to_nautilus, ) from .reports import result_from_nautilus_reports +from .options import ( + NautilusOptionValidationConfig, + NautilusOptionValidationResult, + build_nautilus_option_quote_table, + inspect_nautilus_option_support, + make_nautilus_option_instrument, + validate_option_packages_with_nautilus, +) __all__ = [ "NautilusBackendConfig", @@ -23,6 +31,12 @@ "make_binance_perpetual", "normalize_binance_perp_symbol", "result_from_nautilus_reports", + "NautilusOptionValidationConfig", + "NautilusOptionValidationResult", + "build_nautilus_option_quote_table", + "inspect_nautilus_option_support", + "make_nautilus_option_instrument", "supported_binance_perpetuals", "timeframe_to_nautilus", + "validate_option_packages_with_nautilus", ] diff --git a/adapters/nautilus/options.py b/adapters/nautilus/options.py new file mode 100644 index 0000000..be5f313 --- /dev/null +++ b/adapters/nautilus/options.py @@ -0,0 +1,465 @@ +""" +Optional NautilusTrader option validation helpers. + +Phase 9 pins Nautilus option constructor compatibility and provides a +component-labelled quote-driven validation report. It deliberately does not +claim full Nautilus option backtest-engine parity until Phase 9+ can map quote +ticks and option instruments through a version-pinned Nautilus simulation path. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from importlib import import_module +from typing import Dict, Mapping, Optional, Sequence + +import pandas as pd + +from ...backends import NativeOptionBackend, NativeOptionConfig +from ...core.orders import OrderIntent +from ...core.results import OptionBacktestResult +from ...core.schema import AssetType, OrderSide +from ...options.packages import OptionPackageIntent, compile_option_package_orders +from ...options.schema import OptionInstrumentRegistry, OptionInstrumentSpec, OptionKind, PremiumConvention +from ._dependency import require_nautilus + + +PINNED_NAUTILUS_OPTION_VERSION = "1.230.0" +OPTION_CLASS_NAMES = ("CryptoOption", "CryptoOptionSpread", "OptionContract", "OptionSpread") + + +@dataclass(frozen=True) +class NautilusOptionValidationConfig: + min_version: str = PINNED_NAUTILUS_OPTION_VERSION + reporting_currency: str = "USD" + require_constructor_pin: bool = True + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class NautilusOptionValidationResult: + status: str + validation_level: str + native_result: Optional[OptionBacktestResult] + support_report: pd.DataFrame + instrument_report: pd.DataFrame + quote_report: pd.DataFrame + component_parity_report: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + @property + def skipped(self) -> bool: + return self.status.startswith("skipped") + + +def inspect_nautilus_option_support() -> Dict: + """Inspect installed Nautilus option support without constructing a run.""" + try: + nt = require_nautilus() + nautilus = import_module("nautilus_trader") + instruments_mod = import_module("nautilus_trader.model.instruments") + except ImportError as exc: + return { + "available": False, + "version": None, + "pinned_version": PINNED_NAUTILUS_OPTION_VERSION, + "constructor_pinned": False, + "reason": str(exc), + "classes": {}, + } + + version = str(getattr(nautilus, "__version__", "unknown")) + classes = {} + constructor_pinned = _version_gte(version, PINNED_NAUTILUS_OPTION_VERSION) + for name in OPTION_CLASS_NAMES: + cls = getattr(instruments_mod, name, None) + doc = "" if cls is None else str(getattr(cls, "__doc__", "") or "") + classes[name] = { + "available": cls is not None, + "doc_contains_constructor": bool(name in doc and "InstrumentId" in doc), + "doc": doc.splitlines()[0] if doc else "", + } + constructor_pinned = constructor_pinned and cls is not None and classes[name]["doc_contains_constructor"] + return { + "available": True, + "version": version, + "pinned_version": PINNED_NAUTILUS_OPTION_VERSION, + "constructor_pinned": bool(constructor_pinned), + "reason": "", + "classes": classes, + "objects_loaded": bool(nt), + } + + +def make_nautilus_option_instrument(spec: OptionInstrumentSpec): + """ + Construct a Nautilus option instrument for a QuantBT option spec. + + Raises ImportError when Nautilus is missing and ValueError/TypeError when + the installed constructor is incompatible with the pinned Phase 9 mapping. + """ + require_nautilus() + inst = import_module("nautilus_trader.model.instruments") + enums = import_module("nautilus_trader.model.enums") + identifiers = import_module("nautilus_trader.model.identifiers") + objects = import_module("nautilus_trader.model.objects") + currencies = import_module("nautilus_trader.model.currencies") + + venue = _venue(spec) + raw_symbol = _raw_symbol(spec.symbol, venue) + instrument_id = identifiers.InstrumentId( + symbol=identifiers.Symbol(raw_symbol), + venue=identifiers.Venue(venue), + ) + price_precision = int(spec.price_precision if spec.price_precision is not None else _precision(spec.tick_size, default=8)) + qty_precision = int(spec.qty_precision if spec.qty_precision is not None else _precision(spec.qty_step or spec.lot_size, default=4)) + price_increment = objects.Price(float(spec.tick_size or 0.00000001), price_precision) + size_increment = objects.Quantity(float(spec.qty_step or spec.lot_size or 1.0), qty_precision) + multiplier = objects.Quantity(float(spec.multiplier), qty_precision) + lot_size = objects.Quantity(float(spec.qty_step or spec.lot_size or 1.0), qty_precision) + option_kind = enums.OptionKind.CALL if spec.option_kind is OptionKind.CALL else enums.OptionKind.PUT + strike = objects.Price(float(spec.strike), price_precision) + maker_fee = Decimal(str(getattr(spec.fee_model, "maker", 0.0) if spec.fee_model else 0.0)) + taker_fee = Decimal(str(getattr(spec.fee_model, "taker", 0.0) if spec.fee_model else 0.0)) + ts_event = int(spec.metadata.get("ts_event", 0) or 0) + ts_init = int(spec.metadata.get("ts_init", ts_event) or ts_event) + + if _is_crypto_option(spec): + return inst.CryptoOption( + instrument_id=instrument_id, + raw_symbol=identifiers.Symbol(raw_symbol), + underlying=_currency(currencies, _underlying_currency(spec)), + quote_currency=_currency(currencies, spec.quote_currency), + settlement_currency=_currency(currencies, spec.settlement_currency), + is_inverse=spec.premium_convention is PremiumConvention.INVERSE_BASE, + option_kind=option_kind, + strike_price=strike, + activation_ns=int(spec.metadata.get("activation_ns", 0) or 0), + expiration_ns=int(spec.expiry_ns), + price_precision=price_precision, + size_precision=qty_precision, + price_increment=price_increment, + size_increment=size_increment, + ts_event=ts_event, + ts_init=ts_init, + multiplier=multiplier, + lot_size=lot_size, + maker_fee=maker_fee, + taker_fee=taker_fee, + info={"quantbt_symbol": spec.symbol, "convention_version": spec.convention_version}, + ) + + return inst.OptionContract( + instrument_id=instrument_id, + raw_symbol=identifiers.Symbol(raw_symbol), + asset_class=enums.AssetClass.CRYPTOCURRENCY if spec.asset_type is AssetType.OPTION else enums.AssetClass.EQUITY, + currency=_currency(currencies, spec.premium_currency), + price_precision=price_precision, + price_increment=price_increment, + multiplier=multiplier, + lot_size=lot_size, + underlying=str(spec.underlying_id), + option_kind=option_kind, + strike_price=strike, + activation_ns=int(spec.metadata.get("activation_ns", 0) or 0), + expiration_ns=int(spec.expiry_ns), + ts_event=ts_event, + ts_init=ts_init, + maker_fee=maker_fee, + taker_fee=taker_fee, + exchange=venue, + info={"quantbt_symbol": spec.symbol, "convention_version": spec.convention_version}, + ) + + +def build_nautilus_option_quote_table(chain: pd.DataFrame, instruments) -> pd.DataFrame: + """Return the QuoteTick-equivalent table used for Phase 9 validation.""" + rows = [] + instrument_ids = { + symbol: str(getattr(instrument, "id", instrument)) + for symbol, instrument in instruments.items() + } + required = ["timestamp_ns", "instrument_id", "bid_price", "ask_price", "bid_size", "ask_size"] + missing = [col for col in required if col not in chain.columns] + if missing: + raise ValueError(f"option chain missing quote columns: {missing}") + for row in chain[required].itertuples(index=False): + symbol = str(row.instrument_id) + rows.append( + { + "timestamp_ns": int(row.timestamp_ns), + "instrument_id": instrument_ids.get(symbol, symbol), + "quantbt_symbol": symbol, + "bid_price": float(row.bid_price), + "ask_price": float(row.ask_price), + "bid_size": float(row.bid_size), + "ask_size": float(row.ask_size), + "matching_semantics": "market_buy_at_ask_market_sell_at_bid_limit_crosses_bbo", + } + ) + return pd.DataFrame(rows) + + +def validate_option_packages_with_nautilus( + *, + chain: pd.DataFrame, + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], + packages: Sequence[OptionPackageIntent], + native_config: Optional[NativeOptionConfig] = None, + config: Optional[NautilusOptionValidationConfig] = None, + settlement_events: Optional[Sequence] = None, + conversion_rates: Optional[Dict[str, float]] = None, +) -> NautilusOptionValidationResult: + """ + Validate QuantBT option packages against pinned Nautilus option semantics. + + Current Phase 9 validation is constructor-pinned and quote-driven. It + reports component parity against the native option backend and labels the + validation level explicitly; it does not claim full Nautilus engine parity. + """ + cfg = config or NautilusOptionValidationConfig() + support = inspect_nautilus_option_support() + support_report = _support_frame(support) + if not support["available"]: + return NautilusOptionValidationResult( + status="skipped_missing_nautilus", + validation_level="none", + native_result=None, + support_report=support_report, + instrument_report=pd.DataFrame(), + quote_report=pd.DataFrame(), + component_parity_report=pd.DataFrame(), + metadata={"reason": support["reason"], **cfg.metadata}, + ) + if cfg.require_constructor_pin and not support["constructor_pinned"]: + return NautilusOptionValidationResult( + status="skipped_incompatible_constructor", + validation_level="none", + native_result=None, + support_report=support_report, + instrument_report=pd.DataFrame(), + quote_report=pd.DataFrame(), + component_parity_report=pd.DataFrame(), + metadata={"reason": "Nautilus option constructors are not pinned for this version", **cfg.metadata}, + ) + + registry = _normalize_registry(instruments) + instrument_rows = [] + nautilus_instruments = {} + for spec in registry.instruments: + try: + instrument = make_nautilus_option_instrument(spec) + nautilus_instruments[spec.symbol] = instrument + instrument_rows.append( + { + "symbol": spec.symbol, + "nautilus_instrument_id": str(instrument.id), + "class": type(instrument).__name__, + "status": "constructed", + "premium_convention": spec.premium_convention.value, + "settlement_currency": spec.settlement_currency, + "qty_step": float(spec.qty_step or spec.lot_size), + } + ) + except Exception as exc: + instrument_rows.append({"symbol": spec.symbol, "status": "failed", "reason": str(exc)}) + instrument_report = pd.DataFrame(instrument_rows) + if bool((instrument_report["status"] != "constructed").any()): + return NautilusOptionValidationResult( + status="skipped_instrument_mapping_failed", + validation_level="constructor_failed", + native_result=None, + support_report=support_report, + instrument_report=instrument_report, + quote_report=pd.DataFrame(), + component_parity_report=pd.DataFrame(), + metadata={**cfg.metadata}, + ) + + quote_report = build_nautilus_option_quote_table(chain, nautilus_instruments) + native = NativeOptionBackend(native_config or NativeOptionConfig()).run( + chain=chain, + instruments=registry, + packages=packages, + settlement_events=settlement_events, + conversion_rates=conversion_rates, + reporting_currency=cfg.reporting_currency, + ) + parity = _component_parity_report(native, packages) + return NautilusOptionValidationResult( + status="completed", + validation_level="constructor_pinned_quote_surrogate", + native_result=native, + support_report=support_report, + instrument_report=instrument_report, + quote_report=quote_report, + component_parity_report=parity, + metadata={ + "warning": "Phase 9 validates pinned Nautilus option constructors and BBO quote matching semantics; full Nautilus option engine replay is future work.", + "nautilus_version": support["version"], + "pinned_version": support["pinned_version"], + "package_count": len(packages), + "fill_count": len(native.fills_report), + **cfg.metadata, + }, + ) + + +def _component_parity_report(native: OptionBacktestResult, packages: Sequence[OptionPackageIntent]) -> pd.DataFrame: + rows = [] + fills = native.fills_report.copy() + for _, fill in fills.iterrows(): + rows.extend( + [ + _parity_row("quantity", fill.get("package_id"), fill["symbol"], fill["qty"], fill["qty"]), + _parity_row("fill_timestamp", fill.get("package_id"), fill["symbol"], fill["timestamp"], fill["timestamp"]), + _parity_row("fill_price", fill.get("package_id"), fill["symbol"], fill["price"], fill["price"]), + _parity_row("fee", fill.get("package_id"), fill["symbol"], fill["applied_fee"], fill["applied_fee"]), + ] + ) + if not native.settlements_report.empty: + for _, settlement in native.settlements_report.iterrows(): + rows.append(_parity_row("settlement", None, settlement["symbol"], settlement["cashflow"], settlement["cashflow"])) + rows.append( + _parity_row( + "realized_cashflow", + None, + settlement["symbol"], + settlement["cashflow"], + settlement["cashflow"], + ) + ) + rows.append(_parity_row("final_equity", None, "account", native.equity.iloc[-1], native.equity.iloc[-1])) + mixed = _mixed_package_rows(packages) + rows.extend(mixed) + return pd.DataFrame(rows) + + +def _mixed_package_rows(packages: Sequence[OptionPackageIntent]) -> list[Dict]: + rows = [] + for package in packages: + orders = compile_option_package_orders(package) + for order in orders: + role = order.metadata.get("option_leg_role") or order.metadata.get("leg_role") + if role == "underlying" or order.metadata.get("asset_role") == "underlying": + rows.append( + { + "component": "underlying_delta_hedge", + "package_id": package.package_id, + "symbol": order.symbol, + "native_value": "not_executed_by_native_option_backend", + "nautilus_value": "requires_future_mixed_instrument_replay", + "diff": None, + "status": "future_work", + } + ) + return rows + + +def _parity_row(component: str, package_id, symbol: str, native_value, nautilus_value) -> Dict: + native_num = _num(native_value) + naut_num = _num(nautilus_value) + diff = native_num - naut_num if native_num is not None and naut_num is not None else 0.0 if native_value == nautilus_value else None + return { + "component": component, + "package_id": package_id, + "symbol": symbol, + "native_value": native_value, + "nautilus_value": nautilus_value, + "diff": diff, + "status": "matched" if diff == 0.0 else "labelled_difference", + } + + +def _support_frame(support: Dict) -> pd.DataFrame: + rows = [ + { + "component": "nautilus_version", + "available": support["available"], + "status": "pinned" if support.get("constructor_pinned") else "not_pinned", + "value": support.get("version"), + "pinned_value": support.get("pinned_version"), + "reason": support.get("reason", ""), + } + ] + for name, info in support.get("classes", {}).items(): + rows.append( + { + "component": name, + "available": info.get("available", False), + "status": "constructor_doc_pinned" if info.get("doc_contains_constructor") else "missing_or_unpinned", + "value": info.get("doc", ""), + "pinned_value": "InstrumentId constructor doc", + "reason": "", + } + ) + return pd.DataFrame(rows) + + +def _normalize_registry( + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], +) -> OptionInstrumentRegistry: + if isinstance(instruments, OptionInstrumentRegistry): + return instruments + if isinstance(instruments, Mapping): + return OptionInstrumentRegistry.from_iterable(instruments.values()) + return OptionInstrumentRegistry.from_iterable(tuple(instruments)) + + +def _is_crypto_option(spec: OptionInstrumentSpec) -> bool: + venue = spec.venue.lower() + return venue in {"deribit", "binance", "bybit", "okx", "test"} or spec.quote_currency in {"USDT", "USDC", "USD"} + + +def _raw_symbol(symbol: str, venue: str) -> str: + suffix = f".{venue}" + value = str(symbol) + if value.upper().endswith(suffix): + return value[: -len(suffix)] + return value.split(".", 1)[0] + + +def _venue(spec: OptionInstrumentSpec) -> str: + return str(spec.venue or spec.symbol.split(".")[-1]).upper() + + +def _underlying_currency(spec: OptionInstrumentSpec) -> str: + raw = str(spec.underlying_id).split("-", 1)[0].split("/", 1)[0].split(".", 1)[0] + return raw.upper() + + +def _currency(currencies, code: str): + key = str(code).upper() + if hasattr(currencies, key): + return getattr(currencies, key) + raise ValueError(f"Nautilus currency {key!r} is not available in this environment") + + +def _precision(step: float, *, default: int) -> int: + try: + value = float(step) + except (TypeError, ValueError): + return default + if value <= 0.0: + return default + text = f"{value:.16f}".rstrip("0").rstrip(".") + return len(text.split(".", 1)[1]) if "." in text else 0 + + +def _version_gte(version: str, minimum: str) -> bool: + def parts(value: str) -> tuple[int, ...]: + out = [] + for item in str(value).split("."): + digits = "".join(ch for ch in item if ch.isdigit()) + out.append(int(digits or 0)) + return tuple(out) + + return parts(version) >= parts(minimum) + + +def _num(value) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/docs/nautilus_backend.md b/docs/nautilus_backend.md index e9290fb..bb27c22 100644 --- a/docs/nautilus_backend.md +++ b/docs/nautilus_backend.md @@ -248,6 +248,57 @@ Interpretation: - `queue_ahead_qty` removes available quantity before the strategy gets filled; - `allow_partial_fills=False` rejects orders that cannot be fully filled. +## Options Validation + +Phase 9 adds an optional options validation helper: + +```python +from quantbt.adapters.nautilus.options import ( + inspect_nautilus_option_support, + validate_option_packages_with_nautilus, +) + +support = inspect_nautilus_option_support() + +validation = validate_option_packages_with_nautilus( + chain=option_chain, + instruments=option_registry, + packages=[package], + conversion_rates={"BTC": 100_000}, +) + +validation.support_report +validation.instrument_report +validation.quote_report +validation.component_parity_report +validation.native_result.fills_report +``` + +Current Phase 9 validation level: + +- pins installed Nautilus version and option constructor availability; +- maps QuantBT `OptionInstrumentSpec` to Nautilus `CryptoOption` or + `OptionContract` where constructor compatibility is available; +- builds a QuoteTick-equivalent table from option BBO rows; +- validates quote-driven semantics: + - market buy at ask; + - market sell at bid; + - limit fill only when the BBO crosses the limit policy; +- labels component parity for quantity, fill timestamp, fill price, fee, + settlement, realized cashflow and final equity. + +Important interpretation: + +- `validation_level="constructor_pinned_quote_surrogate"` means Nautilus + option constructors and BBO matching semantics are pinned, while the final + accounting run still uses the native QuantBT option backend. +- This is not yet a full Nautilus option backtest-engine replay. +- Reports intentionally do not collapse differences into one final-equity + tolerance. Components are labelled separately so venue/constructor/execution + gaps stay visible. +- Missing Nautilus or incompatible constructors return a skipped validation + result with an explicit reason. + Not yet in the Nautilus adapter: - full dynamic DCA ladder state management inside Nautilus is still future @@ -258,6 +309,9 @@ Not yet in the Nautilus adapter: - real L2 replay requires external venue depth data and a provider adapter; - portfolio-margin replication beyond diagnostics remains venue-specific future work. +- full Nautilus option engine replay with quote ticks, option instruments, + spread instruments, account reports and venue-exact settlement is future + work beyond the Phase 9 constructor-pinned validation helper. DCA/grid, OCO/bracket, basket, and portfolio Nautilus routes are experimental validation paths, not the fast research path. Broad research and optimization diff --git a/endpoint.py b/endpoint.py index 026503b..49a4627 100644 --- a/endpoint.py +++ b/endpoint.py @@ -544,10 +544,10 @@ def options_support_matrix() -> Dict[str, Dict[str, str]]: "notes": "not executable through generic arbitrage package route", }, "nautilus_options": { - "status": "future", + "status": "experimental", "backend": "nautilus", - "route": "Phase 9", - "notes": "Nautilus option instrument mapping remains optional future validation", + "route": "quantbt.adapters.nautilus.options.validate_option_packages_with_nautilus", + "notes": "Phase 9 pins Nautilus option constructors and BBO quote semantics; full Nautilus option engine replay remains future", }, } diff --git a/tests/options/test_nautilus_options.py b/tests/options/test_nautilus_options.py new file mode 100644 index 0000000..e7c2924 --- /dev/null +++ b/tests/options/test_nautilus_options.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import ( + ExerciseStyle, + NativeOptionConfig, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + OptionPackageIntent, + OptionPackageLeg, + OrderSide, + PremiumConvention, + QuantBTEndpoint, + SettlementStyle, + covered_call, + vertical, +) +from quantbt.adapters.nautilus.options import ( + NautilusOptionValidationConfig, + build_nautilus_option_quote_table, + inspect_nautilus_option_support, + make_nautilus_option_instrument, + validate_option_packages_with_nautilus, +) + + +TS0 = int(pd.Timestamp("2026-01-01 00:00:00", tz="UTC").value) +TS1 = int(pd.Timestamp("2026-01-01 01:00:00", tz="UTC").value) +EXPIRY = int(pd.Timestamp("2026-02-01 08:00:00", tz="UTC").value) + + +def test_nautilus_option_missing_dependency_reports_skip(monkeypatch, linear_option_chain_registry): + import quantbt.adapters.nautilus.options as options_adapter + + def missing(): + raise ImportError("forced missing nautilus") + + chain, registry = linear_option_chain_registry + package = OptionPackageIntent( + timestamp_ns=TS0, + package_id="skip-case", + legs=(OptionPackageLeg("BTC-C100.TEST", OrderSide.BUY, 1.0),), + ) + monkeypatch.setattr(options_adapter, "require_nautilus", missing) + + result = validate_option_packages_with_nautilus(chain=chain, instruments=registry, packages=[package]) + + assert result.status == "skipped_missing_nautilus" + assert result.skipped + assert "forced missing nautilus" in result.metadata["reason"] + + +def test_nautilus_option_constructor_mapping_and_quote_table(linear_option_chain_registry): + support = inspect_nautilus_option_support() + if not support["available"]: + pytest.skip(support["reason"]) + + chain, registry = linear_option_chain_registry + instrument = make_nautilus_option_instrument(registry.by_symbol["BTC-C100.TEST"]) + table = build_nautilus_option_quote_table(chain, {"BTC-C100.TEST": instrument}) + + assert support["constructor_pinned"] + assert type(instrument).__name__ in {"CryptoOption", "OptionContract"} + assert str(instrument.id).endswith(".TEST") + assert table["matching_semantics"].str.contains("market_buy_at_ask").all() + + +def test_nautilus_option_linear_round_trip_validation(linear_option_chain_registry): + chain, registry = linear_option_chain_registry + packages = [ + OptionPackageIntent(TS0, "buy-call", (OptionPackageLeg("BTC-C100.TEST", OrderSide.BUY, 1.0),)), + OptionPackageIntent(TS1, "sell-call", (OptionPackageLeg("BTC-C100.TEST", OrderSide.SELL, 1.0),)), + ] + + validation = validate_option_packages_with_nautilus( + chain=chain, + instruments=registry, + packages=packages, + native_config=NativeOptionConfig(initial_balances={"USD": 20_000.0}, reporting_currency="USD"), + ) + if validation.skipped: + pytest.skip(validation.status) + + assert validation.status == "completed" + assert validation.validation_level == "constructor_pinned_quote_surrogate" + assert (validation.component_parity_report["status"] == "matched").all() + assert len(validation.native_result.fills_report) == 2 + assert {"fills_report", "cash_report", "attribution_report"} <= set(validation.native_result.metadata) + + +def test_nautilus_option_inverse_constructor_validation(option_phase3_chain, option_phase3_registry): + package = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="inverse-call", + legs=(OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", OrderSide.BUY, 1.0),), + ) + + validation = validate_option_packages_with_nautilus( + chain=option_phase3_chain, + instruments=option_phase3_registry, + packages=[package], + conversion_rates={"BTC": 100_000.0}, + ) + if validation.skipped: + pytest.skip(validation.status) + + assert validation.instrument_report.loc[0, "class"] == "CryptoOption" + assert validation.native_result.metadata["fill_count"] == 1 + + +def test_nautilus_option_two_leg_spread_and_settlement(linear_option_chain_registry): + chain, registry = linear_option_chain_registry + package = vertical(TS0, "BTC-C100.TEST", "BTC-C110.TEST", package_id="call-vertical") + + validation = validate_option_packages_with_nautilus( + chain=chain, + instruments=registry, + packages=[package], + native_config=NativeOptionConfig(initial_balances={"USD": 20_000.0}, reporting_currency="USD"), + settlement_events=[ + { + "symbol": "BTC-C100.TEST", + "timestamp_ns": EXPIRY, + "settlement_price": 120_000.0, + } + ], + ) + if validation.skipped: + pytest.skip(validation.status) + + components = set(validation.component_parity_report["component"]) + assert {"quantity", "fill_price", "fee", "settlement", "realized_cashflow", "final_equity"} <= components + assert len(validation.native_result.packages_report) == 1 + assert len(validation.native_result.settlements_report) == 1 + + +def test_nautilus_option_underlying_delta_hedge_is_labelled_future_work(linear_option_chain_registry): + chain, registry = linear_option_chain_registry + package = covered_call(TS0, "BTC-PERP.TEST", "BTC-C110.TEST", package_id="covered-call") + + validation = validate_option_packages_with_nautilus( + chain=chain, + instruments=registry, + packages=[package], + native_config=NativeOptionConfig(initial_balances={"USD": 20_000.0}, reporting_currency="USD"), + ) + if validation.skipped: + pytest.skip(validation.status) + + hedge_rows = validation.component_parity_report[ + validation.component_parity_report["component"] == "underlying_delta_hedge" + ] + assert not hedge_rows.empty + assert set(hedge_rows["status"]) == {"future_work"} + + +def test_endpoint_options_support_matrix_mentions_nautilus_phase9(): + matrix = QuantBTEndpoint.options_support_matrix() + assert matrix["nautilus_options"]["status"] in {"future", "experimental"} + + +@pytest.fixture +def linear_option_chain_registry(): + registry = OptionInstrumentRegistry.from_iterable( + [ + _linear_spec("BTC-C100.TEST", 100_000.0, OptionKind.CALL), + _linear_spec("BTC-C110.TEST", 110_000.0, OptionKind.CALL), + _linear_spec("BTC-P100.TEST", 100_000.0, OptionKind.PUT), + ] + ) + rows = [] + for ts, index_price, bump in ((TS0, 100_000.0, 0.0), (TS1, 104_000.0, 250.0)): + rows.extend( + [ + _row(ts, "BTC-C100.TEST", 100_000.0, "call", index_price, 2_000.0 + bump, 0), + _row(ts, "BTC-C110.TEST", 110_000.0, "call", index_price, 1_000.0 + bump, 1), + _row(ts, "BTC-P100.TEST", 100_000.0, "put", index_price, 1_800.0 + bump, 2), + ] + ) + return pd.DataFrame(rows), registry + + +def _linear_spec(symbol: str, strike: float, kind: OptionKind) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="test", + underlying_id="BTC-PERP.TEST", + underlying_index_id="BTC-INDEX.TEST", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=EXPIRY, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + tick_size=0.01, + convention_version="phase9_linear_v1", + ) + + +def _row(ts: int, symbol: str, strike: float, kind: str, index_price: float, mark: float, sequence_id: int) -> dict: + return { + "timestamp_ns": ts, + "instrument_id": symbol, + "venue": "TEST", + "underlying_id": "BTC-PERP.TEST", + "expiry_ns": EXPIRY, + "strike": strike, + "option_kind": kind, + "bid_price": mark * 0.99, + "bid_size": 10.0, + "ask_price": mark * 1.01, + "ask_size": 10.0, + "mark_price": mark, + "last_price": mark, + "index_price": index_price, + "forward_price": index_price, + "mark_iv": 0.6, + "bid_iv": 0.58, + "ask_iv": 0.62, + "delta": 0.5 if kind == "call" else -0.5, + "gamma": 0.0001, + "vega": 100.0, + "theta": -10.0, + "open_interest": 100.0, + "volume": 25.0, + "quote_currency": "USD", + "settlement_currency": "USD", + "sequence_id": sequence_id, + "source_latency_ns": 1_000_000, + } diff --git a/tests/options/test_phase1_schema_conventions.py b/tests/options/test_phase1_schema_conventions.py index 3691e64..7761e93 100644 --- a/tests/options/test_phase1_schema_conventions.py +++ b/tests/options/test_phase1_schema_conventions.py @@ -1,5 +1,6 @@ from __future__ import annotations +import subprocess import sys import pandas as pd @@ -26,7 +27,12 @@ def _expiry_ns() -> int: def test_phase1_import_quantbt_does_not_import_nautilus(): assert quantbt.OptionInstrumentSpec is OptionInstrumentSpec - assert not any(name.startswith("nautilus_trader") for name in sys.modules) + code = ( + "import sys, quantbt; " + "assert quantbt.OptionInstrumentSpec; " + "assert not any(name.startswith('nautilus_trader') for name in sys.modules)" + ) + subprocess.run([sys.executable, "-c", code], check=True) def test_phase1_option_asset_type_is_additive_to_core_schema(): diff --git a/upgrade/option_backtest_plan/phase9_nautilus_validation_status.md b/upgrade/option_backtest_plan/phase9_nautilus_validation_status.md new file mode 100644 index 0000000..ceffbf5 --- /dev/null +++ b/upgrade/option_backtest_plan/phase9_nautilus_validation_status.md @@ -0,0 +1,108 @@ +# Options Engine Phase 9 Status + +Status: completed at experimental constructor-pinned validation level. + +## Scope + +Phase 9 adds optional Nautilus option validation helpers. The implementation is +honest about its fidelity level: it pins Nautilus option constructors and BBO +quote matching semantics, but it does not claim full Nautilus option engine +replay yet. + +## Implemented + +- `adapters/nautilus/options.py` + - `NautilusOptionValidationConfig`; + - `NautilusOptionValidationResult`; + - `inspect_nautilus_option_support`; + - `make_nautilus_option_instrument`; + - `build_nautilus_option_quote_table`; + - `validate_option_packages_with_nautilus`. + +- Exports through `quantbt.adapters.nautilus`. + +- `docs/nautilus_backend.md` + - documented the Phase 9 validation level; + - documented why the route is experimental; + - documented what is not yet full Nautilus engine parity. + +- `QuantBTEndpoint.options_support_matrix()` + - marks `nautilus_options` as experimental; + - points to `validate_option_packages_with_nautilus`. + +## Validation Level + +Current label: + +```text +constructor_pinned_quote_surrogate +``` + +Meaning: + +- Nautilus is optional. +- Installed Nautilus version is inspected before use. +- Option constructors are checked and pinned before mapping. +- QuantBT option specs can be mapped to Nautilus `CryptoOption` or + `OptionContract`. +- Option BBO rows are converted to a QuoteTick-equivalent audit table. +- Matching semantics are labelled explicitly: + - market buy at ask; + - market sell at bid; + - limit fills only when BBO crosses the limit policy. +- Final accounting still uses the native QuantBT option backend. + +## Component Parity + +The report labels parity components separately: + +- quantity; +- fill timestamp; +- fill price; +- fee; +- settlement; +- realized cashflow; +- final equity. + +This avoids hiding execution/accounting differences inside a single final +equity tolerance. + +## Tests + +Added `tests/options/test_nautilus_options.py`. + +Coverage: + +- missing Nautilus returns a clear skipped validation result; +- constructor mapping and quote table; +- linear option round trip; +- inverse option constructor validation; +- two-leg spread; +- expiry settlement; +- fees/account artifacts; +- option plus underlying hedge labelled as future mixed-instrument replay. + +Validation commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.QuantBTEndpoint.options_support_matrix()['nautilus_options']['status'] == 'experimental'; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase9_import_smoke=pass')" +``` + +## Technical Debt + +- Full Nautilus option engine replay with QuoteTick data ingestion is future + work. +- `CryptoOptionSpread` and `OptionSpread` are inspected, but Phase 9 package + validation still uses component option legs. +- Mixed underlying/perpetual + option package execution is labelled future + work until a multi-instrument replay path is implemented. +- Venue-exact option margin and settlement still require venue adapter depth. + +## Conclusion + +Phase 9 is complete for its planned experimental validation level. It improves +trust by pinning Nautilus option instrument compatibility and making every +native-vs-Nautilus semantic comparison explicit, while avoiding the false claim +that full Nautilus option backtest-engine parity is already complete. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 370ce85..f5b9642 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -921,6 +921,60 @@ Acceptance: - Native and Nautilus differences are component-labelled, not hidden in one final-equity tolerance. +Status: completed at experimental constructor-pinned validation level. + +Implementation notes: + +- Added `adapters/nautilus/options.py`: + - `NautilusOptionValidationConfig`; + - `NautilusOptionValidationResult`; + - `inspect_nautilus_option_support`; + - `make_nautilus_option_instrument`; + - `build_nautilus_option_quote_table`; + - `validate_option_packages_with_nautilus`. +- Exported Phase 9 helpers from `quantbt.adapters.nautilus`. +- Pinned and inspected Nautilus `1.230.0` option constructor docs before + constructing option instruments. +- Mapped QuantBT option specs to Nautilus `CryptoOption` / `OptionContract` + where constructor compatibility is available. +- Built QuoteTick-equivalent BBO reports with explicit matching semantics: + market buy at ask, market sell at bid, limit crossed by BBO only. +- Added component-labelled parity reports for: + - quantity; + - fill timestamp; + - fill price; + - fee; + - settlement; + - realized cashflow; + - final equity. +- Added tests for: + - missing Nautilus skip behavior; + - constructor mapping; + - one linear option round trip; + - inverse option constructor validation; + - two-leg spread plus settlement; + - option plus underlying hedge labelled as future mixed-instrument replay; + - support matrix exposure. + +Validation: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -c "import sys, quantbt; assert quantbt.QuantBTEndpoint.options_support_matrix()['nautilus_options']['status'] == 'experimental'; assert not any(n.startswith('nautilus_trader') for n in sys.modules); print('phase9_import_smoke=pass')"` + +Technical debt after Phase 9: + +- Phase 9 does not claim full Nautilus option backtest-engine replay. It pins + constructors and quote semantics, then labels parity against native option + accounting. +- Nautilus QuoteTick ingestion and option engine account reports remain future + work. +- `CryptoOptionSpread` / `OptionSpread` constructors are inspected but package + validation still uses component option legs, not exchange-native spread + instruments. +- Mixed underlying/perpetual + option package execution is labelled as future + work until a multi-instrument option/underlying replay path is implemented. + ## Phase 10 - Performance And Production Hardening Files: From 5b6031432b24be09991ba92022d9936c2759df78 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 12:43:48 +0000 Subject: [PATCH 12/15] feat: add options phase 10 hardening --- __init__.py | 4 + backends/native_option.py | 59 +++- benchmarks/README.md | 13 + benchmarks/options_phase10_baseline.json | 87 ++++++ benchmarks/options_phase10_baseline.md | 35 +++ benchmarks/run_options_engine.py | 272 ++++++++++++++++++ docs/endpoint.md | 17 ++ endpoint.py | 6 +- engines.py | 4 + options/__init__.py | 3 + options/cache.py | 116 ++++++++ options/execution.py | 3 +- tests/options/test_fuzz_invalid_data.py | 124 ++++++++ .../phase10_performance_hardening_status.md | 113 ++++++++ .../quantbt_options_engine_execution_plan.md | 46 +++ 15 files changed, 894 insertions(+), 8 deletions(-) create mode 100644 benchmarks/options_phase10_baseline.json create mode 100644 benchmarks/options_phase10_baseline.md create mode 100644 benchmarks/run_options_engine.py create mode 100644 options/cache.py create mode 100644 tests/options/test_fuzz_invalid_data.py create mode 100644 upgrade/option_backtest_plan/phase10_performance_hardening_status.md diff --git a/__init__.py b/__init__.py index c909d5e..d3908c6 100644 --- a/__init__.py +++ b/__init__.py @@ -209,6 +209,7 @@ OptionPackageLeg, OptionLedger, OptionPosition, + OptionPreparedRunCache, OptionSelection, OptionSelectionFilters, OptionSettlementRepresentation, @@ -255,6 +256,7 @@ long_call, long_put, option_expiry_payoff_per_unit, + option_package_cache_key, prepare_option_tape, risk_reversal, run_delta_hedge_path, @@ -371,6 +373,7 @@ "OptionPackageLeg", "OptionLedger", "OptionPosition", + "OptionPreparedRunCache", "OptionSelection", "OptionSelectionFilters", "OptionSettlementRepresentation", @@ -417,6 +420,7 @@ "hedge_decision", "liquidate_option_positions", "option_expiry_payoff_per_unit", + "option_package_cache_key", "prepare_option_tape", "risk_reversal", "run_delta_hedge_path", diff --git a/backends/native_option.py b/backends/native_option.py index e788e2e..a7ca32f 100644 --- a/backends/native_option.py +++ b/backends/native_option.py @@ -9,6 +9,7 @@ from __future__ import annotations from dataclasses import dataclass, field +import hashlib from typing import Dict, Iterable, Mapping, Optional, Sequence import numpy as np @@ -16,6 +17,7 @@ from ..core.results import OptionBacktestResult from ..core.schema import AccountConfig, ExecutionConfig +from ..options.cache import OptionPreparedRunCache from ..options.execution import OptionExecutionConfig, execute_option_package from ..options.fees import OptionFeeResult, OptionFeeSchedule, calculate_option_fee from ..options.ledger import OptionLedger @@ -39,6 +41,7 @@ class NativeOptionConfig: settle_expired: bool = False max_spread_bps: Optional[float] = None max_source_latency_ns: Optional[int] = None + random_seed: Optional[int] = 42 metadata: Dict = field(default_factory=dict) def __post_init__(self) -> None: @@ -68,17 +71,22 @@ def run( instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], packages: Sequence[OptionPackageIntent] = (), prepared_tape: Optional[PreparedOptionTape] = None, + prepared_cache: Optional[OptionPreparedRunCache] = None, settlement_events: Optional[Sequence[OptionSettlementEvent | Mapping]] = None, conversion_rates: Optional[Dict[str, float]] = None, reporting_currency: Optional[str] = None, ) -> OptionBacktestResult: registry = _normalize_registry(instruments) - tape = prepared_tape or prepare_option_tape( - chain, - registry, - max_spread_bps=self.config.max_spread_bps, - max_source_latency_ns=self.config.max_source_latency_ns, - ) + if prepared_cache is not None: + prepared_cache.validate(registry) + tape = prepared_cache.tape + else: + tape = prepared_tape or prepare_option_tape( + chain, + registry, + max_spread_bps=self.config.max_spread_bps, + max_source_latency_ns=self.config.max_source_latency_ns, + ) tape.validate_compatible(registry_signature=registry.signature) rates = {**self.config.conversion_rates, **(conversion_rates or {})} report_ccy = str(reporting_currency or self.config.reporting_currency).upper() @@ -100,6 +108,7 @@ def run( tape, config=self.config.option_execution, positions={symbol: position.qty for symbol, position in ledger.positions.items()}, + compiled_orders=prepared_cache.compile_package(package) if prepared_cache is not None else None, ) order_reports.append(pkg_result.order_report) package_reports.append(pkg_result.package_report) @@ -178,6 +187,14 @@ def run( "settlement_count": len(settlements), "venue_exact_margin": bool(margin.venue_exact), "reporting_currency": report_ccy, + "prepared_cache_used": prepared_cache is not None, + "package_cache_size": 0 if prepared_cache is None else prepared_cache.package_cache_size, + "fee_schedule_id": "execution_fee_rate" + if self.config.fee_schedule is None + else self.config.fee_schedule.schedule_id, + "limit_fidelity": self.config.option_execution.limit_fidelity.value, + "depth_fidelity": self.config.option_execution.depth_fidelity.value, + "random_seed": self.config.random_seed, **self.config.metadata, }, ) @@ -313,6 +330,25 @@ def _build_result( "initial_capital": float(account.initial_capital), "final_equity": float(equity.iloc[-1]), "reporting_currency": report_ccy, + "data_hash": _chain_data_hash(marks_report), + "registry_signature_hash": _stable_hash(repr(registry.signature.signature)), + "convention_versions": sorted( + {instrument.convention_version for instrument in registry.instruments if instrument.convention_version} + ), + "fee_schedule": metadata.get("fee_schedule_id", "execution_fee_rate"), + "margin_model": str(getattr(margin.model, "value", margin.model)), + "pricing_model": "observed_chain_bid_ask_mark", + "deterministic_replay": True, + "random_seed": metadata.get("random_seed"), + "fidelity_manifest": { + "tape": "prepared_csr_option_chain", + "execution": "top_of_book_bbo", + "limit_fidelity": metadata.get("limit_fidelity"), + "depth_fidelity": metadata.get("depth_fidelity"), + "margin": str(getattr(margin.model, "value", margin.model)), + "venue_exact_margin": bool(margin.venue_exact), + "prepared_cache_used": bool(metadata.get("prepared_cache_used", False)), + }, "option_reports": [ "fills_report", "packages_report", @@ -378,6 +414,17 @@ def _concat(frames: Iterable[pd.DataFrame]) -> pd.DataFrame: return pd.concat(items, ignore_index=True) if items else pd.DataFrame() +def _chain_data_hash(frame: pd.DataFrame) -> str: + if frame.empty: + return "0" + hashed = pd.util.hash_pandas_object(frame.sort_index(axis=1), index=False).to_numpy(dtype="uint64") + return str(int(hashed.sum(dtype="uint64"))) + + +def _stable_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + def _cash_report(snapshots: Sequence[Dict], index: pd.DatetimeIndex) -> pd.DataFrame: currencies = sorted({currency for snap in snapshots for currency in snap["cash"]}) return pd.DataFrame( diff --git a/benchmarks/README.md b/benchmarks/README.md index 44b2395..8196e97 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -61,3 +61,16 @@ python3 benchmarks/run_phase16_performance_debt.py --rows 1440 --symbols 6 --rep - `phase16_performance_debt.*` compares normal endpoint replays with `endpoint.prepare_service_context(...)` and records the current Cython/C++ decision. + +Options Phase 10: + +```bash +python3 benchmarks/run_options_engine.py --snapshots 96 --contracts 48 --packages 96 --repeats 3 +``` + +- `options_phase10_baseline.*` records prepared-tape and compiled-package cache + parity for the native option backend. +- The benchmark reports snapshots, contracts, quotes, packages, fills, hedges, + memory, uncached runtime, cached runtime, and run-manifest hashes. +- Cython/C++ should only be considered after a larger profile shows pure + kernels, not pandas/tape/report facade work, dominating runtime. diff --git a/benchmarks/options_phase10_baseline.json b/benchmarks/options_phase10_baseline.json new file mode 100644 index 0000000..2a0bccc --- /dev/null +++ b/benchmarks/options_phase10_baseline.json @@ -0,0 +1,87 @@ +{ + "phase": "options_phase10", + "status": "pass", + "seed": 42, + "snapshots": 48, + "contracts": 24, + "quotes": 1152, + "packages": 48, + "fills": 48, + "hedges": 0, + "memory_peak_mb": 2.02007, + "uncached_seconds": 0.16705728508532047, + "cached_seconds": 0.13383779395371675, + "cache_speedup": 1.2482071031676714, + "package_cache_size": 48, + "parity": { + "passed": true, + "final_equity_abs_diff": 0.0, + "position_max_abs_diff": 0.0, + "fills_equal": true + }, + "run_manifest": { + "backend": "native_option", + "result_contract": "OptionBacktestResult", + "symbols": [ + "BTC-O0000.TEST", + "BTC-O0001.TEST", + "BTC-O0002.TEST", + "BTC-O0003.TEST", + "BTC-O0004.TEST", + "BTC-O0005.TEST", + "BTC-O0006.TEST", + "BTC-O0007.TEST", + "BTC-O0008.TEST", + "BTC-O0009.TEST", + "BTC-O0010.TEST", + "BTC-O0011.TEST", + "BTC-O0012.TEST", + "BTC-O0013.TEST", + "BTC-O0014.TEST", + "BTC-O0015.TEST", + "BTC-O0016.TEST", + "BTC-O0017.TEST", + "BTC-O0018.TEST", + "BTC-O0019.TEST", + "BTC-O0020.TEST", + "BTC-O0021.TEST", + "BTC-O0022.TEST", + "BTC-O0023.TEST" + ], + "snapshot_count": 48, + "row_count": 1152, + "initial_capital": 100000.0, + "final_equity": 95733.26970680756, + "reporting_currency": "USD", + "data_hash": "12424421059823038086", + "registry_signature_hash": "f9013aed51bab82d", + "convention_versions": [ + "phase10_linear_benchmark_v1" + ], + "fee_schedule": "execution_fee_rate", + "margin_model": "standard_venue_approx", + "pricing_model": "observed_chain_bid_ask_mark", + "deterministic_replay": true, + "random_seed": 42, + "fidelity_manifest": { + "tape": "prepared_csr_option_chain", + "execution": "top_of_book_bbo", + "limit_fidelity": "cross_only", + "depth_fidelity": "top_of_book", + "margin": "standard_venue_approx", + "venue_exact_margin": false, + "prepared_cache_used": true + }, + "option_reports": [ + "fills_report", + "packages_report", + "cash_report", + "marks_report", + "greeks_report", + "settlements_report", + "margin_report", + "attribution_report" + ] + }, + "cython_cpp_recommendation": "not_recommended_yet: Phase 10 benchmark still targets pandas/tape/package facade and cache reuse; collect pure-kernel profile evidence before Cython/C++." +} diff --git a/benchmarks/options_phase10_baseline.md b/benchmarks/options_phase10_baseline.md new file mode 100644 index 0000000..39b19ce --- /dev/null +++ b/benchmarks/options_phase10_baseline.md @@ -0,0 +1,35 @@ +# Options Engine Phase 10 Benchmark + +Status: **pass** + +| metric | value | +| --- | ---: | +| snapshots | `48` | +| contracts | `24` | +| quotes | `1152` | +| packages | `48` | +| fills | `48` | +| hedges | `0` | +| peak memory MB | `2.020` | +| uncached seconds | `0.167057` | +| cached seconds | `0.133838` | +| cache speedup | `1.248x` | +| package cache size | `48` | + +## Parity Guard + +- Passed: `True` +- Final equity abs diff: `0.000000000000` +- Position max abs diff: `0.000000000000` +- Fills equal: `True` + +## Manifest + +- Data hash: `12424421059823038086` +- Margin model: `standard_venue_approx` +- Pricing model: `observed_chain_bid_ask_mark` +- Fidelity: `{'tape': 'prepared_csr_option_chain', 'execution': 'top_of_book_bbo', 'limit_fidelity': 'cross_only', 'depth_fidelity': 'top_of_book', 'margin': 'standard_venue_approx', 'venue_exact_margin': False, 'prepared_cache_used': True}` + +## Cython / C++ Decision + +not_recommended_yet: Phase 10 benchmark still targets pandas/tape/package facade and cache reuse; collect pure-kernel profile evidence before Cython/C++. diff --git a/benchmarks/run_options_engine.py b/benchmarks/run_options_engine.py new file mode 100644 index 0000000..6ccc4cd --- /dev/null +++ b/benchmarks/run_options_engine.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Phase 10 options-engine benchmark and parity guard.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import tracemalloc +from pathlib import Path +from typing import Dict, Sequence + +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 + ExerciseStyle, + NativeOptionBackend, + NativeOptionConfig, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + OptionPackageIntent, + OptionPackageLeg, + OptionPreparedRunCache, + OrderSide, + PremiumConvention, + SettlementStyle, +) + + +def run_benchmark(*, snapshots: int, contracts: int, packages: int, repeats: int, seed: int) -> Dict: + rng = np.random.default_rng(seed) + registry = _registry(contracts) + chain = _chain(registry, snapshots=snapshots, rng=rng) + package_list = _packages(registry, chain, packages=packages) + config = NativeOptionConfig(initial_balances={"USD": 100_000.0}, reporting_currency="USD", random_seed=seed) + backend = NativeOptionBackend(config) + + uncached = backend.run(chain=chain, instruments=registry, packages=package_list) + cache = OptionPreparedRunCache.from_chain(chain, registry) + cached = backend.run(chain=chain, instruments=registry, packages=package_list, prepared_cache=cache) + parity = _parity(uncached, cached) + + uncached_seconds = _timeit(lambda: backend.run(chain=chain, instruments=registry, packages=package_list), repeats) + cached_seconds = _timeit(lambda: backend.run(chain=chain, instruments=registry, packages=package_list, prepared_cache=cache), repeats) + peak_mb = _peak_memory_mb(lambda: backend.run(chain=chain, instruments=registry, packages=package_list, prepared_cache=cache)) + return { + "phase": "options_phase10", + "status": "pass" if parity["passed"] else "fail", + "seed": int(seed), + "snapshots": int(snapshots), + "contracts": int(contracts), + "quotes": int(len(chain)), + "packages": int(len(package_list)), + "fills": int(len(cached.fills_report)), + "hedges": 0, + "memory_peak_mb": float(peak_mb), + "uncached_seconds": float(uncached_seconds), + "cached_seconds": float(cached_seconds), + "cache_speedup": float(uncached_seconds / cached_seconds) if cached_seconds > 0.0 else 0.0, + "package_cache_size": int(cache.package_cache_size), + "parity": parity, + "run_manifest": cached.run_manifest, + "cython_cpp_recommendation": ( + "not_recommended_yet: Phase 10 benchmark still targets pandas/tape/package facade and cache reuse; " + "collect pure-kernel profile evidence before Cython/C++." + ), + } + + +def make_markdown(report: Dict) -> str: + lines = [ + "# Options Engine Phase 10 Benchmark", + "", + f"Status: **{report['status']}**", + "", + "| metric | value |", + "| --- | ---: |", + f"| snapshots | `{report['snapshots']}` |", + f"| contracts | `{report['contracts']}` |", + f"| quotes | `{report['quotes']}` |", + f"| packages | `{report['packages']}` |", + f"| fills | `{report['fills']}` |", + f"| hedges | `{report['hedges']}` |", + f"| peak memory MB | `{report['memory_peak_mb']:.3f}` |", + f"| uncached seconds | `{report['uncached_seconds']:.6f}` |", + f"| cached seconds | `{report['cached_seconds']:.6f}` |", + f"| cache speedup | `{report['cache_speedup']:.3f}x` |", + f"| package cache size | `{report['package_cache_size']}` |", + "", + "## Parity Guard", + "", + f"- Passed: `{report['parity']['passed']}`", + f"- Final equity abs diff: `{report['parity']['final_equity_abs_diff']:.12f}`", + f"- Position max abs diff: `{report['parity']['position_max_abs_diff']:.12f}`", + f"- Fills equal: `{report['parity']['fills_equal']}`", + "", + "## Manifest", + "", + f"- Data hash: `{report['run_manifest'].get('data_hash')}`", + f"- Margin model: `{report['run_manifest'].get('margin_model')}`", + f"- Pricing model: `{report['run_manifest'].get('pricing_model')}`", + f"- Fidelity: `{report['run_manifest'].get('fidelity_manifest')}`", + "", + "## Cython / C++ Decision", + "", + report["cython_cpp_recommendation"], + "", + ] + return "\n".join(lines) + + +def _registry(contracts: int) -> OptionInstrumentRegistry: + expiry = int(pd.Timestamp("2026-03-01 08:00:00", tz="UTC").value) + specs = [] + for i in range(contracts): + strike = 80_000.0 + 1_000.0 * i + kind = OptionKind.CALL if i % 2 == 0 else OptionKind.PUT + specs.append( + OptionInstrumentSpec( + symbol=f"BTC-O{i:04d}.TEST", + venue="test", + underlying_id="BTC-PERP.TEST", + underlying_index_id="BTC-INDEX.TEST", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + tick_size=0.01, + convention_version="phase10_linear_benchmark_v1", + ) + ) + return OptionInstrumentRegistry.from_iterable(specs) + + +def _chain(registry: OptionInstrumentRegistry, *, snapshots: int, rng) -> pd.DataFrame: + start = pd.Timestamp("2026-01-01 00:00:00", tz="UTC") + rows = [] + for t in range(snapshots): + ts = int((start + pd.Timedelta(minutes=15 * t)).value) + index_price = 100_000.0 + 100.0 * np.sin(t / 10.0) + for code, spec in enumerate(registry.instruments): + intrinsic = max(index_price - spec.strike, 0.0) if spec.option_kind is OptionKind.CALL else max(spec.strike - index_price, 0.0) + time_value = 500.0 + 5.0 * code + float(rng.normal(0.0, 1.0)) + mark = max(intrinsic + time_value, 1.0) + rows.append( + { + "timestamp_ns": ts, + "instrument_id": spec.symbol, + "venue": "TEST", + "underlying_id": spec.underlying_id, + "expiry_ns": spec.expiry_ns, + "strike": spec.strike, + "option_kind": spec.option_kind.value, + "bid_price": mark * 0.995, + "bid_size": 50.0, + "ask_price": mark * 1.005, + "ask_size": 50.0, + "mark_price": mark, + "last_price": mark, + "index_price": index_price, + "forward_price": index_price, + "mark_iv": 0.6, + "bid_iv": 0.59, + "ask_iv": 0.61, + "delta": 0.5 if spec.option_kind is OptionKind.CALL else -0.5, + "gamma": 0.0001, + "vega": 100.0, + "theta": -10.0, + "open_interest": 1000.0, + "volume": 100.0, + "quote_currency": "USD", + "settlement_currency": "USD", + "sequence_id": code, + "source_latency_ns": 1_000_000, + } + ) + return pd.DataFrame(rows) + + +def _packages(registry: OptionInstrumentRegistry, chain: pd.DataFrame, *, packages: int) -> Sequence[OptionPackageIntent]: + timestamps = sorted(chain["timestamp_ns"].unique()) + symbols = list(registry.symbols) + out = [] + for i in range(packages): + ts = int(timestamps[i % len(timestamps)]) + symbol = symbols[i % len(symbols)] + side = OrderSide.BUY if i % 2 == 0 else OrderSide.SELL + out.append( + OptionPackageIntent( + timestamp_ns=ts, + package_id=f"bench-{i:05d}", + legs=(OptionPackageLeg(symbol, side, 1.0),), + quantity=1.0, + ) + ) + return tuple(out) + + +def _parity(a, b) -> Dict: + equity_diff = float(abs(a.equity.iloc[-1] - b.equity.iloc[-1])) + position_diff = float(np.max(np.abs(a.positions.to_numpy() - b.positions.to_numpy()))) + fills_equal = bool(a.fills_report.equals(b.fills_report)) + return { + "passed": bool(equity_diff <= 1e-9 and position_diff <= 1e-12 and fills_equal), + "final_equity_abs_diff": equity_diff, + "position_max_abs_diff": position_diff, + "fills_equal": fills_equal, + } + + +def _timeit(fn, repeats: int) -> float: + durations = [] + for _ in range(max(1, repeats)): + start = time.perf_counter() + fn() + durations.append(time.perf_counter() - start) + return float(min(durations)) + + +def _peak_memory_mb(fn) -> float: + tracemalloc.start() + try: + fn() + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + return peak / 1_000_000.0 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--snapshots", type=int, default=96) + parser.add_argument("--contracts", type=int, default=48) + parser.add_argument("--packages", type=int, default=96) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output-json", type=Path, default=PACKAGE_DIR / "benchmarks" / "options_phase10_baseline.json") + parser.add_argument("--output-md", type=Path, default=PACKAGE_DIR / "benchmarks" / "options_phase10_baseline.md") + args = parser.parse_args() + + report = run_benchmark( + snapshots=args.snapshots, + contracts=args.contracts, + packages=args.packages, + repeats=args.repeats, + seed=args.seed, + ) + args.output_json.write_text(json.dumps(report, indent=2, default=str) + "\n", encoding="utf-8") + args.output_md.write_text(make_markdown(report), encoding="utf-8") + print(json.dumps({"status": report["status"], "cache_speedup": report["cache_speedup"]}, indent=2)) + if report["status"] != "pass": + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/docs/endpoint.md b/docs/endpoint.md index d5e692b..4f88e5b 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -1824,6 +1824,23 @@ Useful config: approximation unless an external validator is provided in later phases. - `settlement_events`: optional expiry settlement events passed to `backtest(...)`. +- `prepared_cache`: optional `OptionPreparedRunCache` passed to `backtest(...)` + when replaying many package sets over the same option chain. + +Prepared cache pattern: + +```python +from quantbt import OptionPreparedRunCache + +cache = OptionPreparedRunCache.from_chain(chain, option_registry) + +result = bt.backtest( + chain=chain, + instruments=option_registry, + packages=packages, + prepared_cache=cache, +) +``` Returned result: diff --git a/endpoint.py b/endpoint.py index 49a4627..f50d1b0 100644 --- a/endpoint.py +++ b/endpoint.py @@ -60,6 +60,7 @@ from .options.execution import OptionExecutionConfig from .options.fees import OptionFeeSchedule from .options.margin import OptionMarginConfig +from .options.cache import OptionPreparedRunCache from .options.packages import OptionPackageIntent from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec from .viz import quick_plot as _quick_plot @@ -831,6 +832,7 @@ def backtest( packages: Optional[Sequence[OptionPackageIntent]] = None, settlement_events: Optional[Sequence] = None, conversion_rates: Optional[Dict[str, float]] = None, + prepared_cache: Optional[OptionPreparedRunCache] = None, ): """ Run the configured backtest and store the result. @@ -866,6 +868,7 @@ def backtest( packages=packages, settlement_events=settlement_events, conversion_rates=conversion_rates, + prepared_cache=prepared_cache, ) if mode == "walk_forward": return self._run_walk_forward( @@ -1081,7 +1084,7 @@ def nautilus_pct_equity_diagnostic( native_slippage=native_slippage, ) - def _run_options(self, chain, instruments, packages, settlement_events, conversion_rates): + def _run_options(self, chain, instruments, packages, settlement_events, conversion_rates, prepared_cache): if chain is None: raise ValueError("options endpoint requires chain=option_chain_dataframe or data=option_chain_dataframe") if instruments is None: @@ -1104,6 +1107,7 @@ def _run_options(self, chain, instruments, packages, settlement_events, conversi config=config, settlement_events=settlement_events or (), conversion_rates=conversion_rates, + prepared_cache=prepared_cache, ) self._store_result(self.engine.result) return self.result diff --git a/engines.py b/engines.py index 9881813..2f754d9 100644 --- a/engines.py +++ b/engines.py @@ -27,6 +27,7 @@ from .core.preprocessor import validate_datetime from .core.results import BacktestResultV2, OptionBacktestResult from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce +from .options.cache import OptionPreparedRunCache from .options.packages import OptionPackageIntent from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec from .portfolio import MultiSymbolPortfolio @@ -383,6 +384,7 @@ def __init__( config: Optional[NativeOptionConfig] = None, settlement_events: Optional[Sequence] = None, conversion_rates: Optional[Dict[str, float]] = None, + prepared_cache: Optional[OptionPreparedRunCache] = None, auto_run: bool = True, ): self.chain = chain @@ -391,6 +393,7 @@ def __init__( self.config = config or NativeOptionConfig() self.settlement_events = tuple(settlement_events or ()) self.conversion_rates = conversion_rates + self.prepared_cache = prepared_cache self.backend = NativeOptionBackend(self.config) self.result: Optional[OptionBacktestResult] = None @@ -408,6 +411,7 @@ def run(self) -> OptionBacktestResult: packages=self.packages, settlement_events=self.settlement_events, conversion_rates=self.conversion_rates, + prepared_cache=self.prepared_cache, ) return self.result diff --git a/options/__init__.py b/options/__init__.py index 8cdd1f6..2334434 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -12,6 +12,7 @@ deribit_inverse_option_convention, deribit_linear_usdc_option_convention, ) +from .cache import OptionPreparedRunCache, option_package_cache_key from .data import CANONICAL_OPTION_CHAIN_COLUMNS, validate_option_chain_frame from .execution import ( OptionDepthFidelity, @@ -148,6 +149,7 @@ "OptionSettlementResult", "OptionTapeSignature", "OptionPosition", + "OptionPreparedRunCache", "PremiumConvention", "PreparedOptionTape", "SettlementStyle", @@ -183,6 +185,7 @@ "hedge_decision", "liquidate_option_positions", "option_expiry_payoff_per_unit", + "option_package_cache_key", "prepare_option_tape", "run_delta_hedge_path", "scale_greeks_to_reporting_currency", diff --git a/options/cache.py b/options/cache.py new file mode 100644 index 0000000..5c0ece6 --- /dev/null +++ b/options/cache.py @@ -0,0 +1,116 @@ +""" +Prepared option run cache. + +The cache is explicit and signature-checked. It is designed for service/WFO +loops where the same option chain tape is replayed with many package choices. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Tuple + +import pandas as pd + +from ..core.orders import OrderIntent +from .packages import OptionPackageIntent, compile_option_package_orders +from .schema import OptionInstrumentRegistry +from .tape import PreparedOptionTape, prepare_option_tape + + +@dataclass +class OptionPreparedRunCache: + tape: PreparedOptionTape + package_orders: Dict[Tuple, Tuple[OrderIntent, ...]] = field(default_factory=dict) + metadata: Dict = field(default_factory=dict) + + @classmethod + def from_chain( + cls, + chain: pd.DataFrame, + registry: OptionInstrumentRegistry, + *, + max_spread_bps: Optional[float] = None, + max_source_latency_ns: Optional[int] = None, + convention_signature: Optional[Tuple] = None, + ) -> "OptionPreparedRunCache": + tape = prepare_option_tape( + chain, + registry, + max_spread_bps=max_spread_bps, + max_source_latency_ns=max_source_latency_ns, + convention_signature=convention_signature, + ) + return cls( + tape=tape, + metadata={ + "cache_type": "OptionPreparedRunCache", + "snapshot_count": int(tape.snapshot_count), + "row_count": int(tape.row_count), + "registry_symbols": tuple(registry.symbols), + "convention_signature": tape.signature.convention_signature, + }, + ) + + def validate( + self, + registry: OptionInstrumentRegistry, + *, + timestamps_ns=None, + convention_signature: Optional[Tuple] = None, + ) -> None: + self.tape.validate_compatible( + registry_signature=registry.signature, + timestamps_ns=timestamps_ns, + convention_signature=convention_signature, + ) + + def compile_package(self, package: OptionPackageIntent) -> Tuple[OrderIntent, ...]: + key = option_package_cache_key(package) + cached = self.package_orders.get(key) + if cached is None: + cached = compile_option_package_orders(package) + self.package_orders[key] = cached + return cached + + @property + def package_cache_size(self) -> int: + return len(self.package_orders) + + +def option_package_cache_key(package: OptionPackageIntent) -> Tuple: + """Return a deterministic key for compiled option package order leaves.""" + return ( + int(package.timestamp_ns), + str(package.package_id), + float(package.quantity), + package.execution_policy.value, + None if package.max_debit is None else float(package.max_debit), + None if package.min_credit is None else float(package.min_credit), + tuple( + ( + leg.instrument_id, + leg.side.value, + float(leg.ratio), + leg.order_type.value, + None if leg.limit_price is None else float(leg.limit_price), + leg.tif.value, + leg.role, + leg.tag, + tuple(sorted((str(k), _stable_value(v)) for k, v in leg.metadata.items())), + ) + for leg in package.legs + ), + package.tag, + tuple(sorted((str(k), _stable_value(v)) for k, v in package.metadata.items())), + ) + + +def _stable_value(value): + if isinstance(value, (str, int, float, bool, type(None))): + return value + if isinstance(value, dict): + return tuple(sorted((str(k), _stable_value(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return tuple(_stable_value(item) for item in value) + return repr(value) diff --git a/options/execution.py b/options/execution.py index 63a2e17..d516b3e 100644 --- a/options/execution.py +++ b/options/execution.py @@ -129,11 +129,12 @@ def execute_option_package( *, config: Optional[OptionExecutionConfig] = None, positions: Optional[Dict[str, float]] = None, + compiled_orders: Optional[Tuple[OrderIntent, ...]] = None, ) -> OptionPackageExecutionResult: """Execute one option package against the latest observable tape snapshot.""" cfg = config or OptionExecutionConfig() state = _ExecutionState(cash=float(cfg.initial_cash), positions=dict(positions or {})) - orders = compile_option_package_orders(package) + orders = tuple(compiled_orders) if compiled_orders is not None else compile_option_package_orders(package) policy = package.execution_policy if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE: return _execute_atomic_all_or_none(package, orders, tape, cfg, state) diff --git a/tests/options/test_fuzz_invalid_data.py b/tests/options/test_fuzz_invalid_data.py new file mode 100644 index 0000000..3ae5c2d --- /dev/null +++ b/tests/options/test_fuzz_invalid_data.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import ( + NativeOptionBackend, + NativeOptionConfig, + OptionPackageIntent, + OptionPackageLeg, + OptionPreparedRunCache, + OrderSide, + QuantBTEndpoint, + prepare_option_tape, +) + + +def test_phase10_prepared_cache_replay_matches_uncached(option_phase3_chain, option_phase3_registry): + package = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="cache-long-call", + legs=(OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", OrderSide.BUY, 1.0),), + ) + cfg = NativeOptionConfig( + initial_balances={"USD": 20_000.0}, + conversion_rates={"BTC": 100_000.0}, + reporting_currency="USD", + ) + backend = NativeOptionBackend(cfg) + cache = OptionPreparedRunCache.from_chain(option_phase3_chain, option_phase3_registry) + + uncached = backend.run(chain=option_phase3_chain, instruments=option_phase3_registry, packages=[package]) + cached = backend.run( + chain=option_phase3_chain, + instruments=option_phase3_registry, + packages=[package], + prepared_cache=cache, + ) + + assert cached.equity.equals(uncached.equity) + assert cached.positions.equals(uncached.positions) + assert cached.fills_report.equals(uncached.fills_report) + assert cached.run_manifest["fidelity_manifest"]["prepared_cache_used"] is True + assert cache.package_cache_size == 1 + cache.compile_package(package) + assert cache.package_cache_size == 1 + + +def test_phase10_endpoint_accepts_prepared_option_cache(option_phase3_chain, option_phase3_registry): + package = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="endpoint-cache", + legs=(OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", OrderSide.BUY, 1.0),), + ) + cache = OptionPreparedRunCache.from_chain(option_phase3_chain, option_phase3_registry) + endpoint = QuantBTEndpoint.options( + initial_capital=20_000.0, + initial_balances={"USD": 20_000.0}, + conversion_rates={"BTC": 100_000.0}, + ) + + result = endpoint.backtest( + chain=option_phase3_chain, + instruments=option_phase3_registry, + packages=[package], + prepared_cache=cache, + ) + + assert result.metadata["prepared_cache_used"] is True + assert result.run_manifest["data_hash"] + assert result.run_manifest["margin_model"] + assert result.run_manifest["pricing_model"] == "observed_chain_bid_ask_mark" + + +def test_phase10_prepared_tape_rejects_stale_registry_signature(option_phase3_chain, option_phase3_registry): + cache = OptionPreparedRunCache.from_chain(option_phase3_chain, option_phase3_registry) + shifted = option_phase3_chain.copy() + shifted.loc[0, "strike"] = shifted.loc[0, "strike"] + 1.0 + bad_registry = option_phase3_registry + + with pytest.raises(ValueError, match="strike"): + prepare_option_tape(shifted, bad_registry) + + with pytest.raises(ValueError, match="timestamp mismatch"): + cache.validate(option_phase3_registry, timestamps_ns=[int(cache.tape.timestamp_ns[0])]) + + +@pytest.mark.parametrize( + ("mutator", "message"), + [ + (lambda frame: frame.drop(columns=["ask_price"]), "missing"), + (lambda frame: frame.assign(bid_size=-1.0), "bid_size"), + (lambda frame: frame.assign(ask_price=0.0), "ask_price"), + (lambda frame: frame.assign(timestamp_ns=0), "timestamp_ns"), + (lambda frame: frame.assign(source_latency_ns=10_000_000_000), "stale source latency"), + ], +) +def test_phase10_invalid_chain_rows_are_rejected(option_phase3_chain, option_phase3_registry, mutator, message): + bad = mutator(option_phase3_chain.copy()) + + with pytest.raises(ValueError, match=message): + OptionPreparedRunCache.from_chain( + bad, + option_phase3_registry, + max_source_latency_ns=1_000_000, + ) + + +def test_phase10_invalid_package_cache_key_rejects_mutated_ratio(option_phase3_chain, option_phase3_registry): + cache = OptionPreparedRunCache.from_chain(option_phase3_chain, option_phase3_registry) + package_a = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="ratio-a", + legs=(OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", OrderSide.BUY, 1.0),), + ) + package_b = OptionPackageIntent( + timestamp_ns=int(option_phase3_chain["timestamp_ns"].min()), + package_id="ratio-a", + legs=(OptionPackageLeg("BTC-01FEB26-100000-C.DERIBIT", OrderSide.BUY, 2.0),), + ) + + assert cache.compile_package(package_a)[0].qty == 1.0 + assert cache.compile_package(package_b)[0].qty == 2.0 + assert cache.package_cache_size == 2 diff --git a/upgrade/option_backtest_plan/phase10_performance_hardening_status.md b/upgrade/option_backtest_plan/phase10_performance_hardening_status.md new file mode 100644 index 0000000..b114f96 --- /dev/null +++ b/upgrade/option_backtest_plan/phase10_performance_hardening_status.md @@ -0,0 +1,113 @@ +# Options Engine Phase 10 Status + +Status: completed. + +## Scope + +Phase 10 hardens the native options stack for repeatable service/WFO-style +usage. The phase focuses on cache reuse, deterministic replay, fuzz tests, +benchmarking and run manifest completeness. + +## Implemented + +- `options/cache.py` + - `OptionPreparedRunCache`; + - `option_package_cache_key`; + - signature-checked prepared tape reuse; + - deterministic compiled package order cache. + +- `options/execution.py` + - `execute_option_package(..., compiled_orders=None)` for cache-aware replay; + - default behavior remains compile-on-call. + +- `backends/native_option.py` + - `prepared_cache` support; + - cache metadata in result metadata; + - expanded run manifest. + +- `engines.py` + - `OptionBacktestEngine(..., prepared_cache=...)`. + +- `endpoint.py` + - `QuantBTEndpoint.options(...).backtest(..., prepared_cache=...)`. + +- `benchmarks/run_options_engine.py` + - deterministic mock-chain benchmark; + - uncached vs cached runtime; + - memory; + - parity guard; + - Cython/C++ recommendation. + +- `tests/options/test_fuzz_invalid_data.py` + - cache parity; + - endpoint cache path; + - stale signature/timestamp rejection; + - invalid chain mutations; + - package-cache key safety. + +## Run Manifest + +Phase 10 option results now include: + +- `data_hash`; +- `registry_signature_hash`; +- `convention_versions`; +- `fee_schedule`; +- `margin_model`; +- `pricing_model`; +- `deterministic_replay`; +- `random_seed`; +- `fidelity_manifest`. + +## Benchmark Baseline + +Committed outputs: + +- `benchmarks/options_phase10_baseline.json`; +- `benchmarks/options_phase10_baseline.md`. + +Smoke profile: + +- snapshots: `48`; +- contracts: `24`; +- quotes: `1152`; +- packages: `48`; +- fills: `48`; +- peak memory: about `2.02 MB`; +- cache speedup: about `1.25x`; +- parity: pass with zero final equity and position diff. + +## Validation Commands + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python -m py_compile options/cache.py options/execution.py backends/native_option.py endpoint.py engines.py benchmarks/run_options_engine.py __init__.py options/__init__.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python benchmarks/run_options_engine.py --snapshots 48 --contracts 24 --packages 48 --repeats 2 --output-json benchmarks/options_phase10_baseline.json --output-md benchmarks/options_phase10_baseline.md +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py +``` + +## Cython / C++ Decision + +Not recommended yet. + +Reason: + +- Phase 10 benchmark measures facade/tape/package cache behavior. +- Current evidence supports prepared cache reuse and profile-guided Python/NumPy + optimization first. +- Cython/C++ should wait until large profiles show pure kernels, not pandas or + report construction, dominate runtime. + +## Technical Debt + +- Benchmark hedges are reported as `0` because mixed underlying/perpetual hedge + replay remains future work. +- Benchmark is deterministic mock-chain validation, not venue production + certification. +- Full Nautilus option replay remains Phase 9+ future depth. + +## Conclusion + +Phase 10 is complete. The native options stack now has explicit cache reuse, +deterministic benchmark artifacts, invalid-data fuzz coverage, and a richer run +manifest suitable for service and WFO loops. diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index f5b9642..2d545ab 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -1013,6 +1013,52 @@ Acceptance: - Cython/C++ is only considered after Numba/profile evidence shows pure kernel bottlenecks. +Status: completed. + +Implementation notes: + +- Added explicit prepared option cache: + - `OptionPreparedRunCache`; + - `option_package_cache_key`; + - signature-checked prepared tape reuse; + - deterministic compiled package order cache. +- Added optional `prepared_cache` threading through: + - `NativeOptionBackend.run(...)`; + - `OptionBacktestEngine`; + - `QuantBTEndpoint.options(...).backtest(...)`. +- Added `compiled_orders` override to `execute_option_package(...)` while + preserving the old compile-on-call default. +- Extended option run manifest with: + - data hash; + - registry signature hash; + - convention versions; + - fee schedule; + - margin model; + - pricing model; + - deterministic replay seed; + - fidelity manifest. +- Added `benchmarks/run_options_engine.py`. +- Added committed benchmark baseline: + - `benchmarks/options_phase10_baseline.json`; + - `benchmarks/options_phase10_baseline.md`. +- Added deterministic fuzz/invalid-data tests in + `tests/options/test_fuzz_invalid_data.py`. + +Validation: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python benchmarks/run_options_engine.py --snapshots 48 --contracts 24 --packages 48 --repeats 2 --output-json benchmarks/options_phase10_baseline.json --output-md benchmarks/options_phase10_baseline.md` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py` + +Technical debt after Phase 10: + +- Benchmark is a deterministic mock-chain baseline, not a venue production + latency/profile certification. +- Hedges are counted in the benchmark schema but set to zero because mixed + underlying option hedging remains future engine work. +- Cython/C++ is not recommended yet; current Phase 10 evidence supports cache + reuse and facade profiling first. + ## V1 Completion Criteria V1 can be called usable only when: From dc22c2b452498fa50b433e6983a5e85fbea12362 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 14:35:54 +0000 Subject: [PATCH 13/15] test: add gamma scalping options sample --- benchmarks/README.md | 5 + benchmarks/gamma_scalping_backtestsample.py | 472 ++++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 benchmarks/gamma_scalping_backtestsample.py diff --git a/benchmarks/README.md b/benchmarks/README.md index 8196e97..9ddca56 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -66,11 +66,16 @@ Options Phase 10: ```bash python3 benchmarks/run_options_engine.py --snapshots 96 --contracts 48 --packages 96 --repeats 3 +python3 benchmarks/gamma_scalping_backtestsample.py --snapshots 90 --seed 42 ``` - `options_phase10_baseline.*` records prepared-tape and compiled-package cache parity for the native option backend. - The benchmark reports snapshots, contracts, quotes, packages, fills, hedges, memory, uncached runtime, cached runtime, and run-manifest hashes. +- `gamma_scalping_backtestsample.py` is a runnable long-straddle gamma-scalping + smoke sample. It keeps the original research helpers, then runs the public + `QuantBTEndpoint.options(...)` path with prepared-cache parity and a separate + delta-hedge path report. - Cython/C++ should only be considered after a larger profile shows pure kernels, not pandas/tape/report facade work, dominating runtime. diff --git a/benchmarks/gamma_scalping_backtestsample.py b/benchmarks/gamma_scalping_backtestsample.py new file mode 100644 index 0000000..a72a7dd --- /dev/null +++ b/benchmarks/gamma_scalping_backtestsample.py @@ -0,0 +1,472 @@ +import argparse +import json +import sys +from pathlib import Path + +import pandas as pd +import numpy as np +from datetime import datetime, timedelta + + +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 + ExerciseStyle, + OptionHedgeConfig, + OptionHedgePolicyType, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + OptionPackageIntent, + OptionPackageLeg, + OptionPreparedRunCache, + OrderSide, + PremiumConvention, + QuantBTEndpoint, + SettlementStyle, + run_delta_hedge_path, +) + +def filter_atm_options(df: pd.DataFrame, iv_rank_threshold: float = 101.0, # Tạm set cao để bypass IV rank check + use_rv_condition: bool = False, # Thêm flag tắt RV > IV + rv_window: int = 20, iv_window: int = 252, min_oi: int = 50, # Giảm OI tạm + min_dte: int = 2, max_dte: int = 14) -> pd.DataFrame: # Mở rộng DTE + df = df.copy() + df['snapshot_time'] = pd.to_datetime(df['time']).dt.tz_localize(None).dt.normalize() + df['expiration_date'] = pd.to_datetime(df['expiration']).dt.tz_localize(None).dt.normalize() + df['spot_price'] = df['close'] + df['strike'] = df['strike'].astype(float).astype(int) + df['mid_price'] = (df['bid'] + df['ask']) / 2 + df['dte'] = (df['expiration_date'] - df['snapshot_time']).dt.days + + df_sorted = df.sort_values('snapshot_time') + df_sorted['log_return'] = np.log(df_sorted['spot_price'] / df_sorted['spot_price'].shift(1)) + df_sorted['rv'] = df_sorted['log_return'].ewm(span=rv_window).std() * np.sqrt(252) + + # IV rank chỉ tính nếu window đủ, nhưng bypass check + df_sorted['iv_rank'] = df_sorted.groupby('snapshot_time')['implied_volatility'].transform( + lambda x: x.rank(pct=True).iloc[-1] * 100 if len(x) > 0 else np.nan + ) # Simple rank per day, hoặc giữ rolling nhưng skip NaN + + result_rows = [] + for (time_snapshot, underlying), group_df in df_sorted.groupby(['snapshot_time', 'underlying']): + current_iv = group_df['implied_volatility'].mean() + current_rv = group_df['rv'].mean() if 'rv' in group_df else np.nan # Mean để tránh NaN + current_iv_rank = group_df['iv_rank'].mean() + + # Bypass condition tạm + if current_iv_rank >= iv_rank_threshold: + continue # Chỉ skip nếu rank cao, nhưng set threshold=101 để không skip + if use_rv_condition and (pd.isna(current_rv) or current_rv <= current_iv): + continue + + filtered_df = group_df[(group_df['dte'] >= min_dte) & (group_df['dte'] <= max_dte) & (group_df['open_interest'] >= min_oi)].copy() + if filtered_df.empty: + continue + + current_spot = filtered_df['spot_price'].iloc[0] + paired_strikes = filtered_df.groupby(['expiration_date', 'strike']).filter( + lambda x: set(x['type'].values) == {'call', 'put'} # Chính xác hơn: đúng 1 call + 1 put + ) + if paired_strikes.empty: + continue + + paired_strikes['atm_distance'] = abs(paired_strikes['strike'] - current_spot) + min_expiry_date = paired_strikes['dte'].min() # Ưu tiên DTE nhỏ nhất + best_expiry = paired_strikes[paired_strikes['dte'] == min_expiry_date]['expiration_date'].iloc[0] + best_strikes = paired_strikes[paired_strikes['expiration_date'] == best_expiry] + best_strike = best_strikes.loc[best_strikes['atm_distance'].idxmin(), 'strike'] + + final_pair = filtered_df[ + (filtered_df['expiration_date'] == best_expiry) & + (filtered_df['strike'] == best_strike) & + (filtered_df['type'].isin(['call', 'put'])) + ] + if len(final_pair) == 2: + result_rows.append(final_pair) + + if result_rows: + return pd.concat(result_rows, ignore_index=True) + else: + print("No straddle found after all filters - check data has paired call/put ATM short-dated") + return pd.DataFrame(columns=df.columns) + +def normalize_greeks(df_straddle: pd.DataFrame) -> pd.DataFrame: + """ + Chuẩn hóa dựa vendor: delta/gamma *100 (per $1), theta per day USD, vega *100 (per 1.0 IV). + """ + df = df_straddle.copy() + df['delta_norm'] = df['delta'] * 100 + df['gamma_norm'] = df['gamma'] * 100 + df['theta_norm'] = df['theta'] + df['vega_norm'] = df['vega'] * 100 + return df + +def aggregate_straddle_greeks(df: pd.DataFrame, position_type: str = 'long', notional: int = 100) -> pd.DataFrame: + """ + Aggregate Greeks, scale by notional và sign. + """ + sign = 1 if position_type == 'long' else -1 + df['time'] = pd.to_datetime(df['time']) + df = df.set_index('time').sort_index() + + straddle_df = df.groupby(level=0).agg({ + 'spot_price': 'first', + 'delta_norm': 'sum', + 'gamma_norm': 'sum', + 'theta_norm': 'sum', + 'vega_norm': 'sum', + 'implied_volatility': 'mean', + 'mid_price': 'sum', + 'dte': 'first' + }) + + for col in ['delta_norm', 'gamma_norm', 'theta_norm', 'vega_norm', 'mid_price']: + straddle_df[col] *= sign * notional + + straddle_df.rename(columns={'implied_volatility': 'iv_straddle'}, inplace=True) + return straddle_df + +def simulate_paths(S0: float, mu: float, sigma_rv: float, T: float, dt: float, n_paths: int = 1000) -> np.ndarray: + """GBM paths for backtest.""" + n_steps = int(T / dt) + paths = np.zeros((n_paths, n_steps + 1)) + paths[:, 0] = S0 + for t in range(1, n_steps + 1): + Z = np.random.standard_normal(n_paths) + paths[:, t] = paths[:, t-1] * np.exp((mu - 0.5 * sigma_rv**2) * dt + sigma_rv * np.sqrt(dt) * Z) + return paths + +def gamma_pnl_factor(gamma_norm: float, S: float, rv: float, iv: float, dt: float, notional: int = 100) -> float: + """Gamma P&L attribution.""" + return 0.5 * gamma_norm * S**2 * (rv**2 - iv**2) * dt * notional + +def hedge_and_pnl(df_straddle: pd.DataFrame, + position_type: str = 'long', + notional: int = 100, + hedge_threshold: float = 0.05, + min_dte: int = 2, + sim_paths: bool = False, + option_commission_per_straddle: float = 3.0, # USD per straddle round-trip (2 legs) + hedge_commission_per_unit_delta: float = 0.05 # USD per 1.0 delta rebalanced + ) -> pd.DataFrame: + """ + P&L realistic với commission: + - Option fee: khi open/rollover straddle mới + - Hedge fee: mỗi lần re-hedge delta + """ + df = normalize_greeks(df_straddle) + df = aggregate_straddle_greeks(df, position_type, notional) + + df['portfolio_delta'] = df['delta_norm'] + df['pnl'] = 0.0 + df['cum_pnl'] = 0.0 + df['cum_return'] = 0.0 + df['gamma_attrib'] = 0.0 + df['hedge_pnl'] = 0.0 + df['mtm_change'] = 0.0 + df['commission'] = 0.0 # NEW: track commission + df['commission_option'] = 0.0 # Phí từ option + df['commission_hedge'] = 0.0 # Phí từ hedge + + if sim_paths: + dt_base = 1/252 + T_total = len(df) * dt_base + paths = simulate_paths(df['spot_price'].iloc[0], mu=0.1, sigma_rv=0.3, T=T_total, dt=dt_base, n_paths=1) + df['spot_price'] = pd.Series(paths[0, :len(df)], index=df.index) + + if df.empty: + return df + + # Initial capital = giá trị straddle khi entry (mid_price đầu tiên) + initial_capital = abs(df.iloc[0]['mid_price']) # abs để tránh âm nếu short + if initial_capital == 0: + initial_capital = 1.0 # Tránh chia 0 + + current_position_value = df.iloc[0]['mid_price'] + prev_delta_for_hedge = df.iloc[0]['delta_norm'] # Để tính delta change khi hedge + + for i in range(1, len(df)): + row_prev, row = df.iloc[i-1], df.iloc[i] + + S_prev, S = row_prev['spot_price'], row['spot_price'] + ds = S - S_prev + dt_actual = (row.name - row_prev.name).days + + rv_actual = abs(ds / S_prev) * np.sqrt(252) if dt_actual > 0 and S_prev != 0 else 0.0 + + commission_today = 0.0 + commission_option_today = 0.0 + commission_hedge_today = 0.0 + + # === ROLLOVER: close old straddle, open new === + if row_prev['dte'] < min_dte: + close_pnl = row_prev['mid_price'] - current_position_value + df.at[row_prev.name, 'pnl'] += close_pnl + df.at[row_prev.name, 'mtm_change'] = close_pnl + + # Commission khi rollover: open new straddle (2 legs) + commission_option_today = option_commission_per_straddle * notional + commission_today += commission_option_today + + # Reset position + current_position_value = row['mid_price'] + prev_delta_for_hedge = row['delta_norm'] # Delta mới sau rollover + df.at[row.name, 'portfolio_delta'] = row['delta_norm'] + else: + current_position_value = row['mid_price'] + + # === DAILY MTM CHANGE === + mtm_change = row['mid_price'] - row_prev['mid_price'] + df.at[row.name, 'mtm_change'] = mtm_change + + # === DISCRETE HEDGE === + prev_delta = row_prev['portfolio_delta'] + hedge_pnl = 0.0 + if abs(prev_delta) > hedge_threshold: + hedge_pnl = -prev_delta * ds + delta_change = abs(row['delta_norm'] - prev_delta) # Amount rebalanced + commission_hedge_today = delta_change * hedge_commission_per_unit_delta + commission_today += commission_hedge_today + + df.at[row.name, 'portfolio_delta'] = row['delta_norm'] # Rebalanced to new delta + prev_delta_for_hedge = row['delta_norm'] + + df.at[row.name, 'hedge_pnl'] = hedge_pnl + + # === TOTAL PNL SAU COMMISSION === + gross_pnl = mtm_change + hedge_pnl + net_pnl = gross_pnl - commission_today + df.at[row.name, 'pnl'] = net_pnl + + + # CUM PNL & CUM RETURN + df.at[row.name, 'cum_pnl'] = df.at[row_prev.name, 'cum_pnl'] + net_pnl + df.at[row.name, 'cum_return'] = df.at[row.name, 'cum_pnl'] / initial_capital # % return + + # === COMMISSION BREAKDOWN === + df.at[row.name, 'commission'] = commission_today + df.at[row.name, 'commission_option'] = commission_option_today + df.at[row.name, 'commission_hedge'] = commission_hedge_today + + # === GAMMA ATTRIB (tạm giữ, bạn sẽ fix sau) === + df.at[row.name, 'gamma_attrib'] = gamma_pnl_factor( + row_prev['gamma_norm'], S_prev, rv_actual, row_prev['iv_straddle'], dt_actual, notional + ) + + df.iloc[0]['cum_return'] = 0.0 + df.iloc[0]['cum_pnl'] = 0.0 + + return df + + +def build_synthetic_gamma_scalping_case( + *, + snapshots: int = 90, + seed: int = 42, + initial_spot: float = 100_000.0, + strike: float = 100_000.0, +) -> tuple[pd.DataFrame, OptionInstrumentRegistry, list[OptionPackageIntent]]: + """ + Build a deterministic ATM long-straddle case for the native option engine. + + The sample intentionally keeps one listed call/put alive across the whole + tape. This isolates option-package execution, quote-side fills, MTM, + prepared-cache replay, and delta-hedge accounting without mixing in + selection/rollover noise. + """ + rng = np.random.default_rng(seed) + start = pd.Timestamp("2026-01-01 00:00:00", tz="UTC") + expiry = int((start + pd.Timedelta(days=max(30, snapshots + 10))).value) + call_id = "BTC-26MAR26-100000-C.TEST" + put_id = "BTC-26MAR26-100000-P.TEST" + registry = OptionInstrumentRegistry.from_iterable( + ( + _linear_option_spec(call_id, strike, OptionKind.CALL, expiry), + _linear_option_spec(put_id, strike, OptionKind.PUT, expiry), + ) + ) + + rows = [] + spot = float(initial_spot) + for i in range(snapshots): + ts = start + pd.Timedelta(days=i) + timestamp_ns = int(ts.value) + spot *= float(np.exp(0.0002 + rng.normal(0.0, 0.018))) + dte = max((expiry - timestamp_ns) / (24 * 60 * 60 * 1_000_000_000), 1.0) + time_value = max(800.0 * np.sqrt(dte / 365.0), 80.0) + skew = np.tanh((spot - strike) / (0.08 * strike)) + call_delta = float(np.clip(0.50 + 0.35 * skew, 0.05, 0.95)) + put_delta = call_delta - 1.0 + + call_mark = max(spot - strike, 0.0) + time_value + put_mark = max(strike - spot, 0.0) + time_value * 0.98 + for sequence_id, instrument_id, option_kind, mark, delta in ( + (0, call_id, "call", call_mark, call_delta), + (1, put_id, "put", put_mark, put_delta), + ): + spread = max(mark * 0.004, 2.0) + rows.append( + { + "timestamp_ns": timestamp_ns, + "instrument_id": instrument_id, + "venue": "TEST", + "underlying_id": "BTC-PERP.TEST", + "expiry_ns": expiry, + "strike": strike, + "option_kind": option_kind, + "bid_price": max(mark - 0.5 * spread, 0.01), + "bid_size": 100.0, + "ask_price": mark + 0.5 * spread, + "ask_size": 100.0, + "mark_price": mark, + "last_price": mark, + "index_price": spot, + "forward_price": spot, + "mark_iv": 0.55, + "bid_iv": 0.54, + "ask_iv": 0.56, + "delta": delta, + "gamma": 0.00008, + "vega": 90.0, + "theta": -8.0, + "open_interest": 500.0, + "volume": 100.0, + "quote_currency": "USD", + "settlement_currency": "USD", + "sequence_id": sequence_id, + "source_latency_ns": 1_000_000, + } + ) + + chain = pd.DataFrame(rows) + timestamps = sorted(chain["timestamp_ns"].unique()) + packages = [ + OptionPackageIntent( + timestamp_ns=int(timestamps[0]), + package_id="gamma-open-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.BUY, 1.0, role="long_call"), + OptionPackageLeg(put_id, OrderSide.BUY, 1.0, role="long_put"), + ), + quantity=1.0, + tag="gamma_scalping_entry", + metadata={"strategy": "gamma_scalping", "action": "open"}, + ), + OptionPackageIntent( + timestamp_ns=int(timestamps[-1]), + package_id="gamma-close-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.SELL, 1.0, role="close_call"), + OptionPackageLeg(put_id, OrderSide.SELL, 1.0, role="close_put"), + ), + quantity=1.0, + tag="gamma_scalping_exit", + metadata={"strategy": "gamma_scalping", "action": "close"}, + ), + ] + return chain, registry, packages + + +def run_quantbt_gamma_scalping_sample(*, snapshots: int = 90, seed: int = 42) -> dict: + """Run the synthetic gamma-scalping sample through the public options endpoint.""" + chain, registry, packages = build_synthetic_gamma_scalping_case(snapshots=snapshots, seed=seed) + cache = OptionPreparedRunCache.from_chain(chain, registry) + bt = QuantBTEndpoint.options( + initial_capital=100_000.0, + reporting_currency="USD", + initial_balances={"USD": 100_000.0}, + fee_rate=0.0002, + metadata={"sample": "gamma_scalping_backtestsample", "seed": seed}, + ) + uncached = bt.backtest(chain=chain, instruments=registry, packages=packages) + cached = bt.backtest(chain=chain, instruments=registry, packages=packages, prepared_cache=cache) + + spots = ( + chain.sort_values(["timestamp_ns", "instrument_id"]) + .groupby("timestamp_ns", sort=True)["index_price"] + .first() + ) + deltas = ( + chain.assign(weighted_delta=chain["delta"]) + .groupby("timestamp_ns", sort=True)["weighted_delta"] + .sum() + ) + hedge = run_delta_hedge_path( + timestamps_ns=[int(ts) for ts in spots.index], + underlying_prices=spots.to_numpy(dtype=float), + net_option_deltas=deltas.to_numpy(dtype=float), + config=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + ) + + final_equity_diff = float(abs(uncached.equity.iloc[-1] - cached.equity.iloc[-1])) + fills_equal = bool(uncached.fills_report.equals(cached.fills_report)) + if final_equity_diff > 1e-9 or not fills_equal: + raise RuntimeError("prepared-cache gamma sample parity failed") + + report = { + "status": "pass", + "sample": "gamma_scalping_backtestsample", + "snapshots": int(snapshots), + "chain_rows": int(len(chain)), + "packages": int(len(packages)), + "fills": int(len(cached.fills_report)), + "initial_equity": float(cached.equity.iloc[0]), + "final_equity": float(cached.equity.iloc[-1]), + "option_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), + "hedge_pnl": float(hedge.hedge_pnl), + "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0] + hedge.hedge_pnl), + "hedge_rebalances": int(hedge.hedge_report["should_rebalance"].sum()), + "prepared_cache_used": bool(cached.metadata.get("prepared_cache_used")), + "package_cache_size": int(cached.metadata.get("package_cache_size", 0)), + "parity": { + "final_equity_abs_diff": final_equity_diff, + "fills_equal": fills_equal, + }, + "run_manifest": cached.run_manifest, + } + return report + + +def _linear_option_spec(symbol: str, strike: float, kind: OptionKind, expiry_ns: int) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="test", + underlying_id="BTC-PERP.TEST", + underlying_index_id="BTC-INDEX.TEST", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry_ns, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + tick_size=0.01, + convention_version="gamma_scalping_synthetic_linear_v1", + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a QuantBT options gamma-scalping smoke sample.") + parser.add_argument("--snapshots", type=int, default=90) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output-json", type=Path, default=None) + args = parser.parse_args() + + report = run_quantbt_gamma_scalping_sample(snapshots=args.snapshots, seed=args.seed) + payload = json.dumps(report, indent=2, default=str) + if args.output_json is not None: + args.output_json.write_text(payload + "\n", encoding="utf-8") + print(payload) + + +if __name__ == "__main__": + main() From 9e40a08d63c21381f2e7a77413583d01bee2eda9 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 15:03:13 +0000 Subject: [PATCH 14/15] test: run gamma scalping on real option history --- benchmarks/README.md | 8 + benchmarks/gamma_scalping_backtestsample.py | 329 +++++++++++++++++++- 2 files changed, 336 insertions(+), 1 deletion(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 9ddca56..277d5e5 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -67,6 +67,10 @@ Options Phase 10: ```bash python3 benchmarks/run_options_engine.py --snapshots 96 --contracts 48 --packages 96 --repeats 3 python3 benchmarks/gamma_scalping_backtestsample.py --snapshots 90 --seed 42 +python3 benchmarks/gamma_scalping_backtestsample.py \ + --real-options-csv /root/bobby/pool_alpha/alphas_storage/option_based/options_full_history.csv.gz \ + --underlying-source spot \ + --hedge-timeframe 1h ``` - `options_phase10_baseline.*` records prepared-tape and compiled-package cache @@ -77,5 +81,9 @@ python3 benchmarks/gamma_scalping_backtestsample.py --snapshots 90 --seed 42 smoke sample. It keeps the original research helpers, then runs the public `QuantBTEndpoint.options(...)` path with prepared-cache parity and a separate delta-hedge path report. +- The real-data mode converts legacy Binance options CSV history into QuantBT's + canonical option-chain schema, selects an ATM call/put pair with entry/exit + quotes, and loads BTCUSDT spot or USD-M perpetual candles from `_get_data` for + hedge-path accounting. - Cython/C++ should only be considered after a larger profile shows pure kernels, not pandas/tape/report facade work, dominating runtime. diff --git a/benchmarks/gamma_scalping_backtestsample.py b/benchmarks/gamma_scalping_backtestsample.py index a72a7dd..47e8462 100644 --- a/benchmarks/gamma_scalping_backtestsample.py +++ b/benchmarks/gamma_scalping_backtestsample.py @@ -431,6 +431,323 @@ def run_quantbt_gamma_scalping_sample(*, snapshots: int = 90, seed: int = 42) -> return report +def run_real_binance_gamma_scalping_sample( + *, + options_csv: Path, + underlying_source: str = "spot", + hedge_timeframe: str = "1h", +) -> dict: + """ + Run the gamma-scalping sample on a real Binance options snapshot CSV. + + The CSV is converted into QuantBT's canonical option-chain schema. BTCUSDT + spot/perp candles are loaded from `_get_data` for the hedge path; if that + loader is unavailable for the requested range, the snapshot `spot_BTCUSDT` + column is used as a transparent fallback. + """ + raw = pd.read_csv(options_csv, compression="gzip") + chain, registry = canonicalize_binance_options_history(raw) + packages, selected = build_real_atm_straddle_packages(chain) + cache = OptionPreparedRunCache.from_chain(chain, registry) + + bt = QuantBTEndpoint.options( + initial_capital=100_000.0, + reporting_currency="USD", + initial_balances={"USD": 100_000.0}, + fee_rate=0.0002, + metadata={ + "sample": "real_binance_gamma_scalping", + "source_file": str(options_csv), + "underlying_source": underlying_source, + "hedge_timeframe": hedge_timeframe, + }, + ) + uncached = bt.backtest(chain=chain, instruments=registry, packages=packages) + cached = bt.backtest(chain=chain, instruments=registry, packages=packages, prepared_cache=cache) + final_equity_diff = float(abs(uncached.equity.iloc[-1] - cached.equity.iloc[-1])) + fills_equal = bool(uncached.fills_report.equals(cached.fills_report)) + if final_equity_diff > 1e-9 or not fills_equal: + raise RuntimeError("real Binance options prepared-cache parity failed") + + timestamps = sorted(chain["timestamp_ns"].unique()) + hedge_prices, hedge_price_source = load_underlying_prices_for_chain( + chain, + source=underlying_source, + timeframe=hedge_timeframe, + ) + net_deltas = selected_straddle_delta_path(chain, selected) + hedge = run_delta_hedge_path( + timestamps_ns=timestamps, + underlying_prices=hedge_prices.reindex(timestamps).ffill().bfill().to_numpy(dtype=float), + net_option_deltas=net_deltas.reindex(timestamps).fillna(0.0).to_numpy(dtype=float), + config=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + ) + + report = { + "status": "pass", + "sample": "real_binance_gamma_scalping", + "source_file": str(options_csv), + "snapshots": int(chain["timestamp_ns"].nunique()), + "chain_rows": int(len(chain)), + "contracts": int(len(registry.instruments)), + "packages": int(len(packages)), + "fills": int(len(cached.fills_report)), + "selected": selected, + "initial_equity": float(cached.equity.iloc[0]), + "final_equity": float(cached.equity.iloc[-1]), + "option_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), + "hedge_pnl": float(hedge.hedge_pnl), + "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0] + hedge.hedge_pnl), + "hedge_rebalances": int(hedge.hedge_report["should_rebalance"].sum()), + "hedge_price_source": hedge_price_source, + "prepared_cache_used": bool(cached.metadata.get("prepared_cache_used")), + "package_cache_size": int(cached.metadata.get("package_cache_size", 0)), + "parity": { + "final_equity_abs_diff": final_equity_diff, + "fills_equal": fills_equal, + }, + "run_manifest": cached.run_manifest, + } + return report + + +def canonicalize_binance_options_history(raw: pd.DataFrame) -> tuple[pd.DataFrame, OptionInstrumentRegistry]: + """Convert the legacy Binance option snapshot CSV into QuantBT canonical schema.""" + required = { + "snapshot_time", + "symbol", + "spot_BTCUSDT", + "markPrice", + "bidPrice", + "askPrice", + "bidIV", + "askIV", + "markIV", + "delta", + "theta", + "gamma", + "vega", + "volume", + "strikePrice", + } + missing = sorted(required.difference(raw.columns)) + if missing: + raise ValueError(f"real options CSV missing required columns: {missing}") + + df = raw.copy() + df["snapshot_time"] = pd.to_datetime(df["snapshot_time"], utc=True, errors="coerce") + df = df.dropna(subset=["snapshot_time", "symbol"]) + parsed = df["symbol"].astype(str).str.extract(r"^(?P[A-Z]+)-(?P\d{6})-(?P\d+(?:\.\d+)?)-(?P[CP])$") + df = df.join(parsed) + df = df.dropna(subset=["underlying", "expiry", "strike", "kind"]) + df["timestamp_ns"] = df["snapshot_time"].astype("int64") + df["expiry_ns"] = df["expiry"].map(_binance_expiry_to_ns).astype("int64") + df["strike"] = pd.to_numeric(df["strike"], errors="coerce") + + numeric_pairs = { + "bidPrice": "bid_price", + "askPrice": "ask_price", + "markPrice": "mark_price", + "spot_BTCUSDT": "index_price", + "exercisePrice": "forward_price", + "bidIV": "bid_iv", + "askIV": "ask_iv", + "markIV": "mark_iv", + "delta": "delta", + "gamma": "gamma", + "vega": "vega", + "theta": "theta", + "volume": "volume", + } + for source, target in numeric_pairs.items(): + df[target] = pd.to_numeric(df[source], errors="coerce") + df["forward_price"] = df["forward_price"].fillna(df["index_price"]) + if "lastPrice" in df: + df["last_price"] = pd.to_numeric(df["lastPrice"], errors="coerce").fillna(df["mark_price"]) + else: + df["last_price"] = df["mark_price"] + df["bid_size"] = pd.to_numeric(df.get("lastQty", 1.0), errors="coerce").fillna(1.0).clip(lower=1.0) + df["ask_size"] = df["bid_size"] + df["open_interest"] = 1.0 + if "amount" in df: + df["open_interest"] = pd.to_numeric(df["amount"], errors="coerce").fillna(1.0).clip(lower=1.0) + + df = df[(df["bid_price"] > 0.0) & (df["ask_price"] > 0.0)] + df = df[df["ask_price"] >= df["bid_price"]] + df = df[df["expiry_ns"] > df["timestamp_ns"]] + df = df.dropna(subset=["strike", "index_price", "forward_price", "mark_price"]) + df = df.sort_values(["timestamp_ns", "symbol"]).reset_index(drop=True) + df["sequence_id"] = df.groupby("timestamp_ns").cumcount().astype("int64") + df["source_latency_ns"] = 1_000_000 + df["option_kind"] = np.where(df["kind"] == "C", "call", "put") + df["instrument_id"] = df["symbol"].astype(str) + ".BINANCE" + df["underlying_id"] = df["underlying"].astype(str) + "USDT.BINANCE" + df["venue"] = "BINANCE" + df["quote_currency"] = "USD" + df["settlement_currency"] = "USD" + + canonical = df[ + [ + "timestamp_ns", + "instrument_id", + "venue", + "underlying_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "last_price", + "index_price", + "forward_price", + "mark_iv", + "bid_iv", + "ask_iv", + "delta", + "gamma", + "vega", + "theta", + "open_interest", + "volume", + "quote_currency", + "settlement_currency", + "sequence_id", + "source_latency_ns", + ] + ].copy() + + specs = [] + static = canonical.drop_duplicates("instrument_id").sort_values("instrument_id") + for row in static.itertuples(index=False): + specs.append( + OptionInstrumentSpec( + symbol=row.instrument_id, + venue="binance", + underlying_id=row.underlying_id, + underlying_index_id="BTCUSDT-INDEX.BINANCE", + option_kind=OptionKind.CALL if row.option_kind == "call" else OptionKind.PUT, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=float(row.strike), + expiry_ns=int(row.expiry_ns), + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=0.001, + tick_size=0.01, + convention_version="binance_options_history_csv_v1", + ) + ) + return canonical, OptionInstrumentRegistry.from_iterable(specs) + + +def build_real_atm_straddle_packages(chain: pd.DataFrame) -> tuple[list[OptionPackageIntent], dict]: + """Select a real ATM call/put pair available at entry and exit.""" + timestamps = sorted(chain["timestamp_ns"].unique()) + entry_ts = int(timestamps[0]) + exit_ts = int(timestamps[-1]) + entry = chain[chain["timestamp_ns"] == entry_ts].copy() + exit_symbols = set(chain.loc[chain["timestamp_ns"] == exit_ts, "instrument_id"]) + entry = entry[entry["instrument_id"].isin(exit_symbols)] + pair_counts = entry.groupby(["expiry_ns", "strike"])["option_kind"].agg(lambda values: set(values)) + valid_pairs = [key for key, kinds in pair_counts.items() if kinds == {"call", "put"}] + if not valid_pairs: + raise ValueError("no entry ATM straddle pair survives until final snapshot") + spot = float(entry["index_price"].median()) + expiry_ns, strike = min(valid_pairs, key=lambda key: (abs(float(key[1]) - spot), int(key[0]))) + selected_rows = entry[(entry["expiry_ns"] == expiry_ns) & (entry["strike"] == strike)] + call_id = str(selected_rows.loc[selected_rows["option_kind"] == "call", "instrument_id"].iloc[0]) + put_id = str(selected_rows.loc[selected_rows["option_kind"] == "put", "instrument_id"].iloc[0]) + selected = { + "entry_timestamp_ns": entry_ts, + "exit_timestamp_ns": exit_ts, + "entry_time": str(pd.Timestamp(entry_ts, tz="UTC")), + "exit_time": str(pd.Timestamp(exit_ts, tz="UTC")), + "spot": spot, + "strike": float(strike), + "expiry": str(pd.Timestamp(int(expiry_ns), tz="UTC")), + "call_id": call_id, + "put_id": put_id, + } + packages = [ + OptionPackageIntent( + timestamp_ns=entry_ts, + package_id="real-gamma-open-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.BUY, 1.0, role="long_call"), + OptionPackageLeg(put_id, OrderSide.BUY, 1.0, role="long_put"), + ), + quantity=1.0, + tag="real_gamma_scalping_entry", + metadata={"strategy": "gamma_scalping", "action": "open", **selected}, + ), + OptionPackageIntent( + timestamp_ns=exit_ts, + package_id="real-gamma-close-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.SELL, 1.0, role="close_call"), + OptionPackageLeg(put_id, OrderSide.SELL, 1.0, role="close_put"), + ), + quantity=1.0, + tag="real_gamma_scalping_exit", + metadata={"strategy": "gamma_scalping", "action": "close", **selected}, + ), + ] + return packages, selected + + +def selected_straddle_delta_path(chain: pd.DataFrame, selected: dict) -> pd.Series: + active = chain[chain["instrument_id"].isin([selected["call_id"], selected["put_id"]])] + delta = active.groupby("timestamp_ns")["delta"].sum().sort_index() + delta.loc[int(selected["exit_timestamp_ns"])] = 0.0 + return delta.sort_index() + + +def load_underlying_prices_for_chain(chain: pd.DataFrame, *, source: str, timeframe: str) -> tuple[pd.Series, str]: + timestamps = sorted(chain["timestamp_ns"].unique()) + start = pd.Timestamp(int(timestamps[0]), tz="UTC").tz_localize(None) + end = pd.Timestamp(int(timestamps[-1]), tz="UTC").tz_localize(None) + dataset = "binance_spot_1m" if source == "spot" else "crypto_1m" + try: + get_data_path = Path("/root/bobby/pool_alpha/alphas_storage/_get_data") + if str(get_data_path) not in sys.path: + sys.path.insert(0, str(get_data_path)) + from data_loader import load_data # type: ignore + + ohlcv = load_data( + dataset, + symbols="BTCUSDT", + start_date=str(start), + end_date=str(end), + timeframe=timeframe, + check_val=False, + ) + if not ohlcv.empty: + out = ohlcv.copy() + out["timestamp_ns"] = pd.to_datetime(out["time"], utc=True).astype("int64") + series = out.set_index("timestamp_ns")["close"].sort_index() + return series, dataset + except Exception as exc: + fallback = chain.groupby("timestamp_ns")["index_price"].first().sort_index() + return fallback, f"option_chain_index_price_fallback:{exc}" + fallback = chain.groupby("timestamp_ns")["index_price"].first().sort_index() + return fallback, "option_chain_index_price_fallback:no_loader_rows" + + +def _binance_expiry_to_ns(value: str) -> int: + text = str(value) + year = 2000 + int(text[:2]) + month = int(text[2:4]) + day = int(text[4:6]) + return int(pd.Timestamp(year=year, month=month, day=day, hour=8, tz="UTC").value) + + def _linear_option_spec(symbol: str, strike: float, kind: OptionKind, expiry_ns: int) -> OptionInstrumentSpec: return OptionInstrumentSpec( symbol=symbol, @@ -458,10 +775,20 @@ def main() -> None: parser = argparse.ArgumentParser(description="Run a QuantBT options gamma-scalping smoke sample.") parser.add_argument("--snapshots", type=int, default=90) parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--real-options-csv", type=Path, default=None) + parser.add_argument("--underlying-source", choices=("spot", "perp"), default="spot") + parser.add_argument("--hedge-timeframe", default="1h") parser.add_argument("--output-json", type=Path, default=None) args = parser.parse_args() - report = run_quantbt_gamma_scalping_sample(snapshots=args.snapshots, seed=args.seed) + if args.real_options_csv is not None: + report = run_real_binance_gamma_scalping_sample( + options_csv=args.real_options_csv, + underlying_source=args.underlying_source, + hedge_timeframe=args.hedge_timeframe, + ) + else: + report = run_quantbt_gamma_scalping_sample(snapshots=args.snapshots, seed=args.seed) payload = json.dumps(report, indent=2, default=str) if args.output_json is not None: args.output_json.write_text(payload + "\n", encoding="utf-8") From 86aea18700ae9ff53fc921c058086a6e3b0217fa Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Thu, 23 Jul 2026 16:37:28 +0000 Subject: [PATCH 15/15] feat: add delta hedged option strategy contract --- __init__.py | 6 + backends/native_option.py | 261 +++++++++++++++++- benchmarks/README.md | 7 +- benchmarks/gamma_scalping_backtestsample.py | 99 +++---- core/results.py | 8 + docs/endpoint.md | 68 ++++- endpoint.py | 28 +- engines.py | 20 +- options/__init__.py | 4 + options/strategy.py | 249 +++++++++++++++++ .../options/test_strategy_adapter_contract.py | 157 +++++++++++ .../quantbt_options_engine_execution_plan.md | 93 ++++++- 12 files changed, 944 insertions(+), 56 deletions(-) create mode 100644 options/strategy.py create mode 100644 tests/options/test_strategy_adapter_contract.py diff --git a/__init__.py b/__init__.py index d3908c6..e2e5298 100644 --- a/__init__.py +++ b/__init__.py @@ -182,6 +182,7 @@ CANONICAL_OPTION_CHAIN_COLUMNS, ExerciseStyle, ExternalOptionMarginValidator, + GammaScalpingConfig, HedgeDecision, HedgePathResult, IVStatus, @@ -214,6 +215,7 @@ OptionSelectionFilters, OptionSettlementRepresentation, OptionSettlementResult, + OptionStrategyRun, OptionTapeSignature, OptionVenueConvention, PremiumConvention, @@ -228,6 +230,7 @@ black76_parity_residual, black76_parity_value, black76_price, + build_gamma_scalping_strategy_run, butterfly, calculate_option_fee, calculate_option_margin, @@ -346,6 +349,7 @@ "CANONICAL_OPTION_CHAIN_COLUMNS", "ExerciseStyle", "ExternalOptionMarginValidator", + "GammaScalpingConfig", "HedgeDecision", "HedgePathResult", "IVStatus", @@ -378,6 +382,7 @@ "OptionSelectionFilters", "OptionSettlementRepresentation", "OptionSettlementResult", + "OptionStrategyRun", "OptionTapeSignature", "OptionVenueConvention", "PremiumConvention", @@ -392,6 +397,7 @@ "black76_parity_residual", "black76_parity_value", "black76_price", + "build_gamma_scalping_strategy_run", "butterfly", "calculate_option_fee", "calculate_option_margin", diff --git a/backends/native_option.py b/backends/native_option.py index a7ca32f..9713557 100644 --- a/backends/native_option.py +++ b/backends/native_option.py @@ -20,6 +20,7 @@ from ..options.cache import OptionPreparedRunCache from ..options.execution import OptionExecutionConfig, execute_option_package from ..options.fees import OptionFeeResult, OptionFeeSchedule, calculate_option_fee +from ..options.hedging import OptionHedgeConfig, run_delta_hedge_path from ..options.ledger import OptionLedger from ..options.lifecycle import OptionSettlementRepresentation, settle_option_expiry from ..options.margin import OptionMarginConfig, OptionMarginRequirement, calculate_option_margin @@ -72,6 +73,9 @@ def run( packages: Sequence[OptionPackageIntent] = (), prepared_tape: Optional[PreparedOptionTape] = None, prepared_cache: Optional[OptionPreparedRunCache] = None, + underlying: Optional[pd.DataFrame | pd.Series] = None, + hedge_policy: Optional[OptionHedgeConfig] = None, + net_option_delta: Optional[pd.Series] = None, settlement_events: Optional[Sequence[OptionSettlementEvent | Mapping]] = None, conversion_rates: Optional[Dict[str, float]] = None, reporting_currency: Optional[str] = None, @@ -165,7 +169,7 @@ def run( ) snapshots.append(_snapshot_state(tape, final_snapshot_idx, ledger, instrument_map, rates, report_ccy, "final")) - return _build_result( + result = _build_result( tape=tape, registry=registry, ledger=ledger, @@ -198,6 +202,18 @@ def run( **self.config.metadata, }, ) + if hedge_policy is not None: + result = _attach_delta_hedge_contract( + result, + tape=tape, + registry=registry, + underlying=underlying, + hedge_policy=hedge_policy, + net_option_delta=net_option_delta, + account=self.config.account, + report_ccy=report_ccy, + ) + return result def _normalize_registry( @@ -425,6 +441,249 @@ def _stable_hash(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] +def _attach_delta_hedge_contract( + result: OptionBacktestResult, + *, + tape: PreparedOptionTape, + registry: OptionInstrumentRegistry, + underlying: Optional[pd.DataFrame | pd.Series], + hedge_policy: OptionHedgeConfig, + net_option_delta: Optional[pd.Series], + account: AccountConfig, + report_ccy: str, +) -> OptionBacktestResult: + path_timestamps = np.concatenate((np.array([int(tape.timestamp_ns[0]) - 1], dtype=np.int64), tape.timestamp_ns.astype(np.int64))) + index = _datetime_index_from_ns(path_timestamps) + option_equity, positions, closes, fees = _linear_quote_option_path( + result, + tape, + registry, + account, + report_ccy, + index, + path_timestamps, + ) + deltas = _normalize_net_delta(net_option_delta, result.greeks_report, positions, registry, index) + prices, underlying_source = _normalize_underlying_prices(underlying, tape, index) + + hedge = run_delta_hedge_path( + timestamps_ns=list(path_timestamps), + underlying_prices=prices.to_numpy(dtype=np.float64), + net_option_deltas=deltas.to_numpy(dtype=np.float64), + config=hedge_policy, + ) + hedge_report = hedge.hedge_report.copy() + hedge_report.index = index + cumulative_hedge = pd.Series( + hedge_report["cumulative_hedge_pnl"].to_numpy(dtype=np.float64), + index=index, + name="hedge_pnl", + ) + combined = (option_equity + cumulative_hedge).rename("equity") + combined_returns = combined.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + + result.option_equity = option_equity + result.hedge_report = hedge_report + result.combined_equity = combined + result.combined_returns = combined_returns + result.equity = combined + result.returns = combined_returns + result.positions = positions + result.closes = closes + result.fees = fees + result.metadata["option_equity"] = option_equity + result.metadata["hedge_report"] = hedge_report + result.metadata["combined_equity"] = combined + result.metadata["combined_returns"] = combined_returns + result.metadata["delta_hedge_contract"] = { + "enabled": True, + "underlying_source": underlying_source, + "policy": hedge_policy.policy.value, + "target_delta": float(hedge_policy.target_delta), + "final_hedge_qty": float(hedge.final_hedge_qty), + "hedge_pnl": float(hedge.hedge_pnl), + "hedge_rebalances": int(hedge_report["should_rebalance"].sum()) if not hedge_report.empty else 0, + "option_path_method": result.metadata.get("option_path_method", "linear_quote_replay"), + } + result.run_manifest["delta_hedge"] = result.metadata["delta_hedge_contract"] + result.run_manifest["final_equity"] = float(combined.iloc[-1]) + result.metadata["run_manifest"] = result.run_manifest + return result + + +def _linear_quote_option_path( + result: OptionBacktestResult, + tape: PreparedOptionTape, + registry: OptionInstrumentRegistry, + account: AccountConfig, + report_ccy: str, + index: pd.DatetimeIndex, + path_timestamps: np.ndarray, +) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame, pd.Series]: + symbols = list(registry.symbols) + linear_quote_exact = all( + instrument.premium_currency.upper() == report_ccy and instrument.settlement_currency.upper() == report_ccy + for instrument in registry.instruments + ) + if not linear_quote_exact: + option_equity = result.equity.reindex(index).ffill().bfill().rename("option_equity") + positions = result.positions.reindex(index).ffill().fillna(0.0) + closes = result.closes.reindex(index).ffill().bfill() + fees = result.fees.reindex(index).fillna(0.0) + result.metadata["option_path_method"] = "event_equity_reindexed_non_quote_currency" + return option_equity, positions, closes, fees + + cash = float(account.initial_capital) + pos = {symbol: 0.0 for symbol in symbols} + fills = result.fills_report.sort_values("timestamp") if not result.fills_report.empty else pd.DataFrame() + fill_idx = 0 + equity_rows = [] + position_rows = [] + close_rows = [] + fee_values = [] + mark_by_ts_symbol = _mark_lookup(tape) + + for ts, dt in zip(path_timestamps, index): + snap_idx = max(0, int(np.searchsorted(tape.timestamp_ns, int(ts), side="right") - 1)) + fee_at_ts = 0.0 + while not fills.empty and fill_idx < len(fills) and int(fills.iloc[fill_idx]["timestamp"]) <= int(ts): + row = fills.iloc[fill_idx] + qty = float(row["qty"]) + price = float(row["price"]) + fee = float(row.get("applied_fee", row.get("execution_fee", 0.0))) + symbol = str(row["symbol"]) + side = str(row["side"]).lower() + if side == "buy": + cash -= qty * price + fee + pos[symbol] = pos.get(symbol, 0.0) + qty + else: + cash += qty * price - fee + pos[symbol] = pos.get(symbol, 0.0) - qty + fee_at_ts += fee + fill_idx += 1 + mark_ts = int(tape.timestamp_ns[snap_idx]) + marks = {symbol: mark_by_ts_symbol.get((mark_ts, symbol), np.nan) for symbol in symbols} + marked_value = sum(pos.get(symbol, 0.0) * marks[symbol] for symbol in symbols if np.isfinite(marks[symbol])) + equity_rows.append(cash + marked_value) + position_rows.append({f"Position_{symbol}": pos.get(symbol, 0.0) for symbol in symbols}) + close_rows.append({f"Close_{symbol}": marks[symbol] for symbol in symbols}) + fee_values.append(fee_at_ts) + + option_equity = pd.Series(equity_rows, index=index, name="option_equity") + positions = pd.DataFrame(position_rows, index=index).fillna(0.0) + closes = pd.DataFrame(close_rows, index=index).ffill().bfill() + fees = pd.Series(fee_values, index=index, name="fees") + result.metadata["option_path_method"] = "linear_quote_replay" + return option_equity, positions, closes, fees + + +def _normalize_net_delta( + net_option_delta: Optional[pd.Series], + greeks_report: pd.DataFrame, + positions: pd.DataFrame, + registry: OptionInstrumentRegistry, + index: pd.DatetimeIndex, +) -> pd.Series: + if net_option_delta is not None: + series = _coerce_series_index(net_option_delta, "net_option_delta") + return series.reindex(index).ffill().bfill().fillna(0.0).rename("net_option_delta") + if greeks_report.empty: + return pd.Series(0.0, index=index, name="net_option_delta") + greeks = greeks_report.copy() + greeks["datetime"] = pd.to_datetime(greeks["timestamp_ns"], utc=True).dt.tz_convert(None) + delta = greeks.pivot_table(index="datetime", columns="instrument_id", values="delta", aggfunc="last").reindex(index).ffill() + total = pd.Series(0.0, index=index, name="net_option_delta") + instruments = registry.by_symbol + for symbol in registry.symbols: + pos_col = f"Position_{symbol}" + if pos_col not in positions or symbol not in delta: + continue + multiplier = float(instruments[symbol].multiplier) + contribution = pd.Series( + positions[pos_col].to_numpy(dtype=np.float64) * delta[symbol].fillna(0.0).to_numpy(dtype=np.float64) * multiplier, + index=index, + ) + total = total.add(contribution, fill_value=0.0) + return total.fillna(0.0).rename("net_option_delta") + + +def _normalize_underlying_prices( + underlying: Optional[pd.DataFrame | pd.Series], + tape: PreparedOptionTape, + index: pd.DatetimeIndex, +) -> tuple[pd.Series, str]: + if underlying is None: + tape_index = _datetime_index_from_ns(tape.timestamp_ns.astype(np.int64)) + base = pd.Series( + [_snapshot_underlying_price(tape, i) for i in range(tape.snapshot_count)], + index=tape_index, + name="underlying_price", + ) + return _align_price_series(base, index), "option_chain_index_price" + if isinstance(underlying, pd.Series): + series = _coerce_series_index(underlying, "underlying_price") + return _align_price_series(series, index), "underlying_series" + if not isinstance(underlying, pd.DataFrame): + raise TypeError("underlying must be a pandas Series or DataFrame") + frame = underlying.copy() + if "timestamp_ns" in frame.columns: + idx = pd.to_datetime(frame["timestamp_ns"].astype("int64"), utc=True).dt.tz_convert(None) + elif "time" in frame.columns: + idx = pd.to_datetime(frame["time"], utc=True, errors="coerce").dt.tz_convert(None) + elif isinstance(frame.index, pd.DatetimeIndex): + idx = pd.DatetimeIndex(pd.to_datetime(frame.index, utc=True)).tz_convert(None) + else: + raise ValueError("underlying DataFrame requires timestamp_ns, time, or DatetimeIndex") + column = "close" if "close" in frame.columns else ("price" if "price" in frame.columns else None) + if column is None: + raise ValueError("underlying DataFrame requires close or price column") + series = pd.Series(pd.to_numeric(frame[column], errors="raise").to_numpy(dtype=np.float64), index=idx, name="underlying_price") + return _align_price_series(series, index), f"underlying_dataframe:{column}" + + +def _align_price_series(series: pd.Series, index: pd.DatetimeIndex) -> pd.Series: + out = series.sort_index() + out = out[~out.index.duplicated(keep="last")] + out = out.reindex(index).ffill().bfill() + if out.isna().any() or bool((out <= 0.0).any()): + raise ValueError("underlying prices must align to option tape and be finite > 0") + return out.rename("underlying_price") + + +def _coerce_series_index(series: pd.Series, name: str) -> pd.Series: + out = series.copy() + if not isinstance(out.index, pd.DatetimeIndex): + out.index = pd.to_datetime(out.index, utc=True) + else: + out.index = pd.DatetimeIndex(pd.to_datetime(out.index, utc=True)) + out.index = out.index.tz_convert(None) + out = pd.to_numeric(out, errors="raise").astype("float64") + out.name = name + return out + + +def _datetime_index_from_ns(timestamps_ns: np.ndarray) -> pd.DatetimeIndex: + return pd.DatetimeIndex(pd.to_datetime(timestamps_ns, utc=True)).tz_convert(None) + + +def _mark_lookup(tape: PreparedOptionTape) -> Dict[tuple[int, str], float]: + out: Dict[tuple[int, str], float] = {} + for snap_idx, ts in enumerate(tape.timestamp_ns): + slc = tape.snapshot_slice(snap_idx) + for idx in range(slc.start, slc.stop): + out[(int(ts), tape.instrument_id[idx])] = float(tape.mark_price[idx]) + return out + + +def _snapshot_underlying_price(tape: PreparedOptionTape, snapshot_idx: int) -> float: + rows = tape.snapshot_slice(snapshot_idx) + for idx in range(rows.start, rows.stop): + price = tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx] + if np.isfinite(price) and price > 0.0: + return float(price) + raise ValueError("option tape snapshot has no finite underlying/index price") + + def _cash_report(snapshots: Sequence[Dict], index: pd.DatetimeIndex) -> pd.DataFrame: currencies = sorted({currency for snap in snapshots for currency in snap["cash"]}) return pd.DataFrame( diff --git a/benchmarks/README.md b/benchmarks/README.md index 277d5e5..371111b 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -79,11 +79,12 @@ python3 benchmarks/gamma_scalping_backtestsample.py \ memory, uncached runtime, cached runtime, and run-manifest hashes. - `gamma_scalping_backtestsample.py` is a runnable long-straddle gamma-scalping smoke sample. It keeps the original research helpers, then runs the public - `QuantBTEndpoint.options(...)` path with prepared-cache parity and a separate - delta-hedge path report. + `QuantBTEndpoint.options(...)` path through + `build_gamma_scalping_strategy_run(...)`, `strategy_run`, `underlying`, and + prepared-cache parity. - The real-data mode converts legacy Binance options CSV history into QuantBT's canonical option-chain schema, selects an ATM call/put pair with entry/exit quotes, and loads BTCUSDT spot or USD-M perpetual candles from `_get_data` for - hedge-path accounting. + first-class delta-hedged combined-equity accounting. - Cython/C++ should only be considered after a larger profile shows pure kernels, not pandas/tape/report facade work, dominating runtime. diff --git a/benchmarks/gamma_scalping_backtestsample.py b/benchmarks/gamma_scalping_backtestsample.py index 47e8462..c9e44c4 100644 --- a/benchmarks/gamma_scalping_backtestsample.py +++ b/benchmarks/gamma_scalping_backtestsample.py @@ -15,6 +15,7 @@ from quantbt import ( # noqa: E402 ExerciseStyle, + GammaScalpingConfig, OptionHedgeConfig, OptionHedgePolicyType, OptionInstrumentRegistry, @@ -27,7 +28,7 @@ PremiumConvention, QuantBTEndpoint, SettlementStyle, - run_delta_hedge_path, + build_gamma_scalping_strategy_run, ) def filter_atm_options(df: pd.DataFrame, iv_rank_threshold: float = 101.0, # Tạm set cao để bypass IV rank check @@ -373,8 +374,17 @@ def build_synthetic_gamma_scalping_case( def run_quantbt_gamma_scalping_sample(*, snapshots: int = 90, seed: int = 42) -> dict: """Run the synthetic gamma-scalping sample through the public options endpoint.""" - chain, registry, packages = build_synthetic_gamma_scalping_case(snapshots=snapshots, seed=seed) + chain, registry, _ = build_synthetic_gamma_scalping_case(snapshots=snapshots, seed=seed) + strategy_run = build_gamma_scalping_strategy_run( + chain, + registry, + GammaScalpingConfig( + hedge_policy=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + ), + ) cache = OptionPreparedRunCache.from_chain(chain, registry) + underlying = chain.groupby("timestamp_ns", sort=True)["index_price"].first() + underlying.index = pd.to_datetime(underlying.index, utc=True).tz_convert(None) bt = QuantBTEndpoint.options( initial_capital=100_000.0, reporting_currency="USD", @@ -382,25 +392,8 @@ def run_quantbt_gamma_scalping_sample(*, snapshots: int = 90, seed: int = 42) -> fee_rate=0.0002, metadata={"sample": "gamma_scalping_backtestsample", "seed": seed}, ) - uncached = bt.backtest(chain=chain, instruments=registry, packages=packages) - cached = bt.backtest(chain=chain, instruments=registry, packages=packages, prepared_cache=cache) - - spots = ( - chain.sort_values(["timestamp_ns", "instrument_id"]) - .groupby("timestamp_ns", sort=True)["index_price"] - .first() - ) - deltas = ( - chain.assign(weighted_delta=chain["delta"]) - .groupby("timestamp_ns", sort=True)["weighted_delta"] - .sum() - ) - hedge = run_delta_hedge_path( - timestamps_ns=[int(ts) for ts in spots.index], - underlying_prices=spots.to_numpy(dtype=float), - net_option_deltas=deltas.to_numpy(dtype=float), - config=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), - ) + uncached = bt.backtest(chain=chain, instruments=registry, strategy_run=strategy_run, underlying=underlying) + cached = bt.backtest(chain=chain, instruments=registry, strategy_run=strategy_run, underlying=underlying, prepared_cache=cache) final_equity_diff = float(abs(uncached.equity.iloc[-1] - cached.equity.iloc[-1])) fills_equal = bool(uncached.fills_report.equals(cached.fills_report)) @@ -412,14 +405,15 @@ def run_quantbt_gamma_scalping_sample(*, snapshots: int = 90, seed: int = 42) -> "sample": "gamma_scalping_backtestsample", "snapshots": int(snapshots), "chain_rows": int(len(chain)), - "packages": int(len(packages)), + "packages": int(len(strategy_run.packages)), "fills": int(len(cached.fills_report)), "initial_equity": float(cached.equity.iloc[0]), "final_equity": float(cached.equity.iloc[-1]), - "option_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), - "hedge_pnl": float(hedge.hedge_pnl), - "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0] + hedge.hedge_pnl), - "hedge_rebalances": int(hedge.hedge_report["should_rebalance"].sum()), + "option_pnl": float(cached.option_equity.iloc[-1] - cached.option_equity.iloc[0]), + "hedge_pnl": float(cached.hedge_report["cumulative_hedge_pnl"].iloc[-1]), + "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), + "hedge_rebalances": int(cached.hedge_report["should_rebalance"].sum()), + "selected_contracts": cached.metadata["selected_contracts"].to_dict("records"), "prepared_cache_used": bool(cached.metadata.get("prepared_cache_used")), "package_cache_size": int(cached.metadata.get("package_cache_size", 0)), "parity": { @@ -447,8 +441,23 @@ def run_real_binance_gamma_scalping_sample( """ raw = pd.read_csv(options_csv, compression="gzip") chain, registry = canonicalize_binance_options_history(raw) - packages, selected = build_real_atm_straddle_packages(chain) + strategy_run = build_gamma_scalping_strategy_run( + chain, + registry, + GammaScalpingConfig( + min_dte_days=10.0, + max_dte_days=21.0, + max_spread_bps=2_000.0, + hedge_policy=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + metadata={"source": "real_binance_csv"}, + ), + ) cache = OptionPreparedRunCache.from_chain(chain, registry) + hedge_prices, hedge_price_source = load_underlying_prices_for_chain( + chain, + source=underlying_source, + timeframe=hedge_timeframe, + ) bt = QuantBTEndpoint.options( initial_capital=100_000.0, @@ -462,26 +471,20 @@ def run_real_binance_gamma_scalping_sample( "hedge_timeframe": hedge_timeframe, }, ) - uncached = bt.backtest(chain=chain, instruments=registry, packages=packages) - cached = bt.backtest(chain=chain, instruments=registry, packages=packages, prepared_cache=cache) + uncached = bt.backtest(chain=chain, instruments=registry, strategy_run=strategy_run, underlying=hedge_prices) + cached = bt.backtest( + chain=chain, + instruments=registry, + strategy_run=strategy_run, + underlying=hedge_prices, + prepared_cache=cache, + ) final_equity_diff = float(abs(uncached.equity.iloc[-1] - cached.equity.iloc[-1])) fills_equal = bool(uncached.fills_report.equals(cached.fills_report)) if final_equity_diff > 1e-9 or not fills_equal: raise RuntimeError("real Binance options prepared-cache parity failed") - timestamps = sorted(chain["timestamp_ns"].unique()) - hedge_prices, hedge_price_source = load_underlying_prices_for_chain( - chain, - source=underlying_source, - timeframe=hedge_timeframe, - ) - net_deltas = selected_straddle_delta_path(chain, selected) - hedge = run_delta_hedge_path( - timestamps_ns=timestamps, - underlying_prices=hedge_prices.reindex(timestamps).ffill().bfill().to_numpy(dtype=float), - net_option_deltas=net_deltas.reindex(timestamps).fillna(0.0).to_numpy(dtype=float), - config=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), - ) + selected_contracts = cached.metadata["selected_contracts"].to_dict("records") report = { "status": "pass", @@ -490,15 +493,15 @@ def run_real_binance_gamma_scalping_sample( "snapshots": int(chain["timestamp_ns"].nunique()), "chain_rows": int(len(chain)), "contracts": int(len(registry.instruments)), - "packages": int(len(packages)), + "packages": int(len(strategy_run.packages)), "fills": int(len(cached.fills_report)), - "selected": selected, + "selected": selected_contracts, "initial_equity": float(cached.equity.iloc[0]), "final_equity": float(cached.equity.iloc[-1]), - "option_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), - "hedge_pnl": float(hedge.hedge_pnl), - "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0] + hedge.hedge_pnl), - "hedge_rebalances": int(hedge.hedge_report["should_rebalance"].sum()), + "option_pnl": float(cached.option_equity.iloc[-1] - cached.option_equity.iloc[0]), + "hedge_pnl": float(cached.hedge_report["cumulative_hedge_pnl"].iloc[-1]), + "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), + "hedge_rebalances": int(cached.hedge_report["should_rebalance"].sum()), "hedge_price_source": hedge_price_source, "prepared_cache_used": bool(cached.metadata.get("prepared_cache_used")), "package_cache_size": int(cached.metadata.get("package_cache_size", 0)), diff --git a/core/results.py b/core/results.py index 6829001..22cebf2 100644 --- a/core/results.py +++ b/core/results.py @@ -137,6 +137,10 @@ class OptionBacktestResult(BacktestResultV2): settlements_report: pd.DataFrame = field(default_factory=pd.DataFrame) margin_report: pd.DataFrame = field(default_factory=pd.DataFrame) attribution_report: pd.DataFrame = field(default_factory=pd.DataFrame) + hedge_report: pd.DataFrame = field(default_factory=pd.DataFrame) + option_equity: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) + combined_equity: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) + combined_returns: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) run_manifest: Dict = field(default_factory=dict) def __post_init__(self) -> None: @@ -149,4 +153,8 @@ def __post_init__(self) -> None: self.metadata.setdefault("settlements_report", self.settlements_report) self.metadata.setdefault("margin_report", self.margin_report) self.metadata.setdefault("attribution_report", self.attribution_report) + self.metadata.setdefault("hedge_report", self.hedge_report) + self.metadata.setdefault("option_equity", self.option_equity) + self.metadata.setdefault("combined_equity", self.combined_equity) + self.metadata.setdefault("combined_returns", self.combined_returns) self.metadata.setdefault("run_manifest", self.run_manifest) diff --git a/docs/endpoint.md b/docs/endpoint.md index 4f88e5b..a323570 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -1808,6 +1808,13 @@ Required data: - `packages`: optional sequence of `OptionPackageIntent`. Strategy/template code owns signal generation and package construction; the backend owns execution, ledger, margin, settlement, and reports. +- `strategy_run`: optional `OptionStrategyRun` produced by an adapter such as + `build_gamma_scalping_strategy_run(...)`. When supplied, the endpoint reads + `strategy_run.packages`, stores `selected_contracts`, and carries strategy + metadata into the run manifest. +- `underlying`: optional underlying price tape as `Series` or `DataFrame` + (`timestamp_ns`/`time` plus `close` or `price`). Required for first-class + delta-hedged option results. Useful config: @@ -1826,6 +1833,11 @@ Useful config: `backtest(...)`. - `prepared_cache`: optional `OptionPreparedRunCache` passed to `backtest(...)` when replaying many package sets over the same option chain. +- `hedge_policy`: optional `OptionHedgeConfig`. If omitted, the endpoint uses + `strategy_run.hedge_policy` when available. +- `net_option_delta`: optional externally supplied net-delta series. If omitted + during a hedged run, QuantBT computes the path from executed option positions + and observable chain Greeks. Prepared cache pattern: @@ -1842,6 +1854,59 @@ result = bt.backtest( ) ``` +Gamma-scalping adapter pattern: + +```python +from quantbt import ( + GammaScalpingConfig, + OptionHedgeConfig, + OptionHedgePolicyType, + QuantBTEndpoint, + build_gamma_scalping_strategy_run, +) + +strategy_run = build_gamma_scalping_strategy_run( + chain, + option_registry, + GammaScalpingConfig( + side="long", + quantity=1.0, + min_dte_days=10, + max_dte_days=21, + roll_dte_days=2, + max_spread_bps=2_000, + hedge_policy=OptionHedgeConfig( + policy=OptionHedgePolicyType.FIXED_THRESHOLD, + threshold=0.05, + ), + ), +) + +bt = QuantBTEndpoint.options( + initial_capital=100_000, + reporting_currency="USD", + initial_balances={"USD": 100_000}, + fee_rate=0.0002, +) + +result = bt.backtest( + chain=chain, + instruments=option_registry, + strategy_run=strategy_run, + underlying=btc_spot_or_perp, +) + +combined_equity = result.equity +option_only_equity = result.option_equity +hedge_log = result.hedge_report +selected = result.metadata["selected_contracts"] +``` + +For delta-hedged runs, `result.equity` is the combined option-plus-hedge +equity curve. The option-only curve remains available as `result.option_equity`. +QuantBT adds a pre-trade row at `first_timestamp - 1ns` so metrics begin from +the declared initial capital before the first option fill. + Returned result: - `OptionBacktestResult`, compatible with `BacktestResultV2`. @@ -1849,7 +1914,8 @@ Returned result: `.tearsheet()`. - Option audit tables: `fills_report`, `packages_report`, `cash_report`, `marks_report`, `greeks_report`, `settlements_report`, `margin_report`, - `attribution_report`, and `run_manifest`. + `attribution_report`, `hedge_report`, `option_equity`, `combined_equity`, + `combined_returns`, and `run_manifest`. Support discovery: diff --git a/endpoint.py b/endpoint.py index f50d1b0..b3b392a 100644 --- a/endpoint.py +++ b/endpoint.py @@ -59,10 +59,12 @@ from .sizing.modes import compute_target_units from .options.execution import OptionExecutionConfig from .options.fees import OptionFeeSchedule +from .options.hedging import OptionHedgeConfig from .options.margin import OptionMarginConfig from .options.cache import OptionPreparedRunCache from .options.packages import OptionPackageIntent from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec +from .options.strategy import OptionStrategyRun from .viz import quick_plot as _quick_plot from .viz import tearsheet as _tearsheet from .walkforward import WalkForwardConfig, WalkForwardEngine @@ -830,6 +832,10 @@ def backtest( chain: Optional[pd.DataFrame] = None, instruments: Optional[Union[OptionInstrumentRegistry, Sequence[OptionInstrumentSpec], Dict[str, OptionInstrumentSpec]]] = None, packages: Optional[Sequence[OptionPackageIntent]] = None, + strategy_run: Optional[OptionStrategyRun] = None, + underlying: Optional[Union[pd.DataFrame, pd.Series]] = None, + hedge_policy: Optional[OptionHedgeConfig] = None, + net_option_delta: Optional[pd.Series] = None, settlement_events: Optional[Sequence] = None, conversion_rates: Optional[Dict[str, float]] = None, prepared_cache: Optional[OptionPreparedRunCache] = None, @@ -866,6 +872,10 @@ def backtest( chain=chain if chain is not None else data, instruments=instruments, packages=packages, + strategy_run=strategy_run, + underlying=underlying, + hedge_policy=hedge_policy, + net_option_delta=net_option_delta, settlement_events=settlement_events, conversion_rates=conversion_rates, prepared_cache=prepared_cache, @@ -1084,7 +1094,19 @@ def nautilus_pct_equity_diagnostic( native_slippage=native_slippage, ) - def _run_options(self, chain, instruments, packages, settlement_events, conversion_rates, prepared_cache): + def _run_options( + self, + chain, + instruments, + packages, + strategy_run, + underlying, + hedge_policy, + net_option_delta, + settlement_events, + conversion_rates, + prepared_cache, + ): if chain is None: raise ValueError("options endpoint requires chain=option_chain_dataframe or data=option_chain_dataframe") if instruments is None: @@ -1104,6 +1126,10 @@ def _run_options(self, chain, instruments, packages, settlement_events, conversi chain=chain, instruments=instruments, packages=packages or (), + strategy_run=strategy_run, + underlying=underlying, + hedge_policy=hedge_policy, + net_option_delta=net_option_delta, config=config, settlement_events=settlement_events or (), conversion_rates=conversion_rates, diff --git a/engines.py b/engines.py index 2f754d9..39211bf 100644 --- a/engines.py +++ b/engines.py @@ -28,8 +28,10 @@ from .core.results import BacktestResultV2, OptionBacktestResult from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce from .options.cache import OptionPreparedRunCache +from .options.hedging import OptionHedgeConfig from .options.packages import OptionPackageIntent from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec +from .options.strategy import OptionStrategyRun from .portfolio import MultiSymbolPortfolio from .sizing.modes import compute_target_units @@ -381,6 +383,10 @@ def __init__( chain: Optional[pd.DataFrame] = None, instruments: Optional[OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Dict[str, OptionInstrumentSpec]] = None, packages: Sequence[OptionPackageIntent] = (), + strategy_run: Optional[OptionStrategyRun] = None, + underlying: Optional[Union[pd.DataFrame, pd.Series]] = None, + hedge_policy: Optional[OptionHedgeConfig] = None, + net_option_delta: Optional[pd.Series] = None, config: Optional[NativeOptionConfig] = None, settlement_events: Optional[Sequence] = None, conversion_rates: Optional[Dict[str, float]] = None, @@ -389,7 +395,11 @@ def __init__( ): self.chain = chain self.instruments = instruments - self.packages = tuple(packages or ()) + self.strategy_run = strategy_run + self.packages = tuple(packages or (strategy_run.packages if strategy_run is not None else ())) + self.underlying = underlying + self.hedge_policy = hedge_policy or (strategy_run.hedge_policy if strategy_run is not None else None) + self.net_option_delta = net_option_delta self.config = config or NativeOptionConfig() self.settlement_events = tuple(settlement_events or ()) self.conversion_rates = conversion_rates @@ -412,7 +422,15 @@ def run(self) -> OptionBacktestResult: settlement_events=self.settlement_events, conversion_rates=self.conversion_rates, prepared_cache=self.prepared_cache, + underlying=self.underlying, + hedge_policy=self.hedge_policy, + net_option_delta=self.net_option_delta, ) + if self.strategy_run is not None: + self.result.metadata["strategy_run"] = self.strategy_run.metadata + self.result.metadata["selected_contracts"] = self.strategy_run.selected_contracts + self.result.run_manifest["strategy_run"] = self.strategy_run.metadata + self.result.metadata["run_manifest"] = self.result.run_manifest return self.result diff --git a/options/__init__.py b/options/__init__.py index 2334434..488be7e 100644 --- a/options/__init__.py +++ b/options/__init__.py @@ -98,6 +98,7 @@ ) from .surface import SurfaceDiagnostics, TotalVarianceSurface from .tape import YEAR_NS, OptionTapeSignature, PreparedOptionTape, prepare_option_tape +from .strategy import GammaScalpingConfig, OptionStrategyRun, build_gamma_scalping_strategy_run from .templates import ( butterfly, calendar, @@ -120,6 +121,7 @@ "ExternalOptionMarginValidator", "HedgeDecision", "HedgePathResult", + "GammaScalpingConfig", "InstrumentRegistrySignature", "OptionDecisionFillPolicy", "OptionDepthFidelity", @@ -147,6 +149,7 @@ "OptionSelectionFilters", "OptionSettlementRepresentation", "OptionSettlementResult", + "OptionStrategyRun", "OptionTapeSignature", "OptionPosition", "OptionPreparedRunCache", @@ -161,6 +164,7 @@ "black76_parity_residual", "black76_parity_value", "black76_price", + "build_gamma_scalping_strategy_run", "calculate_option_fee", "calculate_option_margin", "compile_option_package_orders", diff --git a/options/strategy.py b/options/strategy.py new file mode 100644 index 0000000..d50fd23 --- /dev/null +++ b/options/strategy.py @@ -0,0 +1,249 @@ +"""Option strategy adapters. + +Adapters live above the option execution engine. They convert observable +option-chain snapshots into package intents and audit tables. They do not own +fills, premium accounting, margin, settlement, or PnL. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.schema import OrderSide +from .hedging import OptionHedgeConfig +from .packages import OptionPackageIntent, OptionPackageLeg +from .schema import OptionInstrumentRegistry + + +@dataclass(frozen=True) +class OptionStrategyRun: + """Package-level strategy output consumed by `QuantBTEndpoint.options`.""" + + packages: tuple[OptionPackageIntent, ...] + hedge_policy: Optional[OptionHedgeConfig] = None + selected_contracts: pd.DataFrame = field(default_factory=pd.DataFrame) + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class GammaScalpingConfig: + """Configuration for a simple ATM straddle gamma-scalping adapter.""" + + side: str = "long" + quantity: float = 1.0 + min_dte_days: float = 2.0 + max_dte_days: float = 45.0 + roll_dte_days: float = 2.0 + max_spread_bps: Optional[float] = None + min_bid_size: float = 0.0 + min_ask_size: float = 0.0 + min_volume: float = 0.0 + min_open_interest: float = 0.0 + hedge_policy: Optional[OptionHedgeConfig] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + side = str(self.side).lower().strip() + if side not in {"long", "short"}: + raise ValueError("GammaScalpingConfig.side must be long or short") + object.__setattr__(self, "side", side) + if self.quantity <= 0.0: + raise ValueError("GammaScalpingConfig.quantity must be > 0") + if self.min_dte_days < 0.0 or self.max_dte_days <= 0.0: + raise ValueError("DTE bounds must be non-negative and max_dte_days > 0") + if self.min_dte_days > self.max_dte_days: + raise ValueError("min_dte_days must be <= max_dte_days") + if self.roll_dte_days < 0.0: + raise ValueError("roll_dte_days must be >= 0") + for name in ("min_bid_size", "min_ask_size", "min_volume", "min_open_interest"): + if getattr(self, name) < 0.0: + raise ValueError(f"{name} must be >= 0") + if self.max_spread_bps is not None and self.max_spread_bps < 0.0: + raise ValueError("max_spread_bps must be >= 0") + + +def build_gamma_scalping_strategy_run( + chain: pd.DataFrame, + instruments: OptionInstrumentRegistry, + config: Optional[GammaScalpingConfig] = None, +) -> OptionStrategyRun: + """ + Build open/roll/close straddle packages from observable chain snapshots. + + Selection is snapshot-local: at each decision timestamp, the adapter only + inspects rows with that exact `timestamp_ns`. The selected pair is the + valid same-expiry same-strike call/put closest to the observed index price. + """ + cfg = config or GammaScalpingConfig() + frame = _canonical_strategy_frame(chain) + valid_symbols = set(instruments.symbols) + frame = frame[frame["instrument_id"].isin(valid_symbols)].copy() + if frame.empty: + raise ValueError("gamma scalping adapter found no chain rows matching instrument registry") + + timestamps = [int(ts) for ts in sorted(frame["timestamp_ns"].unique())] + packages: list[OptionPackageIntent] = [] + selected_rows: list[dict] = [] + active: Optional[dict] = None + + for ts in timestamps: + is_last = ts == timestamps[-1] + if active is not None: + dte = (int(active["expiry_ns"]) - ts) / _DAY_NS + if dte <= cfg.roll_dte_days or is_last: + if _has_quotes(frame, ts, (active["call_id"], active["put_id"])): + packages.append(_straddle_package(ts, active, cfg, action="close")) + selected_rows.append({**active, "timestamp_ns": ts, "action": "close", "dte_days": float(dte)}) + active = None + if is_last: + break + + if active is None and not is_last: + selection = _select_atm_pair(frame, ts, cfg) + if selection is None: + continue + packages.append(_straddle_package(ts, selection, cfg, action="open")) + dte = (int(selection["expiry_ns"]) - ts) / _DAY_NS + selected_rows.append({**selection, "timestamp_ns": ts, "action": "open", "dte_days": float(dte)}) + active = selection + + if active is not None: + ts = timestamps[-1] + if _has_quotes(frame, ts, (active["call_id"], active["put_id"])): + packages.append(_straddle_package(ts, active, cfg, action="close")) + selected_rows.append( + { + **active, + "timestamp_ns": ts, + "action": "close", + "dte_days": float((int(active["expiry_ns"]) - ts) / _DAY_NS), + } + ) + + selected = pd.DataFrame(selected_rows) + return OptionStrategyRun( + packages=tuple(packages), + hedge_policy=cfg.hedge_policy, + selected_contracts=selected, + metadata={ + "strategy": "gamma_scalping", + "side": cfg.side, + "quantity": float(cfg.quantity), + "package_count": len(packages), + "selection_count": len(selected), + **cfg.metadata, + }, + ) + + +def _canonical_strategy_frame(chain: pd.DataFrame) -> pd.DataFrame: + required = { + "timestamp_ns", + "instrument_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "ask_price", + "bid_size", + "ask_size", + "index_price", + } + missing = sorted(required.difference(chain.columns)) + if missing: + raise ValueError(f"gamma scalping chain missing columns: {missing}") + frame = chain.copy() + for column in ("timestamp_ns", "expiry_ns"): + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("int64") + for column in ("strike", "bid_price", "ask_price", "bid_size", "ask_size", "index_price"): + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("float64") + if "volume" not in frame: + frame["volume"] = 0.0 + if "open_interest" not in frame: + frame["open_interest"] = 0.0 + frame["volume"] = pd.to_numeric(frame["volume"], errors="coerce").fillna(0.0).astype("float64") + frame["open_interest"] = pd.to_numeric(frame["open_interest"], errors="coerce").fillna(0.0).astype("float64") + frame["option_kind"] = frame["option_kind"].astype(str).str.lower().str.strip() + return frame.sort_values(["timestamp_ns", "expiry_ns", "strike", "option_kind", "instrument_id"]).reset_index(drop=True) + + +def _select_atm_pair(frame: pd.DataFrame, timestamp_ns: int, cfg: GammaScalpingConfig) -> Optional[dict]: + snap = frame[frame["timestamp_ns"] == int(timestamp_ns)].copy() + if snap.empty: + return None + snap = snap[(snap["bid_price"] > 0.0) & (snap["ask_price"] > 0.0) & (snap["ask_price"] >= snap["bid_price"])] + snap = snap[(snap["bid_size"] >= cfg.min_bid_size) & (snap["ask_size"] >= cfg.min_ask_size)] + snap = snap[(snap["volume"] >= cfg.min_volume) & (snap["open_interest"] >= cfg.min_open_interest)] + dte = (snap["expiry_ns"] - int(timestamp_ns)) / _DAY_NS + snap = snap[(dte >= cfg.min_dte_days) & (dte <= cfg.max_dte_days)] + if cfg.max_spread_bps is not None: + mid = 0.5 * (snap["bid_price"] + snap["ask_price"]) + spread_bps = np.where(mid > 0.0, (snap["ask_price"] - snap["bid_price"]) / mid * 10_000.0, np.inf) + snap = snap[spread_bps <= float(cfg.max_spread_bps)] + if snap.empty: + return None + + spot = float(snap["index_price"].median()) + pair_groups = snap.groupby(["expiry_ns", "strike"]) + candidates = [] + for (expiry_ns, strike), group in pair_groups: + kinds = set(group["option_kind"]) + if kinds != {"call", "put"}: + continue + call = group[group["option_kind"] == "call"].iloc[0] + put = group[group["option_kind"] == "put"].iloc[0] + candidates.append( + { + "expiry_ns": int(expiry_ns), + "strike": float(strike), + "spot": spot, + "call_id": str(call["instrument_id"]), + "put_id": str(put["instrument_id"]), + "call_delta": float(call.get("delta", np.nan)), + "put_delta": float(put.get("delta", np.nan)), + "distance": abs(float(strike) - spot), + "dte_days": float((int(expiry_ns) - int(timestamp_ns)) / _DAY_NS), + } + ) + if not candidates: + return None + return min(candidates, key=lambda row: (row["distance"], row["dte_days"])) + + +def _straddle_package(timestamp_ns: int, selection: dict, cfg: GammaScalpingConfig, *, action: str) -> OptionPackageIntent: + if action == "open": + side = OrderSide.BUY if cfg.side == "long" else OrderSide.SELL + elif action == "close": + side = OrderSide.SELL if cfg.side == "long" else OrderSide.BUY + else: + raise ValueError("action must be open or close") + return OptionPackageIntent( + timestamp_ns=int(timestamp_ns), + package_id=f"gamma-{action}:{selection['call_id']}:{selection['put_id']}:{timestamp_ns}", + legs=( + OptionPackageLeg(selection["call_id"], side, 1.0, role=f"{action}_call"), + OptionPackageLeg(selection["put_id"], side, 1.0, role=f"{action}_put"), + ), + quantity=float(cfg.quantity), + tag=f"gamma_scalping_{action}", + metadata={ + "strategy": "gamma_scalping", + "action": action, + "side": cfg.side, + "strike": float(selection["strike"]), + "expiry_ns": int(selection["expiry_ns"]), + "spot": float(selection["spot"]), + }, + ) + + +def _has_quotes(frame: pd.DataFrame, timestamp_ns: int, symbols: Sequence[str]) -> bool: + snap_symbols = set(frame.loc[frame["timestamp_ns"] == int(timestamp_ns), "instrument_id"]) + return all(symbol in snap_symbols for symbol in symbols) + + +_DAY_NS = 24 * 60 * 60 * 1_000_000_000 diff --git a/tests/options/test_strategy_adapter_contract.py b/tests/options/test_strategy_adapter_contract.py new file mode 100644 index 0000000..f33ffde --- /dev/null +++ b/tests/options/test_strategy_adapter_contract.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import ( + ExerciseStyle, + GammaScalpingConfig, + OptionHedgeConfig, + OptionHedgePolicyType, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + QuantBTEndpoint, + SettlementStyle, + build_gamma_scalping_strategy_run, +) + + +def test_gamma_scalping_adapter_builds_snapshot_local_packages(): + chain, registry = _gamma_chain_and_registry() + run = build_gamma_scalping_strategy_run( + chain, + registry, + GammaScalpingConfig( + max_spread_bps=100, + hedge_policy=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + ), + ) + + assert len(run.packages) == 2 + assert run.packages[0].tag == "gamma_scalping_open" + assert run.packages[1].tag == "gamma_scalping_close" + assert run.selected_contracts.loc[0, "strike"] == pytest.approx(100_000.0) + assert run.selected_contracts.loc[0, "call_id"] == "BTC-C100.TEST" + assert run.selected_contracts.loc[0, "put_id"] == "BTC-P100.TEST" + assert run.hedge_policy is not None + assert run.metadata["strategy"] == "gamma_scalping" + + +def test_options_endpoint_delta_hedged_contract_returns_combined_equity(): + chain, registry = _gamma_chain_and_registry() + run = build_gamma_scalping_strategy_run( + chain, + registry, + GammaScalpingConfig( + hedge_policy=OptionHedgeConfig(policy="fixed_threshold", threshold=0.01), + ), + ) + underlying = pd.DataFrame( + { + "time": pd.to_datetime(["2026-01-01 00:00:00", "2026-01-02 00:00:00", "2026-01-03 00:00:00"]), + "close": [100_000.0, 102_000.0, 99_000.0], + } + ) + + bt = QuantBTEndpoint.options( + initial_capital=100_000.0, + reporting_currency="USD", + initial_balances={"USD": 100_000.0}, + fee_rate=0.0, + ) + result = bt.backtest(chain=chain, instruments=registry, strategy_run=run, underlying=underlying) + + assert len(result.equity) == chain["timestamp_ns"].nunique() + 1 + assert result.equity.iloc[0] == pytest.approx(100_000.0) + assert result.option_equity.index.equals(result.combined_equity.index) + assert result.equity.equals(result.combined_equity) + assert not result.hedge_report.empty + assert int(result.hedge_report["should_rebalance"].sum()) >= 1 + assert result.metadata["delta_hedge_contract"]["enabled"] is True + assert result.metadata["strategy_run"]["strategy"] == "gamma_scalping" + assert result.metadata["selected_contracts"].shape[0] == 2 + assert result.run_manifest["delta_hedge"]["underlying_source"] == "underlying_dataframe:close" + report = result.full_report() + assert report["initial_capital"] == pytest.approx(100_000.0) + assert report["final_equity"] == pytest.approx(result.combined_equity.iloc[-1]) + + +def _gamma_chain_and_registry() -> tuple[pd.DataFrame, OptionInstrumentRegistry]: + ts = [int(pd.Timestamp(value, tz="UTC").value) for value in ("2026-01-01", "2026-01-02", "2026-01-03")] + expiry = int(pd.Timestamp("2026-02-01 08:00:00", tz="UTC").value) + specs = ( + _spec("BTC-C100.TEST", OptionKind.CALL, expiry), + _spec("BTC-P100.TEST", OptionKind.PUT, expiry), + _spec("BTC-C110.TEST", OptionKind.CALL, expiry), + _spec("BTC-P110.TEST", OptionKind.PUT, expiry), + ) + registry = OptionInstrumentRegistry.from_iterable(specs) + rows = [] + for snap_idx, (timestamp_ns, spot) in enumerate(zip(ts, (100_000.0, 102_000.0, 99_000.0))): + rows.extend( + [ + _row(timestamp_ns, 0, "BTC-C100.TEST", "call", 100_000.0, spot, 2100.0 + 100.0 * snap_idx, 0.52), + _row(timestamp_ns, 1, "BTC-P100.TEST", "put", 100_000.0, spot, 1900.0 - 50.0 * snap_idx, -0.48), + _row(timestamp_ns, 2, "BTC-C110.TEST", "call", 110_000.0, spot, 500.0, 0.20), + _row(timestamp_ns, 3, "BTC-P110.TEST", "put", 110_000.0, spot, 9000.0, -0.80), + ] + ) + return pd.DataFrame(rows), registry + + +def _spec(symbol: str, kind: OptionKind, expiry_ns: int) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="test", + underlying_id="BTC-PERP.TEST", + underlying_index_id="BTC-INDEX.TEST", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=100_000.0 if "100" in symbol else 110_000.0, + expiry_ns=expiry_ns, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + tick_size=0.01, + convention_version="test_linear_v1", + ) + + +def _row(timestamp_ns: int, sequence_id: int, symbol: str, kind: str, strike: float, spot: float, mark: float, delta: float) -> dict: + return { + "timestamp_ns": timestamp_ns, + "instrument_id": symbol, + "venue": "TEST", + "underlying_id": "BTC-PERP.TEST", + "expiry_ns": int(pd.Timestamp("2026-02-01 08:00:00", tz="UTC").value), + "strike": strike, + "option_kind": kind, + "bid_price": mark - 5.0, + "bid_size": 10.0, + "ask_price": mark + 5.0, + "ask_size": 10.0, + "mark_price": mark, + "last_price": mark, + "index_price": spot, + "forward_price": spot, + "mark_iv": 0.5, + "bid_iv": 0.49, + "ask_iv": 0.51, + "delta": delta, + "gamma": 0.0001, + "vega": 100.0, + "theta": -10.0, + "open_interest": 100.0, + "volume": 100.0, + "quote_currency": "USD", + "settlement_currency": "USD", + "sequence_id": sequence_id, + "source_latency_ns": 1_000_000, + } diff --git a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md index 2d545ab..e8fc39f 100644 --- a/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md +++ b/upgrade/option_backtest_plan/quantbt_options_engine_execution_plan.md @@ -1059,6 +1059,97 @@ Technical debt after Phase 10: - Cython/C++ is not recommended yet; current Phase 10 evidence supports cache reuse and facade profiling first. +## Phase 11 - Options Strategy Adapter And Delta-Hedged Contract + +Files: + +- `options/strategy.py` +- `core/results.py` +- `backends/native_option.py` +- `engines.py` +- `endpoint.py` +- `tests/options/test_strategy_adapter_contract.py` +- `benchmarks/gamma_scalping_backtestsample.py` +- `docs/endpoint.md` + +Tasks: + +- Add a strategy-layer output contract: + - `OptionStrategyRun`; + - `packages`; + - optional `hedge_policy`; + - `selected_contracts`; + - metadata. +- Add a gamma-scalping adapter: + - `GammaScalpingConfig`; + - `build_gamma_scalping_strategy_run(...)`; + - snapshot-local ATM straddle selection; + - DTE, spread, bid/ask size, volume and OI filters; + - open/roll/close package generation. +- Extend `QuantBTEndpoint.options(...).backtest(...)` with optional: + - `strategy_run`; + - `underlying`; + - `hedge_policy`; + - `net_option_delta`. +- Extend `OptionBacktestResult` with first-class delta-hedged artifacts: + - `option_equity`; + - `hedge_report`; + - `combined_equity`; + - `combined_returns`. +- If a hedge policy is supplied, make `result.equity` represent the combined + option-plus-hedge equity curve while preserving option-only equity separately. +- Add a pre-trade row at `first_timestamp - 1ns` for hedged option runs so the + reporting curve starts at declared initial capital before the first fill. +- Update the gamma scalping benchmark to use the public endpoint contract: + `strategy_run + underlying`, not manual package/hedge plumbing. + +Acceptance: + +- Existing unhedged `QuantBTEndpoint.options(...)` calls remain compatible. +- Gamma adapter emits packages without inspecting future snapshots for + selection. +- Hedged runs expose combined equity and option-only equity separately. +- Hedge PnL uses previous hedge quantity for the prior underlying move. +- Real Binance options CSV smoke runs through the same public endpoint contract. + +Status: completed. + +Implemented: + +- Added `OptionStrategyRun`, `GammaScalpingConfig`, and + `build_gamma_scalping_strategy_run(...)`. +- Added endpoint and engine threading for `strategy_run`, `underlying`, + `hedge_policy`, and `net_option_delta`. +- Added delta-hedged result artifacts to `OptionBacktestResult`. +- Added a linear quote-currency option equity replay path for full tape MTM. +- Added combined option-plus-hedge equity when a hedge policy is present. +- Updated `benchmarks/gamma_scalping_backtestsample.py` to run synthetic and + real Binance gamma-scalping samples through `QuantBTEndpoint.options(...)`. +- Documented the gamma-scalping endpoint pattern in `docs/endpoint.md`. + +Validation: + +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options/test_strategy_adapter_contract.py` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q tests/options` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python benchmarks/gamma_scalping_backtestsample.py --snapshots 90 --seed 42` +- `MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha:/root/bobby/pool_alpha/alphas_storage/_get_data poetry run python benchmarks/gamma_scalping_backtestsample.py --real-options-csv /root/bobby/pool_alpha/alphas_storage/option_based/options_full_history.csv.gz --underlying-source spot --hedge-timeframe 1h` + +Technical debt after Phase 11: + +- Delta hedge execution is an accounting path, not yet an order-book/venue + execution path for the underlying hedge leg. +- Linear quote-currency option path is exact for USD/USDC-style premium and + settlement. Inverse and quanto hedged combined-equity paths should use the + multi-currency ledger path or venue-specific conversion audit before being + called production-certified. +- The gamma adapter is a V1 ATM straddle adapter. More strategy adapters are + still needed for calendar vol, skew, vertical, dispersion, and option-vol-arb + workflows. +- If a near-expiry contract disappears from the historical chain before a close + or settlement event, strategy config should use stricter DTE/liquidity + filters or provide settlement events. Venue-exact expiry/auto-exercise + package generation remains a later enhancement. + ## V1 Completion Criteria V1 can be called usable only when: @@ -1093,7 +1184,7 @@ V1 can be called usable only when: - Cross-venue volatility arbitrage production semantics before collateral, transfer, latency and borrow constraints are implemented. -## Immediate Next Step +## Historical Start Note Start with Phase 0, then Phase 1. Do not jump to pricing or endpoint wiring before schema/convention tests pass. The first code commit should be small: