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,796 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Native option backend facade.
|
|
3
|
+
|
|
4
|
+
This backend wires the Phase 1-6 option components into the common QuantBT
|
|
5
|
+
result contract. It does not attempt to be a venue-exact options exchange; the
|
|
6
|
+
venue-specific gaps stay explicit in reports and metadata.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
import hashlib
|
|
13
|
+
from typing import Dict, Iterable, Mapping, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from ..core.results import OptionBacktestResult
|
|
19
|
+
from ..core.schema import AccountConfig, ExecutionConfig
|
|
20
|
+
from ..options.cache import OptionPreparedRunCache
|
|
21
|
+
from ..options.execution import OptionExecutionConfig, execute_option_package
|
|
22
|
+
from ..options.fees import OptionFeeResult, OptionFeeSchedule, calculate_option_fee
|
|
23
|
+
from ..options.hedging import OptionHedgeConfig, run_delta_hedge_path
|
|
24
|
+
from ..options.ledger import OptionLedger
|
|
25
|
+
from ..options.lifecycle import OptionSettlementRepresentation, settle_option_expiry
|
|
26
|
+
from ..options.margin import OptionMarginConfig, OptionMarginRequirement, calculate_option_margin
|
|
27
|
+
from ..options.packages import OptionPackageIntent
|
|
28
|
+
from ..options.schema import OptionInstrumentRegistry, OptionInstrumentSpec
|
|
29
|
+
from ..options.tape import PreparedOptionTape, prepare_option_tape
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class NativeOptionConfig:
|
|
34
|
+
account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0))
|
|
35
|
+
execution: ExecutionConfig = field(default_factory=ExecutionConfig)
|
|
36
|
+
option_execution: OptionExecutionConfig = field(default_factory=OptionExecutionConfig)
|
|
37
|
+
margin: OptionMarginConfig = field(default_factory=OptionMarginConfig)
|
|
38
|
+
fee_schedule: Optional[OptionFeeSchedule] = None
|
|
39
|
+
reporting_currency: str = "USD"
|
|
40
|
+
initial_balances: Optional[Dict[str, float]] = None
|
|
41
|
+
conversion_rates: Dict[str, float] = field(default_factory=dict)
|
|
42
|
+
settle_expired: bool = False
|
|
43
|
+
max_spread_bps: Optional[float] = None
|
|
44
|
+
max_source_latency_ns: Optional[int] = None
|
|
45
|
+
random_seed: Optional[int] = 42
|
|
46
|
+
metadata: Dict = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
object.__setattr__(self, "reporting_currency", str(self.reporting_currency).upper())
|
|
50
|
+
if self.account.initial_capital <= 0.0:
|
|
51
|
+
raise ValueError("account.initial_capital must be > 0")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class OptionSettlementEvent:
|
|
56
|
+
symbol: str
|
|
57
|
+
timestamp_ns: int
|
|
58
|
+
settlement_price: float
|
|
59
|
+
representation: Optional[OptionSettlementRepresentation] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NativeOptionBackend:
|
|
63
|
+
"""Array-first native option backend returning `OptionBacktestResult`."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, config: Optional[NativeOptionConfig] = None):
|
|
66
|
+
self.config = config or NativeOptionConfig()
|
|
67
|
+
|
|
68
|
+
def run(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
chain: pd.DataFrame,
|
|
72
|
+
instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec],
|
|
73
|
+
packages: Sequence[OptionPackageIntent] = (),
|
|
74
|
+
prepared_tape: Optional[PreparedOptionTape] = None,
|
|
75
|
+
prepared_cache: Optional[OptionPreparedRunCache] = None,
|
|
76
|
+
underlying: Optional[pd.DataFrame | pd.Series] = None,
|
|
77
|
+
hedge_policy: Optional[OptionHedgeConfig] = None,
|
|
78
|
+
net_option_delta: Optional[pd.Series] = None,
|
|
79
|
+
settlement_events: Optional[Sequence[OptionSettlementEvent | Mapping]] = None,
|
|
80
|
+
conversion_rates: Optional[Dict[str, float]] = None,
|
|
81
|
+
reporting_currency: Optional[str] = None,
|
|
82
|
+
) -> OptionBacktestResult:
|
|
83
|
+
registry = _normalize_registry(instruments)
|
|
84
|
+
if prepared_cache is not None:
|
|
85
|
+
prepared_cache.validate(registry)
|
|
86
|
+
tape = prepared_cache.tape
|
|
87
|
+
else:
|
|
88
|
+
tape = prepared_tape or prepare_option_tape(
|
|
89
|
+
chain,
|
|
90
|
+
registry,
|
|
91
|
+
max_spread_bps=self.config.max_spread_bps,
|
|
92
|
+
max_source_latency_ns=self.config.max_source_latency_ns,
|
|
93
|
+
)
|
|
94
|
+
tape.validate_compatible(registry_signature=registry.signature)
|
|
95
|
+
rates = {**self.config.conversion_rates, **(conversion_rates or {})}
|
|
96
|
+
report_ccy = str(reporting_currency or self.config.reporting_currency).upper()
|
|
97
|
+
if report_ccy not in rates:
|
|
98
|
+
rates[report_ccy] = 1.0
|
|
99
|
+
|
|
100
|
+
ledger = OptionLedger.from_cash(self.config.initial_balances or {report_ccy: self.config.account.initial_capital})
|
|
101
|
+
instrument_map = registry.by_symbol
|
|
102
|
+
packages_sorted = tuple(sorted(packages or (), key=lambda package: int(package.timestamp_ns)))
|
|
103
|
+
order_reports = []
|
|
104
|
+
package_reports = []
|
|
105
|
+
applied_fills = []
|
|
106
|
+
snapshots = []
|
|
107
|
+
|
|
108
|
+
snapshots.append(_snapshot_state(tape, 0, ledger, instrument_map, rates, report_ccy, "initial"))
|
|
109
|
+
for package in packages_sorted:
|
|
110
|
+
pkg_result = execute_option_package(
|
|
111
|
+
package,
|
|
112
|
+
tape,
|
|
113
|
+
config=self.config.option_execution,
|
|
114
|
+
positions={symbol: position.qty for symbol, position in ledger.positions.items()},
|
|
115
|
+
compiled_orders=prepared_cache.compile_package(package) if prepared_cache is not None else None,
|
|
116
|
+
)
|
|
117
|
+
order_reports.append(pkg_result.order_report)
|
|
118
|
+
package_reports.append(pkg_result.package_report)
|
|
119
|
+
for fill in pkg_result.fills:
|
|
120
|
+
instrument = instrument_map[fill.symbol]
|
|
121
|
+
fee = _option_fee(fill, instrument, tape, self.config.fee_schedule)
|
|
122
|
+
ledger.apply_fill(fill, instrument, fee=fee, timestamp_ns=int(fill.timestamp))
|
|
123
|
+
applied_fills.append((fill, fee))
|
|
124
|
+
snap_idx = tape.snapshot_index_at_or_before(int(package.timestamp_ns))
|
|
125
|
+
snapshots.append(_snapshot_state(tape, snap_idx, ledger, instrument_map, rates, report_ccy, package.package_id))
|
|
126
|
+
|
|
127
|
+
settlements = []
|
|
128
|
+
for event in _normalize_settlement_events(settlement_events):
|
|
129
|
+
instrument = instrument_map[event.symbol]
|
|
130
|
+
settlement = settle_option_expiry(
|
|
131
|
+
ledger,
|
|
132
|
+
instrument,
|
|
133
|
+
timestamp_ns=int(event.timestamp_ns),
|
|
134
|
+
settlement_price=float(event.settlement_price),
|
|
135
|
+
representation=event.representation,
|
|
136
|
+
)
|
|
137
|
+
settlements.append(settlement)
|
|
138
|
+
snap_idx = min(tape.snapshot_count - 1, max(0, np.searchsorted(tape.timestamp_ns, int(event.timestamp_ns), side="right") - 1))
|
|
139
|
+
snapshots.append(_snapshot_state(tape, int(snap_idx), ledger, instrument_map, rates, report_ccy, f"settlement:{event.symbol}"))
|
|
140
|
+
|
|
141
|
+
if self.config.settle_expired:
|
|
142
|
+
last_ts = int(tape.timestamp_ns[-1])
|
|
143
|
+
marks = _snapshot_marks(tape, tape.snapshot_count - 1)
|
|
144
|
+
underlyings = _snapshot_underlyings(tape, tape.snapshot_count - 1)
|
|
145
|
+
for symbol, position in list(ledger.positions.items()):
|
|
146
|
+
instrument = instrument_map[symbol]
|
|
147
|
+
if position.is_flat or int(instrument.expiry_ns) > last_ts:
|
|
148
|
+
continue
|
|
149
|
+
settlement = settle_option_expiry(
|
|
150
|
+
ledger,
|
|
151
|
+
instrument,
|
|
152
|
+
timestamp_ns=last_ts,
|
|
153
|
+
settlement_price=underlyings.get(instrument.underlying_id, marks.get(symbol, 0.0)),
|
|
154
|
+
)
|
|
155
|
+
settlements.append(settlement)
|
|
156
|
+
snapshots.append(_snapshot_state(tape, tape.snapshot_count - 1, ledger, instrument_map, rates, report_ccy, "auto_settlement"))
|
|
157
|
+
|
|
158
|
+
final_snapshot_idx = tape.snapshot_count - 1
|
|
159
|
+
final_marks = _snapshot_marks(tape, final_snapshot_idx)
|
|
160
|
+
final_underlyings = _snapshot_underlyings(tape, final_snapshot_idx)
|
|
161
|
+
margin = calculate_option_margin(
|
|
162
|
+
ledger,
|
|
163
|
+
instrument_map,
|
|
164
|
+
final_marks,
|
|
165
|
+
final_underlyings,
|
|
166
|
+
config=self.config.margin,
|
|
167
|
+
reporting_currency=report_ccy,
|
|
168
|
+
conversion_rates=rates,
|
|
169
|
+
)
|
|
170
|
+
snapshots.append(_snapshot_state(tape, final_snapshot_idx, ledger, instrument_map, rates, report_ccy, "final"))
|
|
171
|
+
|
|
172
|
+
result = _build_result(
|
|
173
|
+
tape=tape,
|
|
174
|
+
registry=registry,
|
|
175
|
+
ledger=ledger,
|
|
176
|
+
account=self.config.account,
|
|
177
|
+
report_ccy=report_ccy,
|
|
178
|
+
conversion_rates=rates,
|
|
179
|
+
snapshots=snapshots,
|
|
180
|
+
fills_with_fees=applied_fills,
|
|
181
|
+
order_report=_concat(order_reports),
|
|
182
|
+
package_report=_concat(package_reports),
|
|
183
|
+
settlements=settlements,
|
|
184
|
+
margin=margin,
|
|
185
|
+
metadata={
|
|
186
|
+
"backend": "native_option",
|
|
187
|
+
"engine": "native_option",
|
|
188
|
+
"phase": "phase7_backend_endpoint_result",
|
|
189
|
+
"package_count": len(packages_sorted),
|
|
190
|
+
"fill_count": len(applied_fills),
|
|
191
|
+
"settlement_count": len(settlements),
|
|
192
|
+
"venue_exact_margin": bool(margin.venue_exact),
|
|
193
|
+
"reporting_currency": report_ccy,
|
|
194
|
+
"prepared_cache_used": prepared_cache is not None,
|
|
195
|
+
"package_cache_size": 0 if prepared_cache is None else prepared_cache.package_cache_size,
|
|
196
|
+
"fee_schedule_id": "execution_fee_rate"
|
|
197
|
+
if self.config.fee_schedule is None
|
|
198
|
+
else self.config.fee_schedule.schedule_id,
|
|
199
|
+
"limit_fidelity": self.config.option_execution.limit_fidelity.value,
|
|
200
|
+
"depth_fidelity": self.config.option_execution.depth_fidelity.value,
|
|
201
|
+
"random_seed": self.config.random_seed,
|
|
202
|
+
**self.config.metadata,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
if hedge_policy is not None:
|
|
206
|
+
result = _attach_delta_hedge_contract(
|
|
207
|
+
result,
|
|
208
|
+
tape=tape,
|
|
209
|
+
registry=registry,
|
|
210
|
+
underlying=underlying,
|
|
211
|
+
hedge_policy=hedge_policy,
|
|
212
|
+
net_option_delta=net_option_delta,
|
|
213
|
+
account=self.config.account,
|
|
214
|
+
report_ccy=report_ccy,
|
|
215
|
+
)
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _normalize_registry(
|
|
220
|
+
instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec],
|
|
221
|
+
) -> OptionInstrumentRegistry:
|
|
222
|
+
if isinstance(instruments, OptionInstrumentRegistry):
|
|
223
|
+
return instruments
|
|
224
|
+
if isinstance(instruments, Mapping):
|
|
225
|
+
return OptionInstrumentRegistry.from_iterable(instruments.values())
|
|
226
|
+
return OptionInstrumentRegistry.from_iterable(tuple(instruments))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _normalize_settlement_events(events: Optional[Sequence[OptionSettlementEvent | Mapping]]) -> tuple[OptionSettlementEvent, ...]:
|
|
230
|
+
if not events:
|
|
231
|
+
return ()
|
|
232
|
+
out = []
|
|
233
|
+
for event in events:
|
|
234
|
+
if isinstance(event, OptionSettlementEvent):
|
|
235
|
+
out.append(event)
|
|
236
|
+
else:
|
|
237
|
+
out.append(
|
|
238
|
+
OptionSettlementEvent(
|
|
239
|
+
symbol=str(event["symbol"]),
|
|
240
|
+
timestamp_ns=int(event["timestamp_ns"]),
|
|
241
|
+
settlement_price=float(event["settlement_price"]),
|
|
242
|
+
representation=event.get("representation"),
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return tuple(out)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _option_fee(fill, instrument: OptionInstrumentSpec, tape: PreparedOptionTape, schedule: Optional[OptionFeeSchedule]) -> Optional[OptionFeeResult]:
|
|
249
|
+
schedule = schedule or fill.metadata.get("option_fee_schedule")
|
|
250
|
+
if schedule is None:
|
|
251
|
+
return None
|
|
252
|
+
if not isinstance(schedule, OptionFeeSchedule):
|
|
253
|
+
return None
|
|
254
|
+
row_index = int(fill.metadata.get("option_row_index", -1))
|
|
255
|
+
if row_index < 0:
|
|
256
|
+
return None
|
|
257
|
+
reference = float(tape.index_price[row_index] if np.isfinite(tape.index_price[row_index]) else tape.forward_price[row_index])
|
|
258
|
+
return calculate_option_fee(fill, instrument, schedule, reference_price=reference)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _snapshot_state(
|
|
262
|
+
tape: PreparedOptionTape,
|
|
263
|
+
snapshot_idx: int,
|
|
264
|
+
ledger: OptionLedger,
|
|
265
|
+
instruments: Dict[str, OptionInstrumentSpec],
|
|
266
|
+
conversion_rates: Dict[str, float],
|
|
267
|
+
report_ccy: str,
|
|
268
|
+
label: str,
|
|
269
|
+
) -> Dict:
|
|
270
|
+
marks = _snapshot_marks(tape, snapshot_idx)
|
|
271
|
+
equity = ledger.equity(conversion_rates=conversion_rates, marks=marks, instruments=instruments, reporting_currency=report_ccy)
|
|
272
|
+
return {
|
|
273
|
+
"timestamp_ns": int(tape.timestamp_ns[snapshot_idx]),
|
|
274
|
+
"label": label,
|
|
275
|
+
"equity": float(equity),
|
|
276
|
+
"cash": dict(ledger.cash),
|
|
277
|
+
"positions": {symbol: position.qty for symbol, position in ledger.positions.items()},
|
|
278
|
+
"marks": marks,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _snapshot_marks(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]:
|
|
283
|
+
rows = tape.snapshot_slice(snapshot_idx)
|
|
284
|
+
return {tape.instrument_id[idx]: float(tape.mark_price[idx]) for idx in range(rows.start, rows.stop)}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _snapshot_underlyings(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]:
|
|
288
|
+
rows = tape.snapshot_slice(snapshot_idx)
|
|
289
|
+
out = {}
|
|
290
|
+
registry = tape.registry.by_symbol
|
|
291
|
+
for idx in range(rows.start, rows.stop):
|
|
292
|
+
symbol = tape.instrument_id[idx]
|
|
293
|
+
instrument = registry[symbol]
|
|
294
|
+
price = float(tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx])
|
|
295
|
+
out[instrument.underlying_id] = price
|
|
296
|
+
out[symbol] = price
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _build_result(
|
|
301
|
+
*,
|
|
302
|
+
tape: PreparedOptionTape,
|
|
303
|
+
registry: OptionInstrumentRegistry,
|
|
304
|
+
ledger: OptionLedger,
|
|
305
|
+
account: AccountConfig,
|
|
306
|
+
report_ccy: str,
|
|
307
|
+
conversion_rates: Dict[str, float],
|
|
308
|
+
snapshots: Sequence[Dict],
|
|
309
|
+
fills_with_fees: Sequence[tuple],
|
|
310
|
+
order_report: pd.DataFrame,
|
|
311
|
+
package_report: pd.DataFrame,
|
|
312
|
+
settlements: Sequence,
|
|
313
|
+
margin: OptionMarginRequirement,
|
|
314
|
+
metadata: Dict,
|
|
315
|
+
) -> OptionBacktestResult:
|
|
316
|
+
index = pd.DatetimeIndex(pd.to_datetime([snap["timestamp_ns"] for snap in snapshots], utc=True)).tz_convert(None)
|
|
317
|
+
equity = pd.Series([snap["equity"] for snap in snapshots], index=index, name="equity")
|
|
318
|
+
if len(equity.index) != len(set(equity.index)):
|
|
319
|
+
offsets = pd.to_timedelta(np.arange(len(equity)), unit="ns")
|
|
320
|
+
equity.index = pd.DatetimeIndex(equity.index + offsets)
|
|
321
|
+
returns = equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
|
322
|
+
|
|
323
|
+
symbols = list(registry.symbols)
|
|
324
|
+
positions = pd.DataFrame(
|
|
325
|
+
[{f"Position_{symbol}": snap["positions"].get(symbol, 0.0) for symbol in symbols} for snap in snapshots],
|
|
326
|
+
index=equity.index,
|
|
327
|
+
columns=[f"Position_{symbol}" for symbol in symbols],
|
|
328
|
+
)
|
|
329
|
+
closes = pd.DataFrame(
|
|
330
|
+
[{f"Close_{symbol}": snap["marks"].get(symbol, np.nan) for symbol in symbols} for snap in snapshots],
|
|
331
|
+
index=equity.index,
|
|
332
|
+
columns=[f"Close_{symbol}" for symbol in symbols],
|
|
333
|
+
).ffill()
|
|
334
|
+
cash_report = _cash_report(snapshots, equity.index)
|
|
335
|
+
marks_report = _marks_report(tape)
|
|
336
|
+
greeks_report = _greeks_report(tape)
|
|
337
|
+
fills_report = _fills_report(fills_with_fees)
|
|
338
|
+
settlements_report = _settlements_report(settlements)
|
|
339
|
+
attribution_report = _attribution_report(ledger, account, equity.iloc[-1], report_ccy, conversion_rates)
|
|
340
|
+
run_manifest = {
|
|
341
|
+
"backend": "native_option",
|
|
342
|
+
"result_contract": "OptionBacktestResult",
|
|
343
|
+
"symbols": symbols,
|
|
344
|
+
"snapshot_count": int(tape.snapshot_count),
|
|
345
|
+
"row_count": int(tape.row_count),
|
|
346
|
+
"initial_capital": float(account.initial_capital),
|
|
347
|
+
"final_equity": float(equity.iloc[-1]),
|
|
348
|
+
"reporting_currency": report_ccy,
|
|
349
|
+
"data_hash": _chain_data_hash(marks_report),
|
|
350
|
+
"registry_signature_hash": _stable_hash(repr(registry.signature.signature)),
|
|
351
|
+
"convention_versions": sorted(
|
|
352
|
+
{instrument.convention_version for instrument in registry.instruments if instrument.convention_version}
|
|
353
|
+
),
|
|
354
|
+
"fee_schedule": metadata.get("fee_schedule_id", "execution_fee_rate"),
|
|
355
|
+
"margin_model": str(getattr(margin.model, "value", margin.model)),
|
|
356
|
+
"pricing_model": "observed_chain_bid_ask_mark",
|
|
357
|
+
"deterministic_replay": True,
|
|
358
|
+
"random_seed": metadata.get("random_seed"),
|
|
359
|
+
"fidelity_manifest": {
|
|
360
|
+
"tape": "prepared_csr_option_chain",
|
|
361
|
+
"execution": "top_of_book_bbo",
|
|
362
|
+
"limit_fidelity": metadata.get("limit_fidelity"),
|
|
363
|
+
"depth_fidelity": metadata.get("depth_fidelity"),
|
|
364
|
+
"margin": str(getattr(margin.model, "value", margin.model)),
|
|
365
|
+
"venue_exact_margin": bool(margin.venue_exact),
|
|
366
|
+
"prepared_cache_used": bool(metadata.get("prepared_cache_used", False)),
|
|
367
|
+
},
|
|
368
|
+
"option_reports": [
|
|
369
|
+
"fills_report",
|
|
370
|
+
"packages_report",
|
|
371
|
+
"cash_report",
|
|
372
|
+
"marks_report",
|
|
373
|
+
"greeks_report",
|
|
374
|
+
"settlements_report",
|
|
375
|
+
"margin_report",
|
|
376
|
+
"attribution_report",
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
result_metadata = {
|
|
380
|
+
**metadata,
|
|
381
|
+
"order_report": order_report,
|
|
382
|
+
"fills_report": fills_report,
|
|
383
|
+
"packages_report": package_report,
|
|
384
|
+
"cash_report": cash_report,
|
|
385
|
+
"marks_report": marks_report,
|
|
386
|
+
"greeks_report": greeks_report,
|
|
387
|
+
"settlements_report": settlements_report,
|
|
388
|
+
"margin_report": margin.detail_report,
|
|
389
|
+
"attribution_report": attribution_report,
|
|
390
|
+
"run_manifest": run_manifest,
|
|
391
|
+
"ledger_event_report": ledger.event_report(),
|
|
392
|
+
"equity_identity": ledger.equity_identity_report(
|
|
393
|
+
conversion_rates=conversion_rates,
|
|
394
|
+
marks=_snapshot_marks(tape, tape.snapshot_count - 1),
|
|
395
|
+
instruments=registry.by_symbol,
|
|
396
|
+
reporting_currency=report_ccy,
|
|
397
|
+
),
|
|
398
|
+
}
|
|
399
|
+
fees = pd.Series(0.0, index=equity.index, name="fees")
|
|
400
|
+
if len(fees) > 0:
|
|
401
|
+
fees.iloc[-1] = float(sum((fee.fee if fee is not None else fill.fee) for fill, fee in fills_with_fees))
|
|
402
|
+
return OptionBacktestResult(
|
|
403
|
+
equity=equity,
|
|
404
|
+
returns=returns,
|
|
405
|
+
positions=positions,
|
|
406
|
+
closes=closes,
|
|
407
|
+
symbols=symbols,
|
|
408
|
+
initial_capital=float(account.initial_capital),
|
|
409
|
+
leverage=float(account.leverage),
|
|
410
|
+
liquidated=False,
|
|
411
|
+
fills=tuple(fill for fill, _ in fills_with_fees),
|
|
412
|
+
fees=fees,
|
|
413
|
+
margin=margin.detail_report,
|
|
414
|
+
diagnostics=package_report,
|
|
415
|
+
metadata=result_metadata,
|
|
416
|
+
fills_report=fills_report,
|
|
417
|
+
packages_report=package_report,
|
|
418
|
+
cash_report=cash_report,
|
|
419
|
+
marks_report=marks_report,
|
|
420
|
+
greeks_report=greeks_report,
|
|
421
|
+
settlements_report=settlements_report,
|
|
422
|
+
margin_report=margin.detail_report,
|
|
423
|
+
attribution_report=attribution_report,
|
|
424
|
+
run_manifest=run_manifest,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _concat(frames: Iterable[pd.DataFrame]) -> pd.DataFrame:
|
|
429
|
+
items = [frame for frame in frames if frame is not None and not frame.empty]
|
|
430
|
+
return pd.concat(items, ignore_index=True) if items else pd.DataFrame()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _chain_data_hash(frame: pd.DataFrame) -> str:
|
|
434
|
+
if frame.empty:
|
|
435
|
+
return "0"
|
|
436
|
+
hashed = pd.util.hash_pandas_object(frame.sort_index(axis=1), index=False).to_numpy(dtype="uint64")
|
|
437
|
+
return str(int(hashed.sum(dtype="uint64")))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _stable_hash(value: str) -> str:
|
|
441
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _attach_delta_hedge_contract(
|
|
445
|
+
result: OptionBacktestResult,
|
|
446
|
+
*,
|
|
447
|
+
tape: PreparedOptionTape,
|
|
448
|
+
registry: OptionInstrumentRegistry,
|
|
449
|
+
underlying: Optional[pd.DataFrame | pd.Series],
|
|
450
|
+
hedge_policy: OptionHedgeConfig,
|
|
451
|
+
net_option_delta: Optional[pd.Series],
|
|
452
|
+
account: AccountConfig,
|
|
453
|
+
report_ccy: str,
|
|
454
|
+
) -> OptionBacktestResult:
|
|
455
|
+
path_timestamps = np.concatenate((np.array([int(tape.timestamp_ns[0]) - 1], dtype=np.int64), tape.timestamp_ns.astype(np.int64)))
|
|
456
|
+
index = _datetime_index_from_ns(path_timestamps)
|
|
457
|
+
option_equity, positions, closes, fees = _linear_quote_option_path(
|
|
458
|
+
result,
|
|
459
|
+
tape,
|
|
460
|
+
registry,
|
|
461
|
+
account,
|
|
462
|
+
report_ccy,
|
|
463
|
+
index,
|
|
464
|
+
path_timestamps,
|
|
465
|
+
)
|
|
466
|
+
deltas = _normalize_net_delta(net_option_delta, result.greeks_report, positions, registry, index)
|
|
467
|
+
prices, underlying_source = _normalize_underlying_prices(underlying, tape, index)
|
|
468
|
+
|
|
469
|
+
hedge = run_delta_hedge_path(
|
|
470
|
+
timestamps_ns=list(path_timestamps),
|
|
471
|
+
underlying_prices=prices.to_numpy(dtype=np.float64),
|
|
472
|
+
net_option_deltas=deltas.to_numpy(dtype=np.float64),
|
|
473
|
+
config=hedge_policy,
|
|
474
|
+
)
|
|
475
|
+
hedge_report = hedge.hedge_report.copy()
|
|
476
|
+
hedge_report.index = index
|
|
477
|
+
cumulative_hedge = pd.Series(
|
|
478
|
+
hedge_report["cumulative_hedge_pnl"].to_numpy(dtype=np.float64),
|
|
479
|
+
index=index,
|
|
480
|
+
name="hedge_pnl",
|
|
481
|
+
)
|
|
482
|
+
combined = (option_equity + cumulative_hedge).rename("equity")
|
|
483
|
+
combined_returns = combined.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
|
484
|
+
|
|
485
|
+
result.option_equity = option_equity
|
|
486
|
+
result.hedge_report = hedge_report
|
|
487
|
+
result.combined_equity = combined
|
|
488
|
+
result.combined_returns = combined_returns
|
|
489
|
+
result.equity = combined
|
|
490
|
+
result.returns = combined_returns
|
|
491
|
+
result.positions = positions
|
|
492
|
+
result.closes = closes
|
|
493
|
+
result.fees = fees
|
|
494
|
+
result.metadata["option_equity"] = option_equity
|
|
495
|
+
result.metadata["hedge_report"] = hedge_report
|
|
496
|
+
result.metadata["combined_equity"] = combined
|
|
497
|
+
result.metadata["combined_returns"] = combined_returns
|
|
498
|
+
result.metadata["delta_hedge_contract"] = {
|
|
499
|
+
"enabled": True,
|
|
500
|
+
"underlying_source": underlying_source,
|
|
501
|
+
"policy": hedge_policy.policy.value,
|
|
502
|
+
"target_delta": float(hedge_policy.target_delta),
|
|
503
|
+
"final_hedge_qty": float(hedge.final_hedge_qty),
|
|
504
|
+
"hedge_pnl": float(hedge.hedge_pnl),
|
|
505
|
+
"hedge_rebalances": int(hedge_report["should_rebalance"].sum()) if not hedge_report.empty else 0,
|
|
506
|
+
"option_path_method": result.metadata.get("option_path_method", "linear_quote_replay"),
|
|
507
|
+
}
|
|
508
|
+
result.run_manifest["delta_hedge"] = result.metadata["delta_hedge_contract"]
|
|
509
|
+
result.run_manifest["final_equity"] = float(combined.iloc[-1])
|
|
510
|
+
result.metadata["run_manifest"] = result.run_manifest
|
|
511
|
+
return result
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _linear_quote_option_path(
|
|
515
|
+
result: OptionBacktestResult,
|
|
516
|
+
tape: PreparedOptionTape,
|
|
517
|
+
registry: OptionInstrumentRegistry,
|
|
518
|
+
account: AccountConfig,
|
|
519
|
+
report_ccy: str,
|
|
520
|
+
index: pd.DatetimeIndex,
|
|
521
|
+
path_timestamps: np.ndarray,
|
|
522
|
+
) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame, pd.Series]:
|
|
523
|
+
symbols = list(registry.symbols)
|
|
524
|
+
linear_quote_exact = all(
|
|
525
|
+
instrument.premium_currency.upper() == report_ccy and instrument.settlement_currency.upper() == report_ccy
|
|
526
|
+
for instrument in registry.instruments
|
|
527
|
+
)
|
|
528
|
+
if not linear_quote_exact:
|
|
529
|
+
option_equity = result.equity.reindex(index).ffill().bfill().rename("option_equity")
|
|
530
|
+
positions = result.positions.reindex(index).ffill().fillna(0.0)
|
|
531
|
+
closes = result.closes.reindex(index).ffill().bfill()
|
|
532
|
+
fees = result.fees.reindex(index).fillna(0.0)
|
|
533
|
+
result.metadata["option_path_method"] = "event_equity_reindexed_non_quote_currency"
|
|
534
|
+
return option_equity, positions, closes, fees
|
|
535
|
+
|
|
536
|
+
cash = float(account.initial_capital)
|
|
537
|
+
pos = {symbol: 0.0 for symbol in symbols}
|
|
538
|
+
fills = result.fills_report.sort_values("timestamp") if not result.fills_report.empty else pd.DataFrame()
|
|
539
|
+
fill_idx = 0
|
|
540
|
+
equity_rows = []
|
|
541
|
+
position_rows = []
|
|
542
|
+
close_rows = []
|
|
543
|
+
fee_values = []
|
|
544
|
+
mark_by_ts_symbol = _mark_lookup(tape)
|
|
545
|
+
|
|
546
|
+
for ts, dt in zip(path_timestamps, index):
|
|
547
|
+
snap_idx = max(0, int(np.searchsorted(tape.timestamp_ns, int(ts), side="right") - 1))
|
|
548
|
+
fee_at_ts = 0.0
|
|
549
|
+
while not fills.empty and fill_idx < len(fills) and int(fills.iloc[fill_idx]["timestamp"]) <= int(ts):
|
|
550
|
+
row = fills.iloc[fill_idx]
|
|
551
|
+
qty = float(row["qty"])
|
|
552
|
+
price = float(row["price"])
|
|
553
|
+
fee = float(row.get("applied_fee", row.get("execution_fee", 0.0)))
|
|
554
|
+
symbol = str(row["symbol"])
|
|
555
|
+
side = str(row["side"]).lower()
|
|
556
|
+
if side == "buy":
|
|
557
|
+
cash -= qty * price + fee
|
|
558
|
+
pos[symbol] = pos.get(symbol, 0.0) + qty
|
|
559
|
+
else:
|
|
560
|
+
cash += qty * price - fee
|
|
561
|
+
pos[symbol] = pos.get(symbol, 0.0) - qty
|
|
562
|
+
fee_at_ts += fee
|
|
563
|
+
fill_idx += 1
|
|
564
|
+
mark_ts = int(tape.timestamp_ns[snap_idx])
|
|
565
|
+
marks = {symbol: mark_by_ts_symbol.get((mark_ts, symbol), np.nan) for symbol in symbols}
|
|
566
|
+
marked_value = sum(pos.get(symbol, 0.0) * marks[symbol] for symbol in symbols if np.isfinite(marks[symbol]))
|
|
567
|
+
equity_rows.append(cash + marked_value)
|
|
568
|
+
position_rows.append({f"Position_{symbol}": pos.get(symbol, 0.0) for symbol in symbols})
|
|
569
|
+
close_rows.append({f"Close_{symbol}": marks[symbol] for symbol in symbols})
|
|
570
|
+
fee_values.append(fee_at_ts)
|
|
571
|
+
|
|
572
|
+
option_equity = pd.Series(equity_rows, index=index, name="option_equity")
|
|
573
|
+
positions = pd.DataFrame(position_rows, index=index).fillna(0.0)
|
|
574
|
+
closes = pd.DataFrame(close_rows, index=index).ffill().bfill()
|
|
575
|
+
fees = pd.Series(fee_values, index=index, name="fees")
|
|
576
|
+
result.metadata["option_path_method"] = "linear_quote_replay"
|
|
577
|
+
return option_equity, positions, closes, fees
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _normalize_net_delta(
|
|
581
|
+
net_option_delta: Optional[pd.Series],
|
|
582
|
+
greeks_report: pd.DataFrame,
|
|
583
|
+
positions: pd.DataFrame,
|
|
584
|
+
registry: OptionInstrumentRegistry,
|
|
585
|
+
index: pd.DatetimeIndex,
|
|
586
|
+
) -> pd.Series:
|
|
587
|
+
if net_option_delta is not None:
|
|
588
|
+
series = _coerce_series_index(net_option_delta, "net_option_delta")
|
|
589
|
+
return series.reindex(index).ffill().bfill().fillna(0.0).rename("net_option_delta")
|
|
590
|
+
if greeks_report.empty:
|
|
591
|
+
return pd.Series(0.0, index=index, name="net_option_delta")
|
|
592
|
+
greeks = greeks_report.copy()
|
|
593
|
+
greeks["datetime"] = pd.to_datetime(greeks["timestamp_ns"], utc=True).dt.tz_convert(None)
|
|
594
|
+
delta = greeks.pivot_table(index="datetime", columns="instrument_id", values="delta", aggfunc="last").reindex(index).ffill()
|
|
595
|
+
total = pd.Series(0.0, index=index, name="net_option_delta")
|
|
596
|
+
instruments = registry.by_symbol
|
|
597
|
+
for symbol in registry.symbols:
|
|
598
|
+
pos_col = f"Position_{symbol}"
|
|
599
|
+
if pos_col not in positions or symbol not in delta:
|
|
600
|
+
continue
|
|
601
|
+
multiplier = float(instruments[symbol].multiplier)
|
|
602
|
+
contribution = pd.Series(
|
|
603
|
+
positions[pos_col].to_numpy(dtype=np.float64) * delta[symbol].fillna(0.0).to_numpy(dtype=np.float64) * multiplier,
|
|
604
|
+
index=index,
|
|
605
|
+
)
|
|
606
|
+
total = total.add(contribution, fill_value=0.0)
|
|
607
|
+
return total.fillna(0.0).rename("net_option_delta")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _normalize_underlying_prices(
|
|
611
|
+
underlying: Optional[pd.DataFrame | pd.Series],
|
|
612
|
+
tape: PreparedOptionTape,
|
|
613
|
+
index: pd.DatetimeIndex,
|
|
614
|
+
) -> tuple[pd.Series, str]:
|
|
615
|
+
if underlying is None:
|
|
616
|
+
tape_index = _datetime_index_from_ns(tape.timestamp_ns.astype(np.int64))
|
|
617
|
+
base = pd.Series(
|
|
618
|
+
[_snapshot_underlying_price(tape, i) for i in range(tape.snapshot_count)],
|
|
619
|
+
index=tape_index,
|
|
620
|
+
name="underlying_price",
|
|
621
|
+
)
|
|
622
|
+
return _align_price_series(base, index), "option_chain_index_price"
|
|
623
|
+
if isinstance(underlying, pd.Series):
|
|
624
|
+
series = _coerce_series_index(underlying, "underlying_price")
|
|
625
|
+
return _align_price_series(series, index), "underlying_series"
|
|
626
|
+
if not isinstance(underlying, pd.DataFrame):
|
|
627
|
+
raise TypeError("underlying must be a pandas Series or DataFrame")
|
|
628
|
+
frame = underlying.copy()
|
|
629
|
+
if "timestamp_ns" in frame.columns:
|
|
630
|
+
idx = pd.to_datetime(frame["timestamp_ns"].astype("int64"), utc=True).dt.tz_convert(None)
|
|
631
|
+
elif "time" in frame.columns:
|
|
632
|
+
idx = pd.to_datetime(frame["time"], utc=True, errors="coerce").dt.tz_convert(None)
|
|
633
|
+
elif isinstance(frame.index, pd.DatetimeIndex):
|
|
634
|
+
idx = pd.DatetimeIndex(pd.to_datetime(frame.index, utc=True)).tz_convert(None)
|
|
635
|
+
else:
|
|
636
|
+
raise ValueError("underlying DataFrame requires timestamp_ns, time, or DatetimeIndex")
|
|
637
|
+
column = "close" if "close" in frame.columns else ("price" if "price" in frame.columns else None)
|
|
638
|
+
if column is None:
|
|
639
|
+
raise ValueError("underlying DataFrame requires close or price column")
|
|
640
|
+
series = pd.Series(pd.to_numeric(frame[column], errors="raise").to_numpy(dtype=np.float64), index=idx, name="underlying_price")
|
|
641
|
+
return _align_price_series(series, index), f"underlying_dataframe:{column}"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _align_price_series(series: pd.Series, index: pd.DatetimeIndex) -> pd.Series:
|
|
645
|
+
out = series.sort_index()
|
|
646
|
+
out = out[~out.index.duplicated(keep="last")]
|
|
647
|
+
out = out.reindex(index).ffill().bfill()
|
|
648
|
+
if out.isna().any() or bool((out <= 0.0).any()):
|
|
649
|
+
raise ValueError("underlying prices must align to option tape and be finite > 0")
|
|
650
|
+
return out.rename("underlying_price")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _coerce_series_index(series: pd.Series, name: str) -> pd.Series:
|
|
654
|
+
out = series.copy()
|
|
655
|
+
if not isinstance(out.index, pd.DatetimeIndex):
|
|
656
|
+
out.index = pd.to_datetime(out.index, utc=True)
|
|
657
|
+
else:
|
|
658
|
+
out.index = pd.DatetimeIndex(pd.to_datetime(out.index, utc=True))
|
|
659
|
+
out.index = out.index.tz_convert(None)
|
|
660
|
+
out = pd.to_numeric(out, errors="raise").astype("float64")
|
|
661
|
+
out.name = name
|
|
662
|
+
return out
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _datetime_index_from_ns(timestamps_ns: np.ndarray) -> pd.DatetimeIndex:
|
|
666
|
+
return pd.DatetimeIndex(pd.to_datetime(timestamps_ns, utc=True)).tz_convert(None)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _mark_lookup(tape: PreparedOptionTape) -> Dict[tuple[int, str], float]:
|
|
670
|
+
out: Dict[tuple[int, str], float] = {}
|
|
671
|
+
for snap_idx, ts in enumerate(tape.timestamp_ns):
|
|
672
|
+
slc = tape.snapshot_slice(snap_idx)
|
|
673
|
+
for idx in range(slc.start, slc.stop):
|
|
674
|
+
out[(int(ts), tape.instrument_id[idx])] = float(tape.mark_price[idx])
|
|
675
|
+
return out
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _snapshot_underlying_price(tape: PreparedOptionTape, snapshot_idx: int) -> float:
|
|
679
|
+
rows = tape.snapshot_slice(snapshot_idx)
|
|
680
|
+
for idx in range(rows.start, rows.stop):
|
|
681
|
+
price = tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx]
|
|
682
|
+
if np.isfinite(price) and price > 0.0:
|
|
683
|
+
return float(price)
|
|
684
|
+
raise ValueError("option tape snapshot has no finite underlying/index price")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _cash_report(snapshots: Sequence[Dict], index: pd.DatetimeIndex) -> pd.DataFrame:
|
|
688
|
+
currencies = sorted({currency for snap in snapshots for currency in snap["cash"]})
|
|
689
|
+
return pd.DataFrame(
|
|
690
|
+
[{currency: snap["cash"].get(currency, 0.0) for currency in currencies} for snap in snapshots],
|
|
691
|
+
index=index,
|
|
692
|
+
columns=currencies,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _marks_report(tape: PreparedOptionTape) -> pd.DataFrame:
|
|
697
|
+
rows = []
|
|
698
|
+
for snap_idx, ts in enumerate(tape.timestamp_ns):
|
|
699
|
+
slc = tape.snapshot_slice(snap_idx)
|
|
700
|
+
for idx in range(slc.start, slc.stop):
|
|
701
|
+
rows.append(
|
|
702
|
+
{
|
|
703
|
+
"timestamp_ns": int(ts),
|
|
704
|
+
"instrument_id": tape.instrument_id[idx],
|
|
705
|
+
"bid_price": float(tape.bid_price[idx]),
|
|
706
|
+
"ask_price": float(tape.ask_price[idx]),
|
|
707
|
+
"mark_price": float(tape.mark_price[idx]),
|
|
708
|
+
"index_price": float(tape.index_price[idx]),
|
|
709
|
+
"forward_price": float(tape.forward_price[idx]),
|
|
710
|
+
"bid_size": float(tape.bid_size[idx]),
|
|
711
|
+
"ask_size": float(tape.ask_size[idx]),
|
|
712
|
+
}
|
|
713
|
+
)
|
|
714
|
+
return pd.DataFrame(rows)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _greeks_report(tape: PreparedOptionTape) -> pd.DataFrame:
|
|
718
|
+
return pd.DataFrame(
|
|
719
|
+
{
|
|
720
|
+
"timestamp_ns": np.repeat(tape.timestamp_ns, np.diff(tape.row_ptr)),
|
|
721
|
+
"instrument_id": tape.instrument_id,
|
|
722
|
+
"mark_iv": tape.mark_iv,
|
|
723
|
+
"bid_iv": tape.bid_iv,
|
|
724
|
+
"ask_iv": tape.ask_iv,
|
|
725
|
+
"delta": tape.delta,
|
|
726
|
+
"gamma": tape.gamma,
|
|
727
|
+
"vega": tape.vega,
|
|
728
|
+
"theta": tape.theta,
|
|
729
|
+
}
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _fills_report(fills_with_fees: Sequence[tuple]) -> pd.DataFrame:
|
|
734
|
+
rows = []
|
|
735
|
+
for fill, fee in fills_with_fees:
|
|
736
|
+
rows.append(
|
|
737
|
+
{
|
|
738
|
+
"timestamp": fill.timestamp,
|
|
739
|
+
"symbol": fill.symbol,
|
|
740
|
+
"side": fill.side.value,
|
|
741
|
+
"qty": float(fill.qty),
|
|
742
|
+
"price": float(fill.price),
|
|
743
|
+
"notional": float(fill.notional),
|
|
744
|
+
"execution_fee": float(fill.fee),
|
|
745
|
+
"applied_fee": float(fee.fee if fee is not None else fill.fee),
|
|
746
|
+
"fee_currency": fee.currency if fee is not None else "",
|
|
747
|
+
"liquidity": fill.liquidity.value,
|
|
748
|
+
"order_id": fill.order_id,
|
|
749
|
+
"package_id": fill.metadata.get("package_id"),
|
|
750
|
+
}
|
|
751
|
+
)
|
|
752
|
+
return pd.DataFrame(rows)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _settlements_report(settlements: Sequence) -> pd.DataFrame:
|
|
756
|
+
return pd.DataFrame(
|
|
757
|
+
[
|
|
758
|
+
{
|
|
759
|
+
"timestamp_ns": item.timestamp_ns,
|
|
760
|
+
"symbol": item.symbol,
|
|
761
|
+
"settlement_price": item.settlement_price,
|
|
762
|
+
"payoff_per_unit": item.payoff_per_unit,
|
|
763
|
+
"cashflow": item.cashflow,
|
|
764
|
+
"settlement_currency": item.settlement_currency,
|
|
765
|
+
"representation": item.representation.value,
|
|
766
|
+
"itm": item.itm,
|
|
767
|
+
"position_closed": item.position_closed,
|
|
768
|
+
}
|
|
769
|
+
for item in settlements
|
|
770
|
+
]
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _attribution_report(
|
|
775
|
+
ledger: OptionLedger,
|
|
776
|
+
account: AccountConfig,
|
|
777
|
+
final_equity: float,
|
|
778
|
+
report_ccy: str,
|
|
779
|
+
conversion_rates: Dict[str, float],
|
|
780
|
+
) -> pd.DataFrame:
|
|
781
|
+
rows = []
|
|
782
|
+
for currency, amount in ledger.cash.items():
|
|
783
|
+
rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan))
|
|
784
|
+
rows.append({"bucket": "cash", "currency": currency, "amount": float(amount), "reporting_value": float(amount) * rate})
|
|
785
|
+
for currency, fee in ledger.fees.items():
|
|
786
|
+
rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan))
|
|
787
|
+
rows.append({"bucket": "fees", "currency": currency, "amount": -float(fee), "reporting_value": -float(fee) * rate})
|
|
788
|
+
rows.append(
|
|
789
|
+
{
|
|
790
|
+
"bucket": "total",
|
|
791
|
+
"currency": report_ccy,
|
|
792
|
+
"amount": float(final_equity - account.initial_capital),
|
|
793
|
+
"reporting_value": float(final_equity - account.initial_capital),
|
|
794
|
+
}
|
|
795
|
+
)
|
|
796
|
+
return pd.DataFrame(rows)
|