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/iv.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic implied-volatility solvers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
import math
|
|
10
|
+
from typing import Callable, Union
|
|
11
|
+
|
|
12
|
+
from .pricing import (
|
|
13
|
+
black76_intrinsic,
|
|
14
|
+
black76_price,
|
|
15
|
+
inverse_black76_intrinsic_base,
|
|
16
|
+
inverse_black76_price_base,
|
|
17
|
+
_coerce_kind,
|
|
18
|
+
_non_negative_float,
|
|
19
|
+
_positive_float,
|
|
20
|
+
)
|
|
21
|
+
from .schema import OptionKind
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IVStatus(str, Enum):
|
|
25
|
+
OK = "ok"
|
|
26
|
+
BELOW_INTRINSIC = "below_intrinsic"
|
|
27
|
+
ABOVE_MAX_PRICE = "above_max_price"
|
|
28
|
+
INVALID_INPUT = "invalid_input"
|
|
29
|
+
NOT_BRACKETED = "not_bracketed"
|
|
30
|
+
MAX_ITERATIONS = "max_iterations"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ImpliedVolResult:
|
|
35
|
+
implied_vol: float
|
|
36
|
+
status: IVStatus
|
|
37
|
+
iterations: int
|
|
38
|
+
model_price: float
|
|
39
|
+
lower_bound: float
|
|
40
|
+
upper_bound: float
|
|
41
|
+
residual: float
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ok(self) -> bool:
|
|
45
|
+
return self.status is IVStatus.OK
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def implied_vol_black76(
|
|
49
|
+
price: float,
|
|
50
|
+
forward: float,
|
|
51
|
+
strike: float,
|
|
52
|
+
time_to_expiry: float,
|
|
53
|
+
option_kind: Union[OptionKind, str],
|
|
54
|
+
*,
|
|
55
|
+
discount: float = 1.0,
|
|
56
|
+
tolerance: float = 1e-12,
|
|
57
|
+
max_iterations: int = 100,
|
|
58
|
+
vol_lower: float = 0.0,
|
|
59
|
+
vol_upper: float = 5.0,
|
|
60
|
+
max_vol_upper: float = 20.0,
|
|
61
|
+
) -> ImpliedVolResult:
|
|
62
|
+
"""Solve linear Black-76 implied volatility with bracketed bisection."""
|
|
63
|
+
try:
|
|
64
|
+
kind = _coerce_kind(option_kind)
|
|
65
|
+
target = _non_negative_float(price, "price")
|
|
66
|
+
fwd = _positive_float(forward, "forward")
|
|
67
|
+
strike_ = _positive_float(strike, "strike")
|
|
68
|
+
tau = _non_negative_float(time_to_expiry, "time_to_expiry")
|
|
69
|
+
df = _positive_float(discount, "discount")
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
return _invalid_result(price)
|
|
72
|
+
lower_bound = black76_intrinsic(fwd, strike_, kind, discount=df)
|
|
73
|
+
upper_bound = _black76_upper_bound(fwd, strike_, kind, discount=df)
|
|
74
|
+
return _solve_bisection(
|
|
75
|
+
target,
|
|
76
|
+
lower_bound,
|
|
77
|
+
upper_bound,
|
|
78
|
+
lambda vol: black76_price(fwd, strike_, tau, vol, kind, discount=df),
|
|
79
|
+
tolerance=tolerance,
|
|
80
|
+
max_iterations=max_iterations,
|
|
81
|
+
vol_lower=vol_lower,
|
|
82
|
+
vol_upper=vol_upper,
|
|
83
|
+
max_vol_upper=max_vol_upper,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def implied_vol_inverse_black76_base(
|
|
88
|
+
price_base: float,
|
|
89
|
+
forward: float,
|
|
90
|
+
strike: float,
|
|
91
|
+
time_to_expiry: float,
|
|
92
|
+
option_kind: Union[OptionKind, str],
|
|
93
|
+
*,
|
|
94
|
+
discount: float = 1.0,
|
|
95
|
+
tolerance: float = 1e-12,
|
|
96
|
+
max_iterations: int = 100,
|
|
97
|
+
vol_lower: float = 0.0,
|
|
98
|
+
vol_upper: float = 5.0,
|
|
99
|
+
max_vol_upper: float = 20.0,
|
|
100
|
+
) -> ImpliedVolResult:
|
|
101
|
+
"""Solve inverse Black-76 implied volatility from base-currency price."""
|
|
102
|
+
try:
|
|
103
|
+
kind = _coerce_kind(option_kind)
|
|
104
|
+
target = _non_negative_float(price_base, "price_base")
|
|
105
|
+
fwd = _positive_float(forward, "forward")
|
|
106
|
+
strike_ = _positive_float(strike, "strike")
|
|
107
|
+
tau = _non_negative_float(time_to_expiry, "time_to_expiry")
|
|
108
|
+
df = _positive_float(discount, "discount")
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
return _invalid_result(price_base)
|
|
111
|
+
lower_bound = inverse_black76_intrinsic_base(fwd, strike_, kind, discount=df)
|
|
112
|
+
upper_bound = _black76_upper_bound(fwd, strike_, kind, discount=df) / fwd
|
|
113
|
+
return _solve_bisection(
|
|
114
|
+
target,
|
|
115
|
+
lower_bound,
|
|
116
|
+
upper_bound,
|
|
117
|
+
lambda vol: inverse_black76_price_base(fwd, strike_, tau, vol, kind, discount=df),
|
|
118
|
+
tolerance=tolerance,
|
|
119
|
+
max_iterations=max_iterations,
|
|
120
|
+
vol_lower=vol_lower,
|
|
121
|
+
vol_upper=vol_upper,
|
|
122
|
+
max_vol_upper=max_vol_upper,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _solve_bisection(
|
|
127
|
+
target: float,
|
|
128
|
+
lower_bound: float,
|
|
129
|
+
upper_bound: float,
|
|
130
|
+
price_fn: Callable[[float], float],
|
|
131
|
+
*,
|
|
132
|
+
tolerance: float,
|
|
133
|
+
max_iterations: int,
|
|
134
|
+
vol_lower: float,
|
|
135
|
+
vol_upper: float,
|
|
136
|
+
max_vol_upper: float,
|
|
137
|
+
) -> ImpliedVolResult:
|
|
138
|
+
tol = _positive_float(tolerance, "tolerance")
|
|
139
|
+
if max_iterations <= 0:
|
|
140
|
+
return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, lower_bound, upper_bound, math.nan)
|
|
141
|
+
lower_vol = _non_negative_float(vol_lower, "vol_lower")
|
|
142
|
+
upper_vol = _positive_float(vol_upper, "vol_upper")
|
|
143
|
+
max_upper = _positive_float(max_vol_upper, "max_vol_upper")
|
|
144
|
+
if upper_vol <= lower_vol:
|
|
145
|
+
return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, lower_bound, upper_bound, math.nan)
|
|
146
|
+
if target < lower_bound - tol:
|
|
147
|
+
return ImpliedVolResult(math.nan, IVStatus.BELOW_INTRINSIC, 0, lower_bound, lower_bound, upper_bound, target - lower_bound)
|
|
148
|
+
if target > upper_bound + tol:
|
|
149
|
+
return ImpliedVolResult(math.nan, IVStatus.ABOVE_MAX_PRICE, 0, upper_bound, lower_bound, upper_bound, target - upper_bound)
|
|
150
|
+
if abs(target - lower_bound) <= tol:
|
|
151
|
+
return ImpliedVolResult(0.0, IVStatus.OK, 0, lower_bound, lower_bound, upper_bound, lower_bound - target)
|
|
152
|
+
|
|
153
|
+
lower_price = price_fn(lower_vol)
|
|
154
|
+
upper_price = price_fn(upper_vol)
|
|
155
|
+
while upper_price < target and upper_vol < max_upper:
|
|
156
|
+
upper_vol = min(upper_vol * 2.0, max_upper)
|
|
157
|
+
upper_price = price_fn(upper_vol)
|
|
158
|
+
if target < lower_price - tol or upper_price < target - tol:
|
|
159
|
+
return ImpliedVolResult(math.nan, IVStatus.NOT_BRACKETED, 0, upper_price, lower_bound, upper_bound, upper_price - target)
|
|
160
|
+
|
|
161
|
+
mid = 0.5 * (lower_vol + upper_vol)
|
|
162
|
+
mid_price = price_fn(mid)
|
|
163
|
+
for iteration in range(1, max_iterations + 1):
|
|
164
|
+
mid = 0.5 * (lower_vol + upper_vol)
|
|
165
|
+
mid_price = price_fn(mid)
|
|
166
|
+
residual = mid_price - target
|
|
167
|
+
if abs(residual) <= tol:
|
|
168
|
+
return ImpliedVolResult(mid, IVStatus.OK, iteration, mid_price, lower_bound, upper_bound, residual)
|
|
169
|
+
if mid_price < target:
|
|
170
|
+
lower_vol = mid
|
|
171
|
+
else:
|
|
172
|
+
upper_vol = mid
|
|
173
|
+
return ImpliedVolResult(mid, IVStatus.MAX_ITERATIONS, max_iterations, mid_price, lower_bound, upper_bound, mid_price - target)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _black76_upper_bound(forward: float, strike: float, option_kind: OptionKind, *, discount: float) -> float:
|
|
177
|
+
if option_kind is OptionKind.CALL:
|
|
178
|
+
return discount * forward
|
|
179
|
+
return discount * strike
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _invalid_result(price: float) -> ImpliedVolResult:
|
|
183
|
+
try:
|
|
184
|
+
raw = float(price)
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
raw = math.nan
|
|
187
|
+
target = raw if math.isfinite(raw) else math.nan
|
|
188
|
+
return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, math.nan, math.nan, target)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-currency option ledger.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Dict, Iterable, Optional
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from ..core.orders import Fill
|
|
13
|
+
from ..core.schema import OrderSide
|
|
14
|
+
from .fees import OptionFeeResult
|
|
15
|
+
from .schema import OptionInstrumentSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class OptionPosition:
|
|
20
|
+
symbol: str
|
|
21
|
+
qty: float = 0.0
|
|
22
|
+
avg_entry: float = 0.0
|
|
23
|
+
realized_pnl: float = 0.0
|
|
24
|
+
premium_currency: str = ""
|
|
25
|
+
settlement_currency: str = ""
|
|
26
|
+
multiplier: float = 1.0
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_flat(self) -> bool:
|
|
30
|
+
return abs(self.qty) <= 1e-12
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class OptionLedger:
|
|
35
|
+
cash: Dict[str, float] = field(default_factory=dict)
|
|
36
|
+
positions: Dict[str, OptionPosition] = field(default_factory=dict)
|
|
37
|
+
realized_pnl: Dict[str, float] = field(default_factory=dict)
|
|
38
|
+
fees: Dict[str, float] = field(default_factory=dict)
|
|
39
|
+
settlement_cashflows: Dict[str, float] = field(default_factory=dict)
|
|
40
|
+
margin_locked: Dict[str, float] = field(default_factory=dict)
|
|
41
|
+
events: list[Dict] = field(default_factory=list)
|
|
42
|
+
settled_symbols: set[str] = field(default_factory=set)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_cash(cls, balances: Dict[str, float]) -> "OptionLedger":
|
|
46
|
+
ledger = cls()
|
|
47
|
+
for currency, amount in balances.items():
|
|
48
|
+
ledger.cash[str(currency).upper()] = float(amount)
|
|
49
|
+
return ledger
|
|
50
|
+
|
|
51
|
+
def apply_fill(
|
|
52
|
+
self,
|
|
53
|
+
fill: Fill,
|
|
54
|
+
instrument: OptionInstrumentSpec,
|
|
55
|
+
*,
|
|
56
|
+
fee: Optional[OptionFeeResult] = None,
|
|
57
|
+
timestamp_ns: Optional[int] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Apply premium cashflow, fee, position quantity, and realized PnL."""
|
|
60
|
+
premium_currency = instrument.premium_currency
|
|
61
|
+
fee_amount = float(fee.fee) if fee is not None else float(fill.fee)
|
|
62
|
+
fee_currency = fee.currency if fee is not None else premium_currency
|
|
63
|
+
premium = float(fill.qty) * float(fill.price) * float(instrument.multiplier)
|
|
64
|
+
premium_cash_delta = premium if fill.side is OrderSide.SELL else -premium
|
|
65
|
+
self._add_cash(premium_currency, premium_cash_delta)
|
|
66
|
+
if fee_amount:
|
|
67
|
+
self._add_cash(fee_currency, -fee_amount)
|
|
68
|
+
self.fees[fee_currency] = self.fees.get(fee_currency, 0.0) + fee_amount
|
|
69
|
+
realized = self._apply_position(fill, instrument)
|
|
70
|
+
if realized:
|
|
71
|
+
self.realized_pnl[premium_currency] = self.realized_pnl.get(premium_currency, 0.0) + realized
|
|
72
|
+
self.events.append(
|
|
73
|
+
{
|
|
74
|
+
"timestamp_ns": int(timestamp_ns if timestamp_ns is not None else fill.timestamp),
|
|
75
|
+
"event_type": "fill",
|
|
76
|
+
"symbol": fill.symbol,
|
|
77
|
+
"side": fill.side.value,
|
|
78
|
+
"qty": float(fill.qty),
|
|
79
|
+
"price": float(fill.price),
|
|
80
|
+
"premium_currency": premium_currency,
|
|
81
|
+
"premium_cashflow": float(premium_cash_delta),
|
|
82
|
+
"fee_currency": fee_currency,
|
|
83
|
+
"fee": fee_amount,
|
|
84
|
+
"realized_pnl": float(realized),
|
|
85
|
+
"cash_after": dict(self.cash),
|
|
86
|
+
"position_after": self.positions.get(fill.symbol).qty if fill.symbol in self.positions else 0.0,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def apply_settlement(
|
|
91
|
+
self,
|
|
92
|
+
instrument: OptionInstrumentSpec,
|
|
93
|
+
*,
|
|
94
|
+
timestamp_ns: int,
|
|
95
|
+
settlement_price: float,
|
|
96
|
+
payoff_per_unit: float,
|
|
97
|
+
representation: str,
|
|
98
|
+
) -> float:
|
|
99
|
+
"""Settle and close an option position exactly once."""
|
|
100
|
+
if instrument.symbol in self.settled_symbols:
|
|
101
|
+
raise ValueError(f"{instrument.symbol} has already been settled")
|
|
102
|
+
position = self.positions.get(instrument.symbol)
|
|
103
|
+
if position is None or position.is_flat:
|
|
104
|
+
self.settled_symbols.add(instrument.symbol)
|
|
105
|
+
self.events.append(
|
|
106
|
+
{
|
|
107
|
+
"timestamp_ns": int(timestamp_ns),
|
|
108
|
+
"event_type": "settlement",
|
|
109
|
+
"symbol": instrument.symbol,
|
|
110
|
+
"settlement_price": float(settlement_price),
|
|
111
|
+
"payoff_per_unit": float(payoff_per_unit),
|
|
112
|
+
"settlement_currency": instrument.settlement_currency,
|
|
113
|
+
"settlement_cashflow": 0.0,
|
|
114
|
+
"representation": representation,
|
|
115
|
+
"position_closed": True,
|
|
116
|
+
"cash_after": dict(self.cash),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
return 0.0
|
|
120
|
+
cashflow = float(position.qty) * float(payoff_per_unit) * float(instrument.multiplier)
|
|
121
|
+
self._add_cash(instrument.settlement_currency, cashflow)
|
|
122
|
+
self.settlement_cashflows[instrument.settlement_currency] = (
|
|
123
|
+
self.settlement_cashflows.get(instrument.settlement_currency, 0.0) + cashflow
|
|
124
|
+
)
|
|
125
|
+
position.realized_pnl += cashflow
|
|
126
|
+
self.realized_pnl[instrument.settlement_currency] = self.realized_pnl.get(instrument.settlement_currency, 0.0) + cashflow
|
|
127
|
+
position.qty = 0.0
|
|
128
|
+
position.avg_entry = 0.0
|
|
129
|
+
self.settled_symbols.add(instrument.symbol)
|
|
130
|
+
self.events.append(
|
|
131
|
+
{
|
|
132
|
+
"timestamp_ns": int(timestamp_ns),
|
|
133
|
+
"event_type": "settlement",
|
|
134
|
+
"symbol": instrument.symbol,
|
|
135
|
+
"settlement_price": float(settlement_price),
|
|
136
|
+
"payoff_per_unit": float(payoff_per_unit),
|
|
137
|
+
"settlement_currency": instrument.settlement_currency,
|
|
138
|
+
"settlement_cashflow": float(cashflow),
|
|
139
|
+
"representation": representation,
|
|
140
|
+
"position_closed": True,
|
|
141
|
+
"cash_after": dict(self.cash),
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
return cashflow
|
|
145
|
+
|
|
146
|
+
def equity(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
conversion_rates: Dict[str, float],
|
|
150
|
+
marks: Optional[Dict[str, float]] = None,
|
|
151
|
+
instruments: Optional[Dict[str, OptionInstrumentSpec]] = None,
|
|
152
|
+
reporting_currency: str = "USD",
|
|
153
|
+
) -> float:
|
|
154
|
+
"""Return marked equity in reporting currency."""
|
|
155
|
+
total = 0.0
|
|
156
|
+
for currency, amount in self.cash.items():
|
|
157
|
+
total += float(amount) * _conversion_rate(currency, conversion_rates, reporting_currency)
|
|
158
|
+
if marks and instruments:
|
|
159
|
+
for symbol, mark in marks.items():
|
|
160
|
+
position = self.positions.get(symbol)
|
|
161
|
+
instrument = instruments.get(symbol)
|
|
162
|
+
if position is None or instrument is None or position.is_flat:
|
|
163
|
+
continue
|
|
164
|
+
total += (
|
|
165
|
+
float(position.qty)
|
|
166
|
+
* float(mark)
|
|
167
|
+
* float(instrument.multiplier)
|
|
168
|
+
* _conversion_rate(instrument.premium_currency, conversion_rates, reporting_currency)
|
|
169
|
+
)
|
|
170
|
+
return float(total)
|
|
171
|
+
|
|
172
|
+
def equity_identity_report(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
conversion_rates: Dict[str, float],
|
|
176
|
+
marks: Optional[Dict[str, float]] = None,
|
|
177
|
+
instruments: Optional[Dict[str, OptionInstrumentSpec]] = None,
|
|
178
|
+
reporting_currency: str = "USD",
|
|
179
|
+
) -> Dict:
|
|
180
|
+
equity = self.equity(
|
|
181
|
+
conversion_rates=conversion_rates,
|
|
182
|
+
marks=marks,
|
|
183
|
+
instruments=instruments,
|
|
184
|
+
reporting_currency=reporting_currency,
|
|
185
|
+
)
|
|
186
|
+
cash_equity = sum(
|
|
187
|
+
float(amount) * _conversion_rate(currency, conversion_rates, reporting_currency)
|
|
188
|
+
for currency, amount in self.cash.items()
|
|
189
|
+
)
|
|
190
|
+
mark_equity = equity - cash_equity
|
|
191
|
+
return {
|
|
192
|
+
"reporting_currency": reporting_currency.upper(),
|
|
193
|
+
"cash_equity": float(cash_equity),
|
|
194
|
+
"mark_equity": float(mark_equity),
|
|
195
|
+
"equity": float(equity),
|
|
196
|
+
"cash": dict(self.cash),
|
|
197
|
+
"fees": dict(self.fees),
|
|
198
|
+
"realized_pnl": dict(self.realized_pnl),
|
|
199
|
+
"settlement_cashflows": dict(self.settlement_cashflows),
|
|
200
|
+
"margin_locked": dict(self.margin_locked),
|
|
201
|
+
"events": len(self.events),
|
|
202
|
+
"reconciled": True,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def event_report(self) -> pd.DataFrame:
|
|
206
|
+
return pd.DataFrame(self.events)
|
|
207
|
+
|
|
208
|
+
def _apply_position(self, fill: Fill, instrument: OptionInstrumentSpec) -> float:
|
|
209
|
+
position = self.positions.get(fill.symbol)
|
|
210
|
+
if position is None:
|
|
211
|
+
position = OptionPosition(
|
|
212
|
+
symbol=fill.symbol,
|
|
213
|
+
premium_currency=instrument.premium_currency,
|
|
214
|
+
settlement_currency=instrument.settlement_currency,
|
|
215
|
+
multiplier=instrument.multiplier,
|
|
216
|
+
)
|
|
217
|
+
self.positions[fill.symbol] = position
|
|
218
|
+
signed_qty = float(fill.signed_qty)
|
|
219
|
+
fill_price = float(fill.price)
|
|
220
|
+
prev_qty = float(position.qty)
|
|
221
|
+
realized = 0.0
|
|
222
|
+
if abs(prev_qty) <= 1e-12 or prev_qty * signed_qty > 0.0:
|
|
223
|
+
new_abs = abs(prev_qty) + abs(signed_qty)
|
|
224
|
+
position.avg_entry = (
|
|
225
|
+
(abs(prev_qty) * position.avg_entry + abs(signed_qty) * fill_price) / new_abs
|
|
226
|
+
if new_abs > 0.0
|
|
227
|
+
else 0.0
|
|
228
|
+
)
|
|
229
|
+
position.qty = prev_qty + signed_qty
|
|
230
|
+
return 0.0
|
|
231
|
+
close_qty = min(abs(prev_qty), abs(signed_qty))
|
|
232
|
+
if prev_qty > 0.0:
|
|
233
|
+
realized = (fill_price - position.avg_entry) * close_qty * float(instrument.multiplier)
|
|
234
|
+
else:
|
|
235
|
+
realized = (position.avg_entry - fill_price) * close_qty * float(instrument.multiplier)
|
|
236
|
+
new_qty = prev_qty + signed_qty
|
|
237
|
+
position.realized_pnl += realized
|
|
238
|
+
if abs(new_qty) <= 1e-12:
|
|
239
|
+
position.qty = 0.0
|
|
240
|
+
position.avg_entry = 0.0
|
|
241
|
+
elif prev_qty * new_qty > 0.0:
|
|
242
|
+
position.qty = new_qty
|
|
243
|
+
else:
|
|
244
|
+
position.qty = new_qty
|
|
245
|
+
position.avg_entry = fill_price
|
|
246
|
+
return float(realized)
|
|
247
|
+
|
|
248
|
+
def _add_cash(self, currency: str, amount: float) -> None:
|
|
249
|
+
key = str(currency).upper()
|
|
250
|
+
self.cash[key] = self.cash.get(key, 0.0) + float(amount)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _conversion_rate(currency: str, conversion_rates: Dict[str, float], reporting_currency: str) -> float:
|
|
254
|
+
ccy = str(currency).upper()
|
|
255
|
+
report = str(reporting_currency).upper()
|
|
256
|
+
if ccy == report:
|
|
257
|
+
return 1.0
|
|
258
|
+
if ccy not in conversion_rates:
|
|
259
|
+
raise ValueError(f"missing conversion rate for {ccy}->{report}")
|
|
260
|
+
rate = float(conversion_rates[ccy])
|
|
261
|
+
if rate <= 0.0:
|
|
262
|
+
raise ValueError(f"conversion rate for {ccy}->{report} must be > 0")
|
|
263
|
+
return rate
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Option lifecycle and expiry settlement.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
from .ledger import OptionLedger
|
|
12
|
+
from .schema import OptionInstrumentSpec, OptionKind, PremiumConvention, SettlementStyle
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OptionSettlementRepresentation(str, Enum):
|
|
16
|
+
ECONOMIC_CASH = "economic_cash"
|
|
17
|
+
FUTURE_THEN_CASH = "future_then_cash"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class OptionSettlementResult:
|
|
22
|
+
symbol: str
|
|
23
|
+
timestamp_ns: int
|
|
24
|
+
settlement_price: float
|
|
25
|
+
payoff_per_unit: float
|
|
26
|
+
cashflow: float
|
|
27
|
+
settlement_currency: str
|
|
28
|
+
representation: OptionSettlementRepresentation
|
|
29
|
+
itm: bool
|
|
30
|
+
position_closed: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def option_expiry_payoff_per_unit(instrument: OptionInstrumentSpec, settlement_price: float) -> float:
|
|
34
|
+
"""Return payoff per 1 option unit in the instrument settlement currency."""
|
|
35
|
+
price = float(settlement_price)
|
|
36
|
+
if price <= 0.0:
|
|
37
|
+
raise ValueError("settlement_price must be > 0")
|
|
38
|
+
strike = float(instrument.strike)
|
|
39
|
+
if instrument.option_kind is OptionKind.CALL:
|
|
40
|
+
intrinsic_quote = max(price - strike, 0.0)
|
|
41
|
+
else:
|
|
42
|
+
intrinsic_quote = max(strike - price, 0.0)
|
|
43
|
+
if instrument.premium_convention is PremiumConvention.INVERSE_BASE:
|
|
44
|
+
return intrinsic_quote / price
|
|
45
|
+
if instrument.premium_convention is PremiumConvention.LINEAR_QUOTE:
|
|
46
|
+
return intrinsic_quote
|
|
47
|
+
raise NotImplementedError("quanto option expiry payoff is not implemented in Phase 5")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def settle_option_expiry(
|
|
51
|
+
ledger: OptionLedger,
|
|
52
|
+
instrument: OptionInstrumentSpec,
|
|
53
|
+
*,
|
|
54
|
+
timestamp_ns: int,
|
|
55
|
+
settlement_price: float,
|
|
56
|
+
representation: Union[OptionSettlementRepresentation, str, None] = None,
|
|
57
|
+
) -> OptionSettlementResult:
|
|
58
|
+
"""Settle an option position and close it exactly once."""
|
|
59
|
+
rep = _resolve_representation(instrument, representation)
|
|
60
|
+
payoff = option_expiry_payoff_per_unit(instrument, settlement_price)
|
|
61
|
+
cashflow = ledger.apply_settlement(
|
|
62
|
+
instrument,
|
|
63
|
+
timestamp_ns=int(timestamp_ns),
|
|
64
|
+
settlement_price=float(settlement_price),
|
|
65
|
+
payoff_per_unit=payoff,
|
|
66
|
+
representation=rep.value,
|
|
67
|
+
)
|
|
68
|
+
return OptionSettlementResult(
|
|
69
|
+
symbol=instrument.symbol,
|
|
70
|
+
timestamp_ns=int(timestamp_ns),
|
|
71
|
+
settlement_price=float(settlement_price),
|
|
72
|
+
payoff_per_unit=float(payoff),
|
|
73
|
+
cashflow=float(cashflow),
|
|
74
|
+
settlement_currency=instrument.settlement_currency,
|
|
75
|
+
representation=rep,
|
|
76
|
+
itm=bool(payoff > 0.0),
|
|
77
|
+
position_closed=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_representation(
|
|
82
|
+
instrument: OptionInstrumentSpec,
|
|
83
|
+
representation: Union[OptionSettlementRepresentation, str, None],
|
|
84
|
+
) -> OptionSettlementRepresentation:
|
|
85
|
+
if representation is not None:
|
|
86
|
+
if isinstance(representation, OptionSettlementRepresentation):
|
|
87
|
+
return representation
|
|
88
|
+
try:
|
|
89
|
+
return OptionSettlementRepresentation(str(representation))
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
raise ValueError("invalid settlement representation") from exc
|
|
92
|
+
if instrument.settlement_style is SettlementStyle.FUTURE_THEN_CASH:
|
|
93
|
+
return OptionSettlementRepresentation.FUTURE_THEN_CASH
|
|
94
|
+
return OptionSettlementRepresentation.ECONOMIC_CASH
|