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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Option strategy adapters.
|
|
2
|
+
|
|
3
|
+
Adapters live above the option execution engine. They convert observable
|
|
4
|
+
option-chain snapshots into package intents and audit tables. They do not own
|
|
5
|
+
fills, premium accounting, margin, settlement, or PnL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ..core.schema import OrderSide
|
|
17
|
+
from .hedging import OptionHedgeConfig
|
|
18
|
+
from .packages import OptionPackageIntent, OptionPackageLeg
|
|
19
|
+
from .schema import OptionInstrumentRegistry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class OptionStrategyRun:
|
|
24
|
+
"""Package-level strategy output consumed by `QuantBTEndpoint.options`."""
|
|
25
|
+
|
|
26
|
+
packages: tuple[OptionPackageIntent, ...]
|
|
27
|
+
hedge_policy: Optional[OptionHedgeConfig] = None
|
|
28
|
+
selected_contracts: pd.DataFrame = field(default_factory=pd.DataFrame)
|
|
29
|
+
metadata: Dict = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class GammaScalpingConfig:
|
|
34
|
+
"""Configuration for a simple ATM straddle gamma-scalping adapter."""
|
|
35
|
+
|
|
36
|
+
side: str = "long"
|
|
37
|
+
quantity: float = 1.0
|
|
38
|
+
min_dte_days: float = 2.0
|
|
39
|
+
max_dte_days: float = 45.0
|
|
40
|
+
roll_dte_days: float = 2.0
|
|
41
|
+
max_spread_bps: Optional[float] = None
|
|
42
|
+
min_bid_size: float = 0.0
|
|
43
|
+
min_ask_size: float = 0.0
|
|
44
|
+
min_volume: float = 0.0
|
|
45
|
+
min_open_interest: float = 0.0
|
|
46
|
+
hedge_policy: Optional[OptionHedgeConfig] = None
|
|
47
|
+
metadata: Dict = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
side = str(self.side).lower().strip()
|
|
51
|
+
if side not in {"long", "short"}:
|
|
52
|
+
raise ValueError("GammaScalpingConfig.side must be long or short")
|
|
53
|
+
object.__setattr__(self, "side", side)
|
|
54
|
+
if self.quantity <= 0.0:
|
|
55
|
+
raise ValueError("GammaScalpingConfig.quantity must be > 0")
|
|
56
|
+
if self.min_dte_days < 0.0 or self.max_dte_days <= 0.0:
|
|
57
|
+
raise ValueError("DTE bounds must be non-negative and max_dte_days > 0")
|
|
58
|
+
if self.min_dte_days > self.max_dte_days:
|
|
59
|
+
raise ValueError("min_dte_days must be <= max_dte_days")
|
|
60
|
+
if self.roll_dte_days < 0.0:
|
|
61
|
+
raise ValueError("roll_dte_days must be >= 0")
|
|
62
|
+
for name in ("min_bid_size", "min_ask_size", "min_volume", "min_open_interest"):
|
|
63
|
+
if getattr(self, name) < 0.0:
|
|
64
|
+
raise ValueError(f"{name} must be >= 0")
|
|
65
|
+
if self.max_spread_bps is not None and self.max_spread_bps < 0.0:
|
|
66
|
+
raise ValueError("max_spread_bps must be >= 0")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_gamma_scalping_strategy_run(
|
|
70
|
+
chain: pd.DataFrame,
|
|
71
|
+
instruments: OptionInstrumentRegistry,
|
|
72
|
+
config: Optional[GammaScalpingConfig] = None,
|
|
73
|
+
) -> OptionStrategyRun:
|
|
74
|
+
"""
|
|
75
|
+
Build open/roll/close straddle packages from observable chain snapshots.
|
|
76
|
+
|
|
77
|
+
Selection is snapshot-local: at each decision timestamp, the adapter only
|
|
78
|
+
inspects rows with that exact `timestamp_ns`. The selected pair is the
|
|
79
|
+
valid same-expiry same-strike call/put closest to the observed index price.
|
|
80
|
+
"""
|
|
81
|
+
cfg = config or GammaScalpingConfig()
|
|
82
|
+
frame = _canonical_strategy_frame(chain)
|
|
83
|
+
valid_symbols = set(instruments.symbols)
|
|
84
|
+
frame = frame[frame["instrument_id"].isin(valid_symbols)].copy()
|
|
85
|
+
if frame.empty:
|
|
86
|
+
raise ValueError("gamma scalping adapter found no chain rows matching instrument registry")
|
|
87
|
+
|
|
88
|
+
timestamps = [int(ts) for ts in sorted(frame["timestamp_ns"].unique())]
|
|
89
|
+
packages: list[OptionPackageIntent] = []
|
|
90
|
+
selected_rows: list[dict] = []
|
|
91
|
+
active: Optional[dict] = None
|
|
92
|
+
|
|
93
|
+
for ts in timestamps:
|
|
94
|
+
is_last = ts == timestamps[-1]
|
|
95
|
+
if active is not None:
|
|
96
|
+
dte = (int(active["expiry_ns"]) - ts) / _DAY_NS
|
|
97
|
+
if dte <= cfg.roll_dte_days or is_last:
|
|
98
|
+
if _has_quotes(frame, ts, (active["call_id"], active["put_id"])):
|
|
99
|
+
packages.append(_straddle_package(ts, active, cfg, action="close"))
|
|
100
|
+
selected_rows.append({**active, "timestamp_ns": ts, "action": "close", "dte_days": float(dte)})
|
|
101
|
+
active = None
|
|
102
|
+
if is_last:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
if active is None and not is_last:
|
|
106
|
+
selection = _select_atm_pair(frame, ts, cfg)
|
|
107
|
+
if selection is None:
|
|
108
|
+
continue
|
|
109
|
+
packages.append(_straddle_package(ts, selection, cfg, action="open"))
|
|
110
|
+
dte = (int(selection["expiry_ns"]) - ts) / _DAY_NS
|
|
111
|
+
selected_rows.append({**selection, "timestamp_ns": ts, "action": "open", "dte_days": float(dte)})
|
|
112
|
+
active = selection
|
|
113
|
+
|
|
114
|
+
if active is not None:
|
|
115
|
+
ts = timestamps[-1]
|
|
116
|
+
if _has_quotes(frame, ts, (active["call_id"], active["put_id"])):
|
|
117
|
+
packages.append(_straddle_package(ts, active, cfg, action="close"))
|
|
118
|
+
selected_rows.append(
|
|
119
|
+
{
|
|
120
|
+
**active,
|
|
121
|
+
"timestamp_ns": ts,
|
|
122
|
+
"action": "close",
|
|
123
|
+
"dte_days": float((int(active["expiry_ns"]) - ts) / _DAY_NS),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
selected = pd.DataFrame(selected_rows)
|
|
128
|
+
return OptionStrategyRun(
|
|
129
|
+
packages=tuple(packages),
|
|
130
|
+
hedge_policy=cfg.hedge_policy,
|
|
131
|
+
selected_contracts=selected,
|
|
132
|
+
metadata={
|
|
133
|
+
"strategy": "gamma_scalping",
|
|
134
|
+
"side": cfg.side,
|
|
135
|
+
"quantity": float(cfg.quantity),
|
|
136
|
+
"package_count": len(packages),
|
|
137
|
+
"selection_count": len(selected),
|
|
138
|
+
**cfg.metadata,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _canonical_strategy_frame(chain: pd.DataFrame) -> pd.DataFrame:
|
|
144
|
+
required = {
|
|
145
|
+
"timestamp_ns",
|
|
146
|
+
"instrument_id",
|
|
147
|
+
"expiry_ns",
|
|
148
|
+
"strike",
|
|
149
|
+
"option_kind",
|
|
150
|
+
"bid_price",
|
|
151
|
+
"ask_price",
|
|
152
|
+
"bid_size",
|
|
153
|
+
"ask_size",
|
|
154
|
+
"index_price",
|
|
155
|
+
}
|
|
156
|
+
missing = sorted(required.difference(chain.columns))
|
|
157
|
+
if missing:
|
|
158
|
+
raise ValueError(f"gamma scalping chain missing columns: {missing}")
|
|
159
|
+
frame = chain.copy()
|
|
160
|
+
for column in ("timestamp_ns", "expiry_ns"):
|
|
161
|
+
frame[column] = pd.to_numeric(frame[column], errors="raise").astype("int64")
|
|
162
|
+
for column in ("strike", "bid_price", "ask_price", "bid_size", "ask_size", "index_price"):
|
|
163
|
+
frame[column] = pd.to_numeric(frame[column], errors="raise").astype("float64")
|
|
164
|
+
if "volume" not in frame:
|
|
165
|
+
frame["volume"] = 0.0
|
|
166
|
+
if "open_interest" not in frame:
|
|
167
|
+
frame["open_interest"] = 0.0
|
|
168
|
+
frame["volume"] = pd.to_numeric(frame["volume"], errors="coerce").fillna(0.0).astype("float64")
|
|
169
|
+
frame["open_interest"] = pd.to_numeric(frame["open_interest"], errors="coerce").fillna(0.0).astype("float64")
|
|
170
|
+
frame["option_kind"] = frame["option_kind"].astype(str).str.lower().str.strip()
|
|
171
|
+
return frame.sort_values(["timestamp_ns", "expiry_ns", "strike", "option_kind", "instrument_id"]).reset_index(drop=True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _select_atm_pair(frame: pd.DataFrame, timestamp_ns: int, cfg: GammaScalpingConfig) -> Optional[dict]:
|
|
175
|
+
snap = frame[frame["timestamp_ns"] == int(timestamp_ns)].copy()
|
|
176
|
+
if snap.empty:
|
|
177
|
+
return None
|
|
178
|
+
snap = snap[(snap["bid_price"] > 0.0) & (snap["ask_price"] > 0.0) & (snap["ask_price"] >= snap["bid_price"])]
|
|
179
|
+
snap = snap[(snap["bid_size"] >= cfg.min_bid_size) & (snap["ask_size"] >= cfg.min_ask_size)]
|
|
180
|
+
snap = snap[(snap["volume"] >= cfg.min_volume) & (snap["open_interest"] >= cfg.min_open_interest)]
|
|
181
|
+
dte = (snap["expiry_ns"] - int(timestamp_ns)) / _DAY_NS
|
|
182
|
+
snap = snap[(dte >= cfg.min_dte_days) & (dte <= cfg.max_dte_days)]
|
|
183
|
+
if cfg.max_spread_bps is not None:
|
|
184
|
+
mid = 0.5 * (snap["bid_price"] + snap["ask_price"])
|
|
185
|
+
spread_bps = np.where(mid > 0.0, (snap["ask_price"] - snap["bid_price"]) / mid * 10_000.0, np.inf)
|
|
186
|
+
snap = snap[spread_bps <= float(cfg.max_spread_bps)]
|
|
187
|
+
if snap.empty:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
spot = float(snap["index_price"].median())
|
|
191
|
+
pair_groups = snap.groupby(["expiry_ns", "strike"])
|
|
192
|
+
candidates = []
|
|
193
|
+
for (expiry_ns, strike), group in pair_groups:
|
|
194
|
+
kinds = set(group["option_kind"])
|
|
195
|
+
if kinds != {"call", "put"}:
|
|
196
|
+
continue
|
|
197
|
+
call = group[group["option_kind"] == "call"].iloc[0]
|
|
198
|
+
put = group[group["option_kind"] == "put"].iloc[0]
|
|
199
|
+
candidates.append(
|
|
200
|
+
{
|
|
201
|
+
"expiry_ns": int(expiry_ns),
|
|
202
|
+
"strike": float(strike),
|
|
203
|
+
"spot": spot,
|
|
204
|
+
"call_id": str(call["instrument_id"]),
|
|
205
|
+
"put_id": str(put["instrument_id"]),
|
|
206
|
+
"call_delta": float(call.get("delta", np.nan)),
|
|
207
|
+
"put_delta": float(put.get("delta", np.nan)),
|
|
208
|
+
"distance": abs(float(strike) - spot),
|
|
209
|
+
"dte_days": float((int(expiry_ns) - int(timestamp_ns)) / _DAY_NS),
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
if not candidates:
|
|
213
|
+
return None
|
|
214
|
+
return min(candidates, key=lambda row: (row["distance"], row["dte_days"]))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _straddle_package(timestamp_ns: int, selection: dict, cfg: GammaScalpingConfig, *, action: str) -> OptionPackageIntent:
|
|
218
|
+
if action == "open":
|
|
219
|
+
side = OrderSide.BUY if cfg.side == "long" else OrderSide.SELL
|
|
220
|
+
elif action == "close":
|
|
221
|
+
side = OrderSide.SELL if cfg.side == "long" else OrderSide.BUY
|
|
222
|
+
else:
|
|
223
|
+
raise ValueError("action must be open or close")
|
|
224
|
+
return OptionPackageIntent(
|
|
225
|
+
timestamp_ns=int(timestamp_ns),
|
|
226
|
+
package_id=f"gamma-{action}:{selection['call_id']}:{selection['put_id']}:{timestamp_ns}",
|
|
227
|
+
legs=(
|
|
228
|
+
OptionPackageLeg(selection["call_id"], side, 1.0, role=f"{action}_call"),
|
|
229
|
+
OptionPackageLeg(selection["put_id"], side, 1.0, role=f"{action}_put"),
|
|
230
|
+
),
|
|
231
|
+
quantity=float(cfg.quantity),
|
|
232
|
+
tag=f"gamma_scalping_{action}",
|
|
233
|
+
metadata={
|
|
234
|
+
"strategy": "gamma_scalping",
|
|
235
|
+
"action": action,
|
|
236
|
+
"side": cfg.side,
|
|
237
|
+
"strike": float(selection["strike"]),
|
|
238
|
+
"expiry_ns": int(selection["expiry_ns"]),
|
|
239
|
+
"spot": float(selection["spot"]),
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _has_quotes(frame: pd.DataFrame, timestamp_ns: int, symbols: Sequence[str]) -> bool:
|
|
245
|
+
snap_symbols = set(frame.loc[frame["timestamp_ns"] == int(timestamp_ns), "instrument_id"])
|
|
246
|
+
return all(symbol in snap_symbols for symbol in symbols)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_DAY_NS = 24 * 60 * 60 * 1_000_000_000
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal option surface diagnostics.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, Iterable, Tuple
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class SurfaceDiagnostics:
|
|
16
|
+
positive_total_variance: bool
|
|
17
|
+
no_future_timestamps: bool
|
|
18
|
+
expiries_after_snapshot: bool
|
|
19
|
+
calendar_total_variance_non_decreasing: bool
|
|
20
|
+
butterfly_convexity_checked: bool
|
|
21
|
+
notes: Tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def pass_basic(self) -> bool:
|
|
25
|
+
return (
|
|
26
|
+
self.positive_total_variance
|
|
27
|
+
and self.no_future_timestamps
|
|
28
|
+
and self.expiries_after_snapshot
|
|
29
|
+
and self.calendar_total_variance_non_decreasing
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TotalVarianceSurface:
|
|
35
|
+
timestamp_ns: int
|
|
36
|
+
expiry_ns: np.ndarray
|
|
37
|
+
strike: np.ndarray
|
|
38
|
+
total_variance: np.ndarray
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
timestamp = int(self.timestamp_ns)
|
|
42
|
+
expiry = np.asarray(self.expiry_ns, dtype=np.int64)
|
|
43
|
+
strike = np.asarray(self.strike, dtype=np.float64)
|
|
44
|
+
variance = np.asarray(self.total_variance, dtype=np.float64)
|
|
45
|
+
if timestamp <= 0:
|
|
46
|
+
raise ValueError("timestamp_ns must be > 0")
|
|
47
|
+
if expiry.ndim != 1 or strike.ndim != 1 or variance.ndim != 1:
|
|
48
|
+
raise ValueError("surface arrays must be 1-D")
|
|
49
|
+
if len(expiry) == 0 or len(expiry) != len(strike) or len(expiry) != len(variance):
|
|
50
|
+
raise ValueError("surface arrays must be non-empty and equal length")
|
|
51
|
+
if bool((expiry <= timestamp).any()):
|
|
52
|
+
raise ValueError("surface expiry_ns must be after timestamp_ns")
|
|
53
|
+
if bool((strike <= 0.0).any()):
|
|
54
|
+
raise ValueError("surface strikes must be > 0")
|
|
55
|
+
if bool((~np.isfinite(variance)).any()) or bool((variance < 0.0).any()):
|
|
56
|
+
raise ValueError("total_variance must be finite and >= 0")
|
|
57
|
+
order = np.lexsort((strike, expiry))
|
|
58
|
+
object.__setattr__(self, "timestamp_ns", timestamp)
|
|
59
|
+
object.__setattr__(self, "expiry_ns", expiry[order])
|
|
60
|
+
object.__setattr__(self, "strike", strike[order])
|
|
61
|
+
object.__setattr__(self, "total_variance", variance[order])
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_snapshot_frame(
|
|
65
|
+
cls,
|
|
66
|
+
frame: pd.DataFrame,
|
|
67
|
+
*,
|
|
68
|
+
timestamp_ns: int,
|
|
69
|
+
volatility_column: str = "mark_iv",
|
|
70
|
+
) -> "TotalVarianceSurface":
|
|
71
|
+
required = {"timestamp_ns", "expiry_ns", "strike", volatility_column}
|
|
72
|
+
missing = sorted(required.difference(frame.columns))
|
|
73
|
+
if missing:
|
|
74
|
+
raise ValueError(f"surface frame missing required columns: {missing}")
|
|
75
|
+
timestamp = int(timestamp_ns)
|
|
76
|
+
future_rows = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") > timestamp]
|
|
77
|
+
if len(future_rows) > 0:
|
|
78
|
+
raise ValueError("surface calibration cannot include future timestamp rows")
|
|
79
|
+
snapshot = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") == timestamp].copy()
|
|
80
|
+
if snapshot.empty:
|
|
81
|
+
raise ValueError("surface snapshot has no rows for timestamp_ns")
|
|
82
|
+
expiry = pd.to_numeric(snapshot["expiry_ns"], errors="raise").astype("int64").to_numpy()
|
|
83
|
+
strike = pd.to_numeric(snapshot["strike"], errors="raise").astype("float64").to_numpy()
|
|
84
|
+
vol = pd.to_numeric(snapshot[volatility_column], errors="raise").astype("float64").to_numpy()
|
|
85
|
+
tau_years = (expiry.astype(np.float64) - float(timestamp)) / (365.0 * 24.0 * 60.0 * 60.0 * 1_000_000_000.0)
|
|
86
|
+
total_variance = vol * vol * tau_years
|
|
87
|
+
return cls(timestamp_ns=timestamp, expiry_ns=expiry, strike=strike, total_variance=total_variance)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def expiries(self) -> np.ndarray:
|
|
91
|
+
return np.unique(self.expiry_ns)
|
|
92
|
+
|
|
93
|
+
def interpolate_total_variance(self, *, expiry_ns: int, strike: float) -> float:
|
|
94
|
+
"""Interpolate total variance by strike first, then expiry."""
|
|
95
|
+
target_expiry = int(expiry_ns)
|
|
96
|
+
target_strike = float(strike)
|
|
97
|
+
if target_expiry <= self.timestamp_ns:
|
|
98
|
+
raise ValueError("target expiry must be after surface timestamp")
|
|
99
|
+
if target_strike <= 0.0:
|
|
100
|
+
raise ValueError("target strike must be > 0")
|
|
101
|
+
expiries = self.expiries
|
|
102
|
+
per_expiry = np.array([self._interpolate_strike(expiry, target_strike) for expiry in expiries], dtype=np.float64)
|
|
103
|
+
if len(expiries) == 1:
|
|
104
|
+
return float(per_expiry[0])
|
|
105
|
+
return float(np.interp(float(target_expiry), expiries.astype(np.float64), per_expiry))
|
|
106
|
+
|
|
107
|
+
def diagnostics(self) -> SurfaceDiagnostics:
|
|
108
|
+
notes = ["butterfly convexity is placeholder-only in Phase 2"]
|
|
109
|
+
by_strike = _group_by_strike(self.expiry_ns, self.strike, self.total_variance)
|
|
110
|
+
calendar_ok = True
|
|
111
|
+
for rows in by_strike.values():
|
|
112
|
+
rows_sorted = sorted(rows, key=lambda item: item[0])
|
|
113
|
+
variances = np.array([item[1] for item in rows_sorted], dtype=np.float64)
|
|
114
|
+
if len(variances) > 1 and bool((np.diff(variances) < -1e-12).any()):
|
|
115
|
+
calendar_ok = False
|
|
116
|
+
break
|
|
117
|
+
return SurfaceDiagnostics(
|
|
118
|
+
positive_total_variance=bool((self.total_variance >= 0.0).all()),
|
|
119
|
+
no_future_timestamps=True,
|
|
120
|
+
expiries_after_snapshot=bool((self.expiry_ns > self.timestamp_ns).all()),
|
|
121
|
+
calendar_total_variance_non_decreasing=calendar_ok,
|
|
122
|
+
butterfly_convexity_checked=False,
|
|
123
|
+
notes=tuple(notes),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _interpolate_strike(self, expiry_ns: int, strike: float) -> float:
|
|
127
|
+
mask = self.expiry_ns == int(expiry_ns)
|
|
128
|
+
strikes = self.strike[mask]
|
|
129
|
+
variances = self.total_variance[mask]
|
|
130
|
+
if len(strikes) == 0:
|
|
131
|
+
raise ValueError("expiry not found")
|
|
132
|
+
if len(strikes) == 1:
|
|
133
|
+
return float(variances[0])
|
|
134
|
+
order = np.argsort(strikes)
|
|
135
|
+
return float(np.interp(strike, strikes[order], variances[order]))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _group_by_strike(expiry_ns: Iterable[int], strike: Iterable[float], total_variance: Iterable[float]) -> Dict[float, list[tuple[int, float]]]:
|
|
139
|
+
grouped: Dict[float, list[tuple[int, float]]] = {}
|
|
140
|
+
for expiry, strike_value, variance in zip(expiry_ns, strike, total_variance):
|
|
141
|
+
grouped.setdefault(float(strike_value), []).append((int(expiry), float(variance)))
|
|
142
|
+
return grouped
|
quantbt/options/tape.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prepared ragged option tape.
|
|
3
|
+
|
|
4
|
+
The canonical option chain remains long-form. This module compiles validated
|
|
5
|
+
rows into CSR-style arrays so later selectors and execution code can scan the
|
|
6
|
+
listed contracts at each observable snapshot without building a dense
|
|
7
|
+
bar-by-contract matrix.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from .data import validate_option_chain_frame
|
|
19
|
+
from .schema import InstrumentRegistrySignature, OptionInstrumentRegistry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
YEAR_NS = 365 * 24 * 60 * 60 * 1_000_000_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class OptionTapeSignature:
|
|
27
|
+
row_count: int
|
|
28
|
+
snapshot_count: int
|
|
29
|
+
first_timestamp_ns: int
|
|
30
|
+
last_timestamp_ns: int
|
|
31
|
+
instrument_registry_signature: InstrumentRegistrySignature
|
|
32
|
+
convention_signature: Tuple
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PreparedOptionTape:
|
|
37
|
+
timestamp_ns: np.ndarray
|
|
38
|
+
row_ptr: np.ndarray
|
|
39
|
+
instrument_code: np.ndarray
|
|
40
|
+
instrument_id: Tuple[str, ...]
|
|
41
|
+
expiry_ns: np.ndarray
|
|
42
|
+
strike: np.ndarray
|
|
43
|
+
option_kind_code: np.ndarray
|
|
44
|
+
bid_price: np.ndarray
|
|
45
|
+
bid_size: np.ndarray
|
|
46
|
+
ask_price: np.ndarray
|
|
47
|
+
ask_size: np.ndarray
|
|
48
|
+
mark_price: np.ndarray
|
|
49
|
+
index_price: np.ndarray
|
|
50
|
+
forward_price: np.ndarray
|
|
51
|
+
mark_iv: np.ndarray
|
|
52
|
+
bid_iv: np.ndarray
|
|
53
|
+
ask_iv: np.ndarray
|
|
54
|
+
delta: np.ndarray
|
|
55
|
+
gamma: np.ndarray
|
|
56
|
+
vega: np.ndarray
|
|
57
|
+
theta: np.ndarray
|
|
58
|
+
open_interest: np.ndarray
|
|
59
|
+
volume: np.ndarray
|
|
60
|
+
source_latency_ns: np.ndarray
|
|
61
|
+
registry: OptionInstrumentRegistry
|
|
62
|
+
signature: OptionTapeSignature
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if self.timestamp_ns.ndim != 1 or self.row_ptr.ndim != 1:
|
|
66
|
+
raise ValueError("timestamp_ns and row_ptr must be 1-D")
|
|
67
|
+
if len(self.row_ptr) != len(self.timestamp_ns) + 1:
|
|
68
|
+
raise ValueError("row_ptr length must equal snapshot_count + 1")
|
|
69
|
+
if len(self.instrument_code) != self.signature.row_count:
|
|
70
|
+
raise ValueError("instrument_code length must match row_count")
|
|
71
|
+
if self.row_ptr[0] != 0 or self.row_ptr[-1] != self.signature.row_count:
|
|
72
|
+
raise ValueError("row_ptr bounds do not match row_count")
|
|
73
|
+
if bool((np.diff(self.row_ptr) < 0).any()):
|
|
74
|
+
raise ValueError("row_ptr must be non-decreasing")
|
|
75
|
+
if bool((np.diff(self.timestamp_ns) <= 0).any()):
|
|
76
|
+
raise ValueError("timestamp_ns must be strictly increasing")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def snapshot_count(self) -> int:
|
|
80
|
+
return len(self.timestamp_ns)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def row_count(self) -> int:
|
|
84
|
+
return len(self.instrument_code)
|
|
85
|
+
|
|
86
|
+
def snapshot_index_at_or_before(self, decision_timestamp_ns: int, *, max_quote_age_ns: Optional[int] = None) -> int:
|
|
87
|
+
decision_ts = int(decision_timestamp_ns)
|
|
88
|
+
idx = int(np.searchsorted(self.timestamp_ns, decision_ts, side="right") - 1)
|
|
89
|
+
if idx < 0:
|
|
90
|
+
raise ValueError("no option snapshot is observable at or before decision_timestamp_ns")
|
|
91
|
+
if max_quote_age_ns is not None and decision_ts - int(self.timestamp_ns[idx]) > int(max_quote_age_ns):
|
|
92
|
+
raise ValueError("latest option snapshot is stale for decision_timestamp_ns")
|
|
93
|
+
return idx
|
|
94
|
+
|
|
95
|
+
def snapshot_slice(self, snapshot_index: int) -> slice:
|
|
96
|
+
idx = int(snapshot_index)
|
|
97
|
+
if idx < 0 or idx >= self.snapshot_count:
|
|
98
|
+
raise IndexError("snapshot_index out of range")
|
|
99
|
+
return slice(int(self.row_ptr[idx]), int(self.row_ptr[idx + 1]))
|
|
100
|
+
|
|
101
|
+
def validate_compatible(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
registry_signature: Optional[InstrumentRegistrySignature] = None,
|
|
105
|
+
convention_signature: Optional[Tuple] = None,
|
|
106
|
+
timestamps_ns: Optional[Sequence[int]] = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
if registry_signature is not None and registry_signature != self.signature.instrument_registry_signature:
|
|
109
|
+
raise ValueError("prepared option tape registry signature mismatch")
|
|
110
|
+
if convention_signature is not None and tuple(convention_signature) != self.signature.convention_signature:
|
|
111
|
+
raise ValueError("prepared option tape convention signature mismatch")
|
|
112
|
+
if timestamps_ns is not None:
|
|
113
|
+
expected = np.asarray(timestamps_ns, dtype=np.int64)
|
|
114
|
+
if len(expected) != len(self.timestamp_ns) or bool((expected != self.timestamp_ns).any()):
|
|
115
|
+
raise ValueError("prepared option tape timestamp mismatch")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def prepare_option_tape(
|
|
119
|
+
chain: pd.DataFrame,
|
|
120
|
+
registry: OptionInstrumentRegistry,
|
|
121
|
+
*,
|
|
122
|
+
max_spread_bps: Optional[float] = None,
|
|
123
|
+
max_source_latency_ns: Optional[int] = None,
|
|
124
|
+
convention_signature: Optional[Tuple] = None,
|
|
125
|
+
) -> PreparedOptionTape:
|
|
126
|
+
"""
|
|
127
|
+
Validate long-form chain rows and compile a CSR-style option tape.
|
|
128
|
+
|
|
129
|
+
`max_source_latency_ns` checks the per-row venue/source latency column when
|
|
130
|
+
present. Decision-time quote age is checked later by selectors because it
|
|
131
|
+
depends on the strategy timestamp.
|
|
132
|
+
"""
|
|
133
|
+
canonical = validate_option_chain_frame(chain, max_spread_bps=max_spread_bps)
|
|
134
|
+
registry_symbols = registry.by_symbol
|
|
135
|
+
unknown = sorted(set(canonical["instrument_id"]).difference(registry_symbols))
|
|
136
|
+
if unknown:
|
|
137
|
+
raise ValueError(f"option chain contains instruments not in registry: {unknown}")
|
|
138
|
+
if max_source_latency_ns is not None:
|
|
139
|
+
if max_source_latency_ns < 0:
|
|
140
|
+
raise ValueError("max_source_latency_ns must be >= 0")
|
|
141
|
+
if "source_latency_ns" not in canonical:
|
|
142
|
+
raise ValueError("source_latency_ns is required when max_source_latency_ns is set")
|
|
143
|
+
if bool((canonical["source_latency_ns"].to_numpy(dtype=np.int64) > int(max_source_latency_ns)).any()):
|
|
144
|
+
raise ValueError("option chain contains stale source latency rows")
|
|
145
|
+
_validate_registry_static_fields(canonical, registry)
|
|
146
|
+
timestamps, row_ptr = _build_row_ptr(canonical["timestamp_ns"].to_numpy(dtype=np.int64))
|
|
147
|
+
ids = tuple(canonical["instrument_id"].astype(str).tolist())
|
|
148
|
+
code_map = {symbol: code for code, symbol in enumerate(registry.symbols)}
|
|
149
|
+
instrument_code = np.asarray([code_map[symbol] for symbol in ids], dtype=np.int32)
|
|
150
|
+
kind_code = np.asarray([0 if kind == "call" else 1 for kind in canonical["option_kind"].astype(str)], dtype=np.int8)
|
|
151
|
+
convention_sig = tuple(convention_signature) if convention_signature is not None else registry.signature.signature
|
|
152
|
+
signature = OptionTapeSignature(
|
|
153
|
+
row_count=len(canonical),
|
|
154
|
+
snapshot_count=len(timestamps),
|
|
155
|
+
first_timestamp_ns=int(timestamps[0]),
|
|
156
|
+
last_timestamp_ns=int(timestamps[-1]),
|
|
157
|
+
instrument_registry_signature=registry.signature,
|
|
158
|
+
convention_signature=convention_sig,
|
|
159
|
+
)
|
|
160
|
+
return PreparedOptionTape(
|
|
161
|
+
timestamp_ns=timestamps,
|
|
162
|
+
row_ptr=row_ptr,
|
|
163
|
+
instrument_code=instrument_code,
|
|
164
|
+
instrument_id=ids,
|
|
165
|
+
expiry_ns=canonical["expiry_ns"].to_numpy(dtype=np.int64),
|
|
166
|
+
strike=canonical["strike"].to_numpy(dtype=np.float64),
|
|
167
|
+
option_kind_code=kind_code,
|
|
168
|
+
bid_price=canonical["bid_price"].to_numpy(dtype=np.float64),
|
|
169
|
+
bid_size=canonical["bid_size"].to_numpy(dtype=np.float64),
|
|
170
|
+
ask_price=canonical["ask_price"].to_numpy(dtype=np.float64),
|
|
171
|
+
ask_size=canonical["ask_size"].to_numpy(dtype=np.float64),
|
|
172
|
+
mark_price=canonical["mark_price"].to_numpy(dtype=np.float64),
|
|
173
|
+
index_price=canonical["index_price"].to_numpy(dtype=np.float64),
|
|
174
|
+
forward_price=canonical["forward_price"].to_numpy(dtype=np.float64),
|
|
175
|
+
mark_iv=_float_column(canonical, "mark_iv", default=np.nan),
|
|
176
|
+
bid_iv=_float_column(canonical, "bid_iv", default=np.nan),
|
|
177
|
+
ask_iv=_float_column(canonical, "ask_iv", default=np.nan),
|
|
178
|
+
delta=_float_column(canonical, "delta", default=np.nan),
|
|
179
|
+
gamma=_float_column(canonical, "gamma", default=np.nan),
|
|
180
|
+
vega=_float_column(canonical, "vega", default=np.nan),
|
|
181
|
+
theta=_float_column(canonical, "theta", default=np.nan),
|
|
182
|
+
open_interest=_float_column(canonical, "open_interest", default=0.0),
|
|
183
|
+
volume=_float_column(canonical, "volume", default=0.0),
|
|
184
|
+
source_latency_ns=_int_column(canonical, "source_latency_ns", default=0),
|
|
185
|
+
registry=registry,
|
|
186
|
+
signature=signature,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_row_ptr(timestamp_ns: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
191
|
+
timestamps, counts = np.unique(timestamp_ns, return_counts=True)
|
|
192
|
+
row_ptr = np.empty(len(timestamps) + 1, dtype=np.int64)
|
|
193
|
+
row_ptr[0] = 0
|
|
194
|
+
row_ptr[1:] = np.cumsum(counts, dtype=np.int64)
|
|
195
|
+
return timestamps.astype(np.int64), row_ptr
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _float_column(frame: pd.DataFrame, column: str, *, default: float) -> np.ndarray:
|
|
199
|
+
if column not in frame:
|
|
200
|
+
return np.full(len(frame), default, dtype=np.float64)
|
|
201
|
+
return frame[column].to_numpy(dtype=np.float64)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _int_column(frame: pd.DataFrame, column: str, *, default: int) -> np.ndarray:
|
|
205
|
+
if column not in frame:
|
|
206
|
+
return np.full(len(frame), default, dtype=np.int64)
|
|
207
|
+
return frame[column].to_numpy(dtype=np.int64)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validate_registry_static_fields(chain: pd.DataFrame, registry: OptionInstrumentRegistry) -> None:
|
|
211
|
+
for row in chain.itertuples(index=False):
|
|
212
|
+
spec = registry.by_symbol[getattr(row, "instrument_id")]
|
|
213
|
+
if int(getattr(row, "expiry_ns")) != int(spec.expiry_ns):
|
|
214
|
+
raise ValueError("option chain expiry_ns does not match registry")
|
|
215
|
+
if abs(float(getattr(row, "strike")) - float(spec.strike)) > 1e-12:
|
|
216
|
+
raise ValueError("option chain strike does not match registry")
|
|
217
|
+
if str(getattr(row, "option_kind")).lower() != spec.option_kind.value:
|
|
218
|
+
raise ValueError("option chain option_kind does not match registry")
|
|
219
|
+
if str(getattr(row, "venue")).lower() != spec.venue:
|
|
220
|
+
raise ValueError("option chain venue does not match registry")
|
|
221
|
+
if str(getattr(row, "underlying_id")).strip() != spec.underlying_id:
|
|
222
|
+
raise ValueError("option chain underlying_id does not match registry")
|
|
223
|
+
if str(getattr(row, "quote_currency")).upper() != spec.quote_currency:
|
|
224
|
+
raise ValueError("option chain quote_currency does not match registry")
|
|
225
|
+
if str(getattr(row, "settlement_currency")).upper() != spec.settlement_currency:
|
|
226
|
+
raise ValueError("option chain settlement_currency does not match registry")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Option package builder templates."""
|
|
2
|
+
|
|
3
|
+
from .packages import (
|
|
4
|
+
butterfly,
|
|
5
|
+
calendar,
|
|
6
|
+
collar,
|
|
7
|
+
condor,
|
|
8
|
+
covered_call,
|
|
9
|
+
long_call,
|
|
10
|
+
long_put,
|
|
11
|
+
risk_reversal,
|
|
12
|
+
short_call,
|
|
13
|
+
short_put,
|
|
14
|
+
straddle,
|
|
15
|
+
strangle,
|
|
16
|
+
vertical,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"butterfly",
|
|
21
|
+
"calendar",
|
|
22
|
+
"collar",
|
|
23
|
+
"condor",
|
|
24
|
+
"covered_call",
|
|
25
|
+
"long_call",
|
|
26
|
+
"long_put",
|
|
27
|
+
"risk_reversal",
|
|
28
|
+
"short_call",
|
|
29
|
+
"short_put",
|
|
30
|
+
"straddle",
|
|
31
|
+
"strangle",
|
|
32
|
+
"vertical",
|
|
33
|
+
]
|