quantbt-engine 1.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
quantbt/options/fees.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Option fee schedules.
|
|
3
|
+
|
|
4
|
+
Phase 5 implements deterministic per-leg capped fees. There is intentionally no
|
|
5
|
+
package-level cap because real venues cap option fees per contract/leg.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
from ..core.orders import Fill
|
|
14
|
+
from ..core.schema import LiquiditySide
|
|
15
|
+
from .schema import OptionInstrumentSpec, PremiumConvention
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class OptionFeeResult:
|
|
20
|
+
fee: float
|
|
21
|
+
currency: str
|
|
22
|
+
raw_fee: float
|
|
23
|
+
cap: float
|
|
24
|
+
capped: bool
|
|
25
|
+
schedule_id: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class OptionFeeSchedule:
|
|
30
|
+
schedule_id: str
|
|
31
|
+
fee_currency: str
|
|
32
|
+
maker_rate: float = 0.0
|
|
33
|
+
taker_rate: float = 0.0
|
|
34
|
+
cap_premium_fraction: float = 0.125
|
|
35
|
+
per_contract_fee: float = 0.0
|
|
36
|
+
premium_convention: Union[PremiumConvention, str] = PremiumConvention.LINEAR_QUOTE
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
object.__setattr__(self, "premium_convention", _coerce_premium(self.premium_convention))
|
|
40
|
+
object.__setattr__(self, "fee_currency", str(self.fee_currency).upper())
|
|
41
|
+
if not self.schedule_id:
|
|
42
|
+
raise ValueError("schedule_id is required")
|
|
43
|
+
if not self.fee_currency:
|
|
44
|
+
raise ValueError("fee_currency is required")
|
|
45
|
+
if self.maker_rate < 0.0 or self.taker_rate < 0.0:
|
|
46
|
+
raise ValueError("maker_rate and taker_rate must be >= 0")
|
|
47
|
+
if self.cap_premium_fraction < 0.0:
|
|
48
|
+
raise ValueError("cap_premium_fraction must be >= 0")
|
|
49
|
+
if self.per_contract_fee < 0.0:
|
|
50
|
+
raise ValueError("per_contract_fee must be >= 0")
|
|
51
|
+
|
|
52
|
+
def rate_for(self, liquidity: LiquiditySide) -> float:
|
|
53
|
+
return self.maker_rate if liquidity is LiquiditySide.MAKER else self.taker_rate
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def deribit_inverse_fee_schedule(
|
|
57
|
+
*,
|
|
58
|
+
base_currency: str = "BTC",
|
|
59
|
+
per_contract_fee: float = 0.0003,
|
|
60
|
+
cap_premium_fraction: float = 0.125,
|
|
61
|
+
) -> OptionFeeSchedule:
|
|
62
|
+
return OptionFeeSchedule(
|
|
63
|
+
schedule_id=f"deribit_{base_currency.lower()}_inverse_options_phase5",
|
|
64
|
+
fee_currency=base_currency,
|
|
65
|
+
per_contract_fee=per_contract_fee,
|
|
66
|
+
cap_premium_fraction=cap_premium_fraction,
|
|
67
|
+
premium_convention=PremiumConvention.INVERSE_BASE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def deribit_linear_usdc_fee_schedule(
|
|
72
|
+
*,
|
|
73
|
+
taker_rate: float = 0.0003,
|
|
74
|
+
maker_rate: float = 0.0003,
|
|
75
|
+
cap_premium_fraction: float = 0.125,
|
|
76
|
+
) -> OptionFeeSchedule:
|
|
77
|
+
return OptionFeeSchedule(
|
|
78
|
+
schedule_id="deribit_linear_usdc_options_phase5",
|
|
79
|
+
fee_currency="USDC",
|
|
80
|
+
maker_rate=maker_rate,
|
|
81
|
+
taker_rate=taker_rate,
|
|
82
|
+
cap_premium_fraction=cap_premium_fraction,
|
|
83
|
+
premium_convention=PremiumConvention.LINEAR_QUOTE,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def calculate_option_fee(
|
|
88
|
+
fill: Fill,
|
|
89
|
+
instrument: OptionInstrumentSpec,
|
|
90
|
+
schedule: OptionFeeSchedule,
|
|
91
|
+
*,
|
|
92
|
+
reference_price: float,
|
|
93
|
+
) -> OptionFeeResult:
|
|
94
|
+
"""
|
|
95
|
+
Calculate a per-leg capped option fee.
|
|
96
|
+
|
|
97
|
+
For inverse options the common venue-like form is a base-currency fee per
|
|
98
|
+
contract capped by a fraction of option premium. For linear options the raw
|
|
99
|
+
fee is reference notional times rate, also capped by option premium.
|
|
100
|
+
"""
|
|
101
|
+
if schedule.premium_convention != instrument.premium_convention:
|
|
102
|
+
raise ValueError("fee schedule premium convention does not match instrument")
|
|
103
|
+
if schedule.fee_currency != instrument.premium_currency:
|
|
104
|
+
raise ValueError("fee schedule currency must match option premium currency in Phase 5")
|
|
105
|
+
if reference_price <= 0.0:
|
|
106
|
+
raise ValueError("reference_price must be > 0")
|
|
107
|
+
premium_notional = float(fill.qty) * float(fill.price) * float(instrument.multiplier)
|
|
108
|
+
cap = premium_notional * float(schedule.cap_premium_fraction)
|
|
109
|
+
if instrument.premium_convention is PremiumConvention.INVERSE_BASE:
|
|
110
|
+
raw_fee = float(fill.qty) * float(instrument.multiplier) * float(schedule.per_contract_fee)
|
|
111
|
+
else:
|
|
112
|
+
raw_fee = (
|
|
113
|
+
float(fill.qty)
|
|
114
|
+
* float(instrument.multiplier)
|
|
115
|
+
* float(reference_price)
|
|
116
|
+
* float(schedule.rate_for(fill.liquidity))
|
|
117
|
+
)
|
|
118
|
+
fee = min(raw_fee, cap) if schedule.cap_premium_fraction > 0.0 else raw_fee
|
|
119
|
+
return OptionFeeResult(
|
|
120
|
+
fee=float(fee),
|
|
121
|
+
currency=schedule.fee_currency,
|
|
122
|
+
raw_fee=float(raw_fee),
|
|
123
|
+
cap=float(cap),
|
|
124
|
+
capped=bool(fee < raw_fee),
|
|
125
|
+
schedule_id=schedule.schedule_id,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _coerce_premium(value: Union[PremiumConvention, str]) -> PremiumConvention:
|
|
130
|
+
if isinstance(value, PremiumConvention):
|
|
131
|
+
return value
|
|
132
|
+
try:
|
|
133
|
+
return PremiumConvention(str(value))
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise ValueError("premium_convention is invalid") from exc
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Option Greeks with explicit units.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import math
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
from .pricing import (
|
|
12
|
+
black76_d1_d2,
|
|
13
|
+
black76_price,
|
|
14
|
+
normal_cdf,
|
|
15
|
+
normal_pdf,
|
|
16
|
+
_coerce_kind,
|
|
17
|
+
_non_negative_float,
|
|
18
|
+
_positive_float,
|
|
19
|
+
)
|
|
20
|
+
from .schema import OptionKind
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class OptionGreeks:
|
|
25
|
+
price: float
|
|
26
|
+
delta: float
|
|
27
|
+
gamma: float
|
|
28
|
+
vega: float
|
|
29
|
+
theta: float
|
|
30
|
+
currency: str
|
|
31
|
+
unit: str
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def vega_per_vol_point(self) -> float:
|
|
35
|
+
"""Return vega for a 1 vol-point change, not a 1.0 vol change."""
|
|
36
|
+
return self.vega / 100.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def linear_black76_greeks(
|
|
40
|
+
forward: float,
|
|
41
|
+
strike: float,
|
|
42
|
+
time_to_expiry: float,
|
|
43
|
+
volatility: float,
|
|
44
|
+
option_kind: Union[OptionKind, str],
|
|
45
|
+
*,
|
|
46
|
+
discount: float = 1.0,
|
|
47
|
+
currency: str = "QUOTE",
|
|
48
|
+
) -> OptionGreeks:
|
|
49
|
+
"""Return Black-76 Greeks in quote currency per 1 underlying."""
|
|
50
|
+
kind = _coerce_kind(option_kind)
|
|
51
|
+
fwd, strike_, tau, vol, df = _validated_greek_inputs(forward, strike, time_to_expiry, volatility, discount)
|
|
52
|
+
price = black76_price(fwd, strike_, tau, vol, kind, discount=df)
|
|
53
|
+
if tau <= 0.0 or vol <= 0.0:
|
|
54
|
+
delta = df if (kind is OptionKind.CALL and fwd > strike_) else 0.0
|
|
55
|
+
if kind is OptionKind.PUT and fwd < strike_:
|
|
56
|
+
delta = -df
|
|
57
|
+
return OptionGreeks(price=price, delta=delta, gamma=0.0, vega=0.0, theta=0.0, currency=currency, unit="quote")
|
|
58
|
+
d1, _ = black76_d1_d2(fwd, strike_, tau, vol)
|
|
59
|
+
pdf = normal_pdf(d1)
|
|
60
|
+
if kind is OptionKind.CALL:
|
|
61
|
+
delta = df * normal_cdf(d1)
|
|
62
|
+
else:
|
|
63
|
+
delta = df * (normal_cdf(d1) - 1.0)
|
|
64
|
+
gamma = df * pdf / (fwd * vol * math.sqrt(tau))
|
|
65
|
+
vega = df * fwd * pdf * math.sqrt(tau)
|
|
66
|
+
theta = -0.5 * df * fwd * pdf * vol / math.sqrt(tau)
|
|
67
|
+
return OptionGreeks(
|
|
68
|
+
price=price,
|
|
69
|
+
delta=delta,
|
|
70
|
+
gamma=gamma,
|
|
71
|
+
vega=vega,
|
|
72
|
+
theta=theta,
|
|
73
|
+
currency=str(currency).upper(),
|
|
74
|
+
unit="quote",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def inverse_black76_greeks_base(
|
|
79
|
+
forward: float,
|
|
80
|
+
strike: float,
|
|
81
|
+
time_to_expiry: float,
|
|
82
|
+
volatility: float,
|
|
83
|
+
option_kind: Union[OptionKind, str],
|
|
84
|
+
*,
|
|
85
|
+
discount: float = 1.0,
|
|
86
|
+
currency: str = "BASE",
|
|
87
|
+
) -> OptionGreeks:
|
|
88
|
+
"""Return inverse option Greeks in native base settlement currency."""
|
|
89
|
+
fwd = _positive_float(forward, "forward")
|
|
90
|
+
linear = linear_black76_greeks(fwd, strike, time_to_expiry, volatility, option_kind, discount=discount)
|
|
91
|
+
price = linear.price / fwd
|
|
92
|
+
delta = linear.delta / fwd - linear.price / (fwd * fwd)
|
|
93
|
+
gamma = linear.gamma / fwd - 2.0 * linear.delta / (fwd * fwd) + 2.0 * linear.price / (fwd * fwd * fwd)
|
|
94
|
+
vega = linear.vega / fwd
|
|
95
|
+
theta = linear.theta / fwd
|
|
96
|
+
return OptionGreeks(
|
|
97
|
+
price=price,
|
|
98
|
+
delta=delta,
|
|
99
|
+
gamma=gamma,
|
|
100
|
+
vega=vega,
|
|
101
|
+
theta=theta,
|
|
102
|
+
currency=str(currency).upper(),
|
|
103
|
+
unit="base",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def inverse_black76_greeks_quote(
|
|
108
|
+
forward: float,
|
|
109
|
+
strike: float,
|
|
110
|
+
time_to_expiry: float,
|
|
111
|
+
volatility: float,
|
|
112
|
+
option_kind: Union[OptionKind, str],
|
|
113
|
+
*,
|
|
114
|
+
discount: float = 1.0,
|
|
115
|
+
currency: str = "QUOTE",
|
|
116
|
+
) -> OptionGreeks:
|
|
117
|
+
"""
|
|
118
|
+
Return inverse option Greeks converted to quote reporting currency.
|
|
119
|
+
|
|
120
|
+
Under the Phase 2 inverse convention, quote-reporting value equals the
|
|
121
|
+
corresponding linear Black-76 value, so Greeks match the linear Greeks.
|
|
122
|
+
"""
|
|
123
|
+
return linear_black76_greeks(
|
|
124
|
+
forward,
|
|
125
|
+
strike,
|
|
126
|
+
time_to_expiry,
|
|
127
|
+
volatility,
|
|
128
|
+
option_kind,
|
|
129
|
+
discount=discount,
|
|
130
|
+
currency=currency,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def scale_greeks_to_reporting_currency(
|
|
135
|
+
greeks: OptionGreeks,
|
|
136
|
+
conversion_rate: float,
|
|
137
|
+
*,
|
|
138
|
+
reporting_currency: str,
|
|
139
|
+
vega_per_vol_point: bool = False,
|
|
140
|
+
) -> OptionGreeks:
|
|
141
|
+
"""
|
|
142
|
+
Statically scale Greeks into a reporting currency.
|
|
143
|
+
|
|
144
|
+
This is a pure currency conversion helper. It does not add chain-rule delta
|
|
145
|
+
from a conversion rate that itself depends on the underlying.
|
|
146
|
+
"""
|
|
147
|
+
rate = _positive_float(conversion_rate, "conversion_rate")
|
|
148
|
+
vega_scale = 0.01 if vega_per_vol_point else 1.0
|
|
149
|
+
return OptionGreeks(
|
|
150
|
+
price=greeks.price * rate,
|
|
151
|
+
delta=greeks.delta * rate,
|
|
152
|
+
gamma=greeks.gamma * rate,
|
|
153
|
+
vega=greeks.vega * rate * vega_scale,
|
|
154
|
+
theta=greeks.theta * rate,
|
|
155
|
+
currency=str(reporting_currency).upper(),
|
|
156
|
+
unit=f"{greeks.unit}_reported",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _validated_greek_inputs(
|
|
161
|
+
forward: float,
|
|
162
|
+
strike: float,
|
|
163
|
+
time_to_expiry: float,
|
|
164
|
+
volatility: float,
|
|
165
|
+
discount: float,
|
|
166
|
+
) -> tuple[float, float, float, float, float]:
|
|
167
|
+
return (
|
|
168
|
+
_positive_float(forward, "forward"),
|
|
169
|
+
_positive_float(strike, "strike"),
|
|
170
|
+
_non_negative_float(time_to_expiry, "time_to_expiry"),
|
|
171
|
+
_non_negative_float(volatility, "volatility"),
|
|
172
|
+
_positive_float(discount, "discount"),
|
|
173
|
+
)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Option hedge policy primitives.
|
|
3
|
+
|
|
4
|
+
Hedge accounting is intentionally explicit about ordering: hedge PnL for a
|
|
5
|
+
price move is earned by the hedge quantity held before that move; rebalance
|
|
6
|
+
decisions are evaluated after option package fills and Greek recomputation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Dict, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from .greeks import OptionGreeks
|
|
19
|
+
from .ledger import OptionLedger
|
|
20
|
+
from .schema import OptionInstrumentSpec
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OptionHedgePolicyType(str, Enum):
|
|
24
|
+
FIXED_THRESHOLD = "fixed_threshold"
|
|
25
|
+
HYSTERESIS_BAND = "hysteresis_band"
|
|
26
|
+
TIME_BASED = "time_based"
|
|
27
|
+
REALIZED_VOL_SCALED_BAND = "realized_vol_scaled_band"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class OptionHedgeConfig:
|
|
32
|
+
policy: OptionHedgePolicyType = OptionHedgePolicyType.FIXED_THRESHOLD
|
|
33
|
+
target_delta: float = 0.0
|
|
34
|
+
threshold: float = 0.05
|
|
35
|
+
enter_band: float = 0.10
|
|
36
|
+
exit_band: float = 0.03
|
|
37
|
+
rebalance_interval_ns: int = 0
|
|
38
|
+
realized_vol_window: int = 20
|
|
39
|
+
realized_vol_multiplier: float = 1.0
|
|
40
|
+
min_band: float = 0.01
|
|
41
|
+
hedge_contract_multiplier: float = 1.0
|
|
42
|
+
metadata: Dict = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
object.__setattr__(self, "policy", _coerce_policy(self.policy))
|
|
46
|
+
if self.threshold < 0.0 or self.enter_band < 0.0 or self.exit_band < 0.0:
|
|
47
|
+
raise ValueError("threshold and bands must be >= 0")
|
|
48
|
+
if self.exit_band > self.enter_band:
|
|
49
|
+
raise ValueError("exit_band must be <= enter_band")
|
|
50
|
+
if self.rebalance_interval_ns < 0:
|
|
51
|
+
raise ValueError("rebalance_interval_ns must be >= 0")
|
|
52
|
+
if self.realized_vol_window <= 1:
|
|
53
|
+
raise ValueError("realized_vol_window must be > 1")
|
|
54
|
+
if self.realized_vol_multiplier < 0.0 or self.min_band < 0.0:
|
|
55
|
+
raise ValueError("realized_vol_multiplier and min_band must be >= 0")
|
|
56
|
+
if self.hedge_contract_multiplier <= 0.0:
|
|
57
|
+
raise ValueError("hedge_contract_multiplier must be > 0")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class HedgeDecision:
|
|
62
|
+
timestamp_ns: int
|
|
63
|
+
net_option_delta: float
|
|
64
|
+
previous_hedge_qty: float
|
|
65
|
+
target_hedge_qty: float
|
|
66
|
+
trade_qty: float
|
|
67
|
+
should_rebalance: bool
|
|
68
|
+
reason: str
|
|
69
|
+
band: float
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class HedgePathResult:
|
|
74
|
+
hedge_report: pd.DataFrame
|
|
75
|
+
final_hedge_qty: float
|
|
76
|
+
hedge_pnl: float
|
|
77
|
+
decisions: tuple[HedgeDecision, ...]
|
|
78
|
+
metadata: Dict
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def compute_net_option_delta(
|
|
82
|
+
ledger: OptionLedger,
|
|
83
|
+
greeks_by_symbol: Dict[str, OptionGreeks],
|
|
84
|
+
instruments: Dict[str, OptionInstrumentSpec],
|
|
85
|
+
) -> float:
|
|
86
|
+
"""Return portfolio option delta after package fills and Greek recompute."""
|
|
87
|
+
total = 0.0
|
|
88
|
+
for symbol, position in ledger.positions.items():
|
|
89
|
+
if position.is_flat:
|
|
90
|
+
continue
|
|
91
|
+
greek = greeks_by_symbol.get(symbol)
|
|
92
|
+
instrument = instruments.get(symbol)
|
|
93
|
+
if greek is None or instrument is None:
|
|
94
|
+
raise ValueError(f"missing Greek or instrument for {symbol}")
|
|
95
|
+
total += float(position.qty) * float(greek.delta) * float(instrument.multiplier)
|
|
96
|
+
return float(total)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def hedge_decision(
|
|
100
|
+
*,
|
|
101
|
+
timestamp_ns: int,
|
|
102
|
+
net_option_delta: float,
|
|
103
|
+
current_hedge_qty: float,
|
|
104
|
+
config: OptionHedgeConfig,
|
|
105
|
+
last_rebalance_timestamp_ns: Optional[int] = None,
|
|
106
|
+
underlying_prices: Optional[Sequence[float]] = None,
|
|
107
|
+
currently_active: bool = False,
|
|
108
|
+
) -> HedgeDecision:
|
|
109
|
+
"""Decide whether to rebalance the hedge after Greek recomputation."""
|
|
110
|
+
target_qty = (float(config.target_delta) - float(net_option_delta)) / float(config.hedge_contract_multiplier)
|
|
111
|
+
trade_qty = target_qty - float(current_hedge_qty)
|
|
112
|
+
band = _active_band(config, underlying_prices)
|
|
113
|
+
reason = "within_band"
|
|
114
|
+
should = False
|
|
115
|
+
abs_trade = abs(trade_qty)
|
|
116
|
+
if config.policy is OptionHedgePolicyType.FIXED_THRESHOLD:
|
|
117
|
+
should = abs_trade >= config.threshold
|
|
118
|
+
reason = "fixed_threshold" if should else reason
|
|
119
|
+
elif config.policy is OptionHedgePolicyType.HYSTERESIS_BAND:
|
|
120
|
+
threshold = config.exit_band if currently_active else config.enter_band
|
|
121
|
+
should = abs_trade >= threshold
|
|
122
|
+
reason = "hysteresis_exit_band" if currently_active and should else ("hysteresis_enter_band" if should else reason)
|
|
123
|
+
band = threshold
|
|
124
|
+
elif config.policy is OptionHedgePolicyType.TIME_BASED:
|
|
125
|
+
due = last_rebalance_timestamp_ns is None or int(timestamp_ns) - int(last_rebalance_timestamp_ns) >= config.rebalance_interval_ns
|
|
126
|
+
should = due and abs_trade > 1e-12
|
|
127
|
+
reason = "time_based_due" if should else "time_based_not_due"
|
|
128
|
+
elif config.policy is OptionHedgePolicyType.REALIZED_VOL_SCALED_BAND:
|
|
129
|
+
should = abs_trade >= band
|
|
130
|
+
reason = "realized_vol_scaled_band" if should else reason
|
|
131
|
+
return HedgeDecision(
|
|
132
|
+
timestamp_ns=int(timestamp_ns),
|
|
133
|
+
net_option_delta=float(net_option_delta),
|
|
134
|
+
previous_hedge_qty=float(current_hedge_qty),
|
|
135
|
+
target_hedge_qty=float(target_qty),
|
|
136
|
+
trade_qty=float(trade_qty if should else 0.0),
|
|
137
|
+
should_rebalance=bool(should),
|
|
138
|
+
reason=reason,
|
|
139
|
+
band=float(band),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_delta_hedge_path(
|
|
144
|
+
timestamps_ns: Sequence[int],
|
|
145
|
+
underlying_prices: Sequence[float],
|
|
146
|
+
net_option_deltas: Sequence[float],
|
|
147
|
+
config: OptionHedgeConfig,
|
|
148
|
+
*,
|
|
149
|
+
initial_hedge_qty: float = 0.0,
|
|
150
|
+
) -> HedgePathResult:
|
|
151
|
+
"""
|
|
152
|
+
Simulate hedge PnL and rebalances over a path.
|
|
153
|
+
|
|
154
|
+
At bar `t`, PnL from `price[t-1] -> price[t]` uses the hedge quantity held
|
|
155
|
+
at `t-1`. Only after that move do we evaluate the new option delta and
|
|
156
|
+
rebalance.
|
|
157
|
+
"""
|
|
158
|
+
ts = np.asarray(timestamps_ns, dtype=np.int64)
|
|
159
|
+
prices = np.asarray(underlying_prices, dtype=np.float64)
|
|
160
|
+
deltas = np.asarray(net_option_deltas, dtype=np.float64)
|
|
161
|
+
if len(ts) == 0 or len(ts) != len(prices) or len(ts) != len(deltas):
|
|
162
|
+
raise ValueError("timestamps, prices and deltas must be non-empty and equal length")
|
|
163
|
+
if bool((prices <= 0.0).any()) or bool((~np.isfinite(prices)).any()):
|
|
164
|
+
raise ValueError("underlying prices must be finite and > 0")
|
|
165
|
+
hedge_qty = float(initial_hedge_qty)
|
|
166
|
+
hedge_pnl = 0.0
|
|
167
|
+
last_rebalance_ts: Optional[int] = None
|
|
168
|
+
active = abs(hedge_qty) > 1e-12
|
|
169
|
+
rows = []
|
|
170
|
+
decisions = []
|
|
171
|
+
for i in range(len(ts)):
|
|
172
|
+
pnl = 0.0
|
|
173
|
+
if i > 0:
|
|
174
|
+
pnl = hedge_qty * (prices[i] - prices[i - 1]) * config.hedge_contract_multiplier
|
|
175
|
+
hedge_pnl += pnl
|
|
176
|
+
decision = hedge_decision(
|
|
177
|
+
timestamp_ns=int(ts[i]),
|
|
178
|
+
net_option_delta=float(deltas[i]),
|
|
179
|
+
current_hedge_qty=hedge_qty,
|
|
180
|
+
config=config,
|
|
181
|
+
last_rebalance_timestamp_ns=last_rebalance_ts,
|
|
182
|
+
underlying_prices=prices[max(0, i - config.realized_vol_window + 1) : i + 1],
|
|
183
|
+
currently_active=active,
|
|
184
|
+
)
|
|
185
|
+
if decision.should_rebalance:
|
|
186
|
+
hedge_qty += decision.trade_qty
|
|
187
|
+
last_rebalance_ts = int(ts[i])
|
|
188
|
+
active = abs(hedge_qty) > 1e-12
|
|
189
|
+
decisions.append(decision)
|
|
190
|
+
rows.append(
|
|
191
|
+
{
|
|
192
|
+
"timestamp_ns": int(ts[i]),
|
|
193
|
+
"underlying_price": float(prices[i]),
|
|
194
|
+
"prior_hedge_qty": decision.previous_hedge_qty,
|
|
195
|
+
"net_option_delta": decision.net_option_delta,
|
|
196
|
+
"hedge_pnl_for_prior_move": float(pnl),
|
|
197
|
+
"cumulative_hedge_pnl": float(hedge_pnl),
|
|
198
|
+
"target_hedge_qty": decision.target_hedge_qty,
|
|
199
|
+
"trade_qty": decision.trade_qty,
|
|
200
|
+
"hedge_qty_after": float(hedge_qty),
|
|
201
|
+
"should_rebalance": decision.should_rebalance,
|
|
202
|
+
"reason": decision.reason,
|
|
203
|
+
"band": decision.band,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return HedgePathResult(
|
|
207
|
+
hedge_report=pd.DataFrame(rows),
|
|
208
|
+
final_hedge_qty=float(hedge_qty),
|
|
209
|
+
hedge_pnl=float(hedge_pnl),
|
|
210
|
+
decisions=tuple(decisions),
|
|
211
|
+
metadata={"policy": config.policy.value, "hedge_contract_multiplier": config.hedge_contract_multiplier},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _active_band(config: OptionHedgeConfig, prices: Optional[Sequence[float]]) -> float:
|
|
216
|
+
if config.policy is not OptionHedgePolicyType.REALIZED_VOL_SCALED_BAND:
|
|
217
|
+
return float(config.threshold)
|
|
218
|
+
if prices is None or len(prices) < 2:
|
|
219
|
+
return float(config.min_band)
|
|
220
|
+
arr = np.asarray(prices, dtype=np.float64)
|
|
221
|
+
returns = np.diff(np.log(arr))
|
|
222
|
+
realized = float(np.std(returns, ddof=1)) if len(returns) > 1 else abs(float(returns[0]))
|
|
223
|
+
return max(float(config.min_band), realized * float(config.realized_vol_multiplier))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _coerce_policy(value) -> OptionHedgePolicyType:
|
|
227
|
+
if isinstance(value, OptionHedgePolicyType):
|
|
228
|
+
return value
|
|
229
|
+
try:
|
|
230
|
+
return OptionHedgePolicyType(str(value))
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
raise ValueError("invalid option hedge policy") from exc
|