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,600 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Snapshot-level option package execution.
|
|
3
|
+
|
|
4
|
+
Phase 4 is an execution simulator on a prepared option tape. It is intentionally
|
|
5
|
+
not the final multi-currency ledger, margin, expiry, or Nautilus adapter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ..core.orders import Fill, OrderIntent
|
|
17
|
+
from ..core.schema import LiquiditySide, OrderSide, OrderType, TimeInForce
|
|
18
|
+
from .packages import OptionPackageExecutionPolicy, OptionPackageIntent, compile_option_package_orders
|
|
19
|
+
from .tape import PreparedOptionTape
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OptionLimitFidelity(str, Enum):
|
|
23
|
+
CROSS_ONLY = "cross_only"
|
|
24
|
+
MAKER_TOUCH = "maker_touch"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OptionDepthFidelity(str, Enum):
|
|
28
|
+
TOP_OF_BOOK = "top_of_book"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class OptionExecutionConfig:
|
|
33
|
+
initial_cash: float = 0.0
|
|
34
|
+
fee_rate: float = 0.0
|
|
35
|
+
allow_partial_fill: bool = True
|
|
36
|
+
max_quote_age_ns: Optional[int] = None
|
|
37
|
+
limit_fidelity: OptionLimitFidelity = OptionLimitFidelity.CROSS_ONLY
|
|
38
|
+
depth_fidelity: OptionDepthFidelity = OptionDepthFidelity.TOP_OF_BOOK
|
|
39
|
+
metadata: Dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
object.__setattr__(self, "limit_fidelity", _coerce_enum(OptionLimitFidelity, self.limit_fidelity, "limit_fidelity"))
|
|
43
|
+
object.__setattr__(self, "depth_fidelity", _coerce_enum(OptionDepthFidelity, self.depth_fidelity, "depth_fidelity"))
|
|
44
|
+
if self.fee_rate < 0.0:
|
|
45
|
+
raise ValueError("fee_rate must be >= 0")
|
|
46
|
+
if self.max_quote_age_ns is not None and self.max_quote_age_ns < 0:
|
|
47
|
+
raise ValueError("max_quote_age_ns must be >= 0")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class OptionPackageExecutionResult:
|
|
52
|
+
fills: Tuple[Fill, ...]
|
|
53
|
+
order_report: pd.DataFrame
|
|
54
|
+
package_report: pd.DataFrame
|
|
55
|
+
cash: float
|
|
56
|
+
positions: Dict[str, float]
|
|
57
|
+
margin_report: Dict
|
|
58
|
+
metadata: Dict = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _ExecutionState:
|
|
63
|
+
cash: float
|
|
64
|
+
positions: Dict[str, float]
|
|
65
|
+
|
|
66
|
+
def copy(self) -> "_ExecutionState":
|
|
67
|
+
return _ExecutionState(cash=float(self.cash), positions=dict(self.positions))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class _OrderEvaluation:
|
|
72
|
+
fill: Optional[Fill]
|
|
73
|
+
row: Dict
|
|
74
|
+
cash_delta: float
|
|
75
|
+
position_delta: float
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_ORDER_REPORT_COLUMNS = [
|
|
79
|
+
"package_id",
|
|
80
|
+
"order_id",
|
|
81
|
+
"symbol",
|
|
82
|
+
"side",
|
|
83
|
+
"order_type",
|
|
84
|
+
"tif",
|
|
85
|
+
"requested_qty",
|
|
86
|
+
"filled_qty",
|
|
87
|
+
"residual_qty",
|
|
88
|
+
"fill_price",
|
|
89
|
+
"fee",
|
|
90
|
+
"cash_delta",
|
|
91
|
+
"status",
|
|
92
|
+
"reject_reason",
|
|
93
|
+
"liquidity",
|
|
94
|
+
"snapshot_timestamp_ns",
|
|
95
|
+
"decision_timestamp_ns",
|
|
96
|
+
"row_index",
|
|
97
|
+
"depth_fidelity",
|
|
98
|
+
"limit_fidelity",
|
|
99
|
+
"residual_risk",
|
|
100
|
+
"atomicity",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
_PACKAGE_REPORT_COLUMNS = [
|
|
104
|
+
"package_id",
|
|
105
|
+
"execution_policy",
|
|
106
|
+
"status",
|
|
107
|
+
"reject_reason",
|
|
108
|
+
"requested_orders",
|
|
109
|
+
"filled_orders",
|
|
110
|
+
"partial_orders",
|
|
111
|
+
"cash_before",
|
|
112
|
+
"cash_after",
|
|
113
|
+
"net_cash_delta",
|
|
114
|
+
"gross_premium",
|
|
115
|
+
"debit",
|
|
116
|
+
"credit",
|
|
117
|
+
"max_debit",
|
|
118
|
+
"min_credit",
|
|
119
|
+
"atomicity",
|
|
120
|
+
"exchange_combo",
|
|
121
|
+
"block_trade_style",
|
|
122
|
+
"depth_fidelity",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def execute_option_package(
|
|
127
|
+
package: OptionPackageIntent,
|
|
128
|
+
tape: PreparedOptionTape,
|
|
129
|
+
*,
|
|
130
|
+
config: Optional[OptionExecutionConfig] = None,
|
|
131
|
+
positions: Optional[Dict[str, float]] = None,
|
|
132
|
+
compiled_orders: Optional[Tuple[OrderIntent, ...]] = None,
|
|
133
|
+
) -> OptionPackageExecutionResult:
|
|
134
|
+
"""Execute one option package against the latest observable tape snapshot."""
|
|
135
|
+
cfg = config or OptionExecutionConfig()
|
|
136
|
+
state = _ExecutionState(cash=float(cfg.initial_cash), positions=dict(positions or {}))
|
|
137
|
+
orders = tuple(compiled_orders) if compiled_orders is not None else compile_option_package_orders(package)
|
|
138
|
+
policy = package.execution_policy
|
|
139
|
+
if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE:
|
|
140
|
+
return _execute_atomic_all_or_none(package, orders, tape, cfg, state)
|
|
141
|
+
if policy is OptionPackageExecutionPolicy.BEST_EFFORT:
|
|
142
|
+
return _execute_best_effort(package, orders, tape, cfg, state)
|
|
143
|
+
if policy is OptionPackageExecutionPolicy.SEQUENTIAL:
|
|
144
|
+
return _execute_sequential(package, orders, tape, cfg, state)
|
|
145
|
+
if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY:
|
|
146
|
+
return _execute_hedge_after_primary(package, orders, tape, cfg, state)
|
|
147
|
+
if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY:
|
|
148
|
+
return _execute_rebalance_only(package, orders, tape, cfg, state)
|
|
149
|
+
raise ValueError(f"unsupported option execution policy: {policy}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _execute_atomic_all_or_none(
|
|
153
|
+
package: OptionPackageIntent,
|
|
154
|
+
orders: Tuple[OrderIntent, ...],
|
|
155
|
+
tape: PreparedOptionTape,
|
|
156
|
+
cfg: OptionExecutionConfig,
|
|
157
|
+
state: _ExecutionState,
|
|
158
|
+
) -> OptionPackageExecutionResult:
|
|
159
|
+
trial = state.copy()
|
|
160
|
+
evaluations = [_evaluate_order(order, tape, cfg, trial, package.package_id) for order in orders]
|
|
161
|
+
all_full = all(row.row["status"] == "filled" for row in evaluations)
|
|
162
|
+
guard_ok, guard_reason = _package_cash_guard(package, evaluations)
|
|
163
|
+
if not all_full or not guard_ok:
|
|
164
|
+
reason = guard_reason or "atomic_all_or_none_unfilled_leg"
|
|
165
|
+
rows = [_rejected_row(ev.row, reason) for ev in evaluations]
|
|
166
|
+
return _final_result(package, cfg, state, state, [], rows, "rejected", reason)
|
|
167
|
+
fills = []
|
|
168
|
+
for ev in evaluations:
|
|
169
|
+
_apply_evaluation(trial, ev)
|
|
170
|
+
fills.append(ev.fill)
|
|
171
|
+
return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], "filled", "")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _execute_best_effort(
|
|
175
|
+
package: OptionPackageIntent,
|
|
176
|
+
orders: Tuple[OrderIntent, ...],
|
|
177
|
+
tape: PreparedOptionTape,
|
|
178
|
+
cfg: OptionExecutionConfig,
|
|
179
|
+
state: _ExecutionState,
|
|
180
|
+
) -> OptionPackageExecutionResult:
|
|
181
|
+
trial = state.copy()
|
|
182
|
+
fills: List[Fill] = []
|
|
183
|
+
evaluations = []
|
|
184
|
+
for order in orders:
|
|
185
|
+
ev = _evaluate_order(order, tape, cfg, trial, package.package_id)
|
|
186
|
+
evaluations.append(ev)
|
|
187
|
+
if ev.fill is not None:
|
|
188
|
+
_apply_evaluation(trial, ev)
|
|
189
|
+
fills.append(ev.fill)
|
|
190
|
+
guard_ok, guard_reason = _package_cash_guard(package, evaluations)
|
|
191
|
+
if not guard_ok:
|
|
192
|
+
rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
|
|
193
|
+
return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
|
|
194
|
+
status = _package_status(evaluations)
|
|
195
|
+
return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _execute_sequential(
|
|
199
|
+
package: OptionPackageIntent,
|
|
200
|
+
orders: Tuple[OrderIntent, ...],
|
|
201
|
+
tape: PreparedOptionTape,
|
|
202
|
+
cfg: OptionExecutionConfig,
|
|
203
|
+
state: _ExecutionState,
|
|
204
|
+
) -> OptionPackageExecutionResult:
|
|
205
|
+
trial = state.copy()
|
|
206
|
+
fills: List[Fill] = []
|
|
207
|
+
evaluations = []
|
|
208
|
+
stopped = False
|
|
209
|
+
for order in orders:
|
|
210
|
+
if stopped:
|
|
211
|
+
row = _base_skipped_row(package.package_id, order, "sequential_previous_leg_failed", cfg)
|
|
212
|
+
evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0))
|
|
213
|
+
continue
|
|
214
|
+
ev = _evaluate_order(order, tape, cfg, trial, package.package_id)
|
|
215
|
+
evaluations.append(ev)
|
|
216
|
+
if ev.fill is not None:
|
|
217
|
+
_apply_evaluation(trial, ev)
|
|
218
|
+
fills.append(ev.fill)
|
|
219
|
+
if ev.row["status"] not in {"filled", "partial"}:
|
|
220
|
+
stopped = True
|
|
221
|
+
guard_ok, guard_reason = _package_cash_guard(package, evaluations)
|
|
222
|
+
if not guard_ok:
|
|
223
|
+
rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
|
|
224
|
+
return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
|
|
225
|
+
status = _package_status(evaluations)
|
|
226
|
+
return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _execute_hedge_after_primary(
|
|
230
|
+
package: OptionPackageIntent,
|
|
231
|
+
orders: Tuple[OrderIntent, ...],
|
|
232
|
+
tape: PreparedOptionTape,
|
|
233
|
+
cfg: OptionExecutionConfig,
|
|
234
|
+
state: _ExecutionState,
|
|
235
|
+
) -> OptionPackageExecutionResult:
|
|
236
|
+
trial = state.copy()
|
|
237
|
+
fills: List[Fill] = []
|
|
238
|
+
evaluations = []
|
|
239
|
+
primary = next((order for order in orders if order.metadata.get("option_leg_role") == "primary"), orders[0])
|
|
240
|
+
hedge_orders = tuple(order for order in orders if order is not primary)
|
|
241
|
+
primary_ev = _evaluate_order(primary, tape, cfg, trial, package.package_id)
|
|
242
|
+
evaluations.append(primary_ev)
|
|
243
|
+
if primary_ev.row["status"] != "filled":
|
|
244
|
+
rows = [primary_ev.row] + [_base_skipped_row(package.package_id, order, "primary_not_filled", cfg) for order in hedge_orders]
|
|
245
|
+
return _final_result(package, cfg, state, state, [], rows, "rejected", "primary_not_filled")
|
|
246
|
+
_apply_evaluation(trial, primary_ev)
|
|
247
|
+
fills.append(primary_ev.fill)
|
|
248
|
+
for order in hedge_orders:
|
|
249
|
+
ev = _evaluate_order(order, tape, cfg, trial, package.package_id)
|
|
250
|
+
evaluations.append(ev)
|
|
251
|
+
if ev.fill is not None:
|
|
252
|
+
_apply_evaluation(trial, ev)
|
|
253
|
+
fills.append(ev.fill)
|
|
254
|
+
guard_ok, guard_reason = _package_cash_guard(package, evaluations)
|
|
255
|
+
if not guard_ok:
|
|
256
|
+
rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
|
|
257
|
+
return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
|
|
258
|
+
status = _package_status(evaluations)
|
|
259
|
+
return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _execute_rebalance_only(
|
|
263
|
+
package: OptionPackageIntent,
|
|
264
|
+
orders: Tuple[OrderIntent, ...],
|
|
265
|
+
tape: PreparedOptionTape,
|
|
266
|
+
cfg: OptionExecutionConfig,
|
|
267
|
+
state: _ExecutionState,
|
|
268
|
+
) -> OptionPackageExecutionResult:
|
|
269
|
+
trial = state.copy()
|
|
270
|
+
fills: List[Fill] = []
|
|
271
|
+
evaluations = []
|
|
272
|
+
for order in orders:
|
|
273
|
+
target_signed = float(order.side.sign) * float(order.qty)
|
|
274
|
+
current = float(trial.positions.get(order.symbol, 0.0))
|
|
275
|
+
delta = target_signed - current
|
|
276
|
+
if abs(delta) <= 1e-12:
|
|
277
|
+
row = _base_skipped_row(package.package_id, order, "already_at_target", cfg)
|
|
278
|
+
row["status"] = "no_op"
|
|
279
|
+
evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0))
|
|
280
|
+
continue
|
|
281
|
+
adjusted = OrderIntent(
|
|
282
|
+
timestamp=order.timestamp,
|
|
283
|
+
symbol=order.symbol,
|
|
284
|
+
side=OrderSide.BUY if delta > 0 else OrderSide.SELL,
|
|
285
|
+
order_type=order.order_type,
|
|
286
|
+
qty=abs(delta),
|
|
287
|
+
price=order.price,
|
|
288
|
+
tif=order.tif,
|
|
289
|
+
tag=order.tag,
|
|
290
|
+
metadata={**order.metadata, "rebalance_target_signed_qty": target_signed, "rebalance_current_qty": current},
|
|
291
|
+
)
|
|
292
|
+
ev = _evaluate_order(adjusted, tape, cfg, trial, package.package_id)
|
|
293
|
+
evaluations.append(ev)
|
|
294
|
+
if ev.fill is not None:
|
|
295
|
+
_apply_evaluation(trial, ev)
|
|
296
|
+
fills.append(ev.fill)
|
|
297
|
+
guard_ok, guard_reason = _package_cash_guard(package, evaluations)
|
|
298
|
+
if not guard_ok:
|
|
299
|
+
rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
|
|
300
|
+
return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
|
|
301
|
+
status = _package_status(evaluations)
|
|
302
|
+
return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _evaluate_order(
|
|
306
|
+
order: OrderIntent,
|
|
307
|
+
tape: PreparedOptionTape,
|
|
308
|
+
cfg: OptionExecutionConfig,
|
|
309
|
+
state: _ExecutionState,
|
|
310
|
+
package_id: str,
|
|
311
|
+
) -> _OrderEvaluation:
|
|
312
|
+
snapshot_index = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns)
|
|
313
|
+
rows = tape.snapshot_slice(snapshot_index)
|
|
314
|
+
row_index = _find_row_index(tape, rows, order.symbol)
|
|
315
|
+
if row_index is None:
|
|
316
|
+
return _OrderEvaluation(None, _base_rejected_row(package_id, order, "instrument_not_listed_at_snapshot", cfg), 0.0, 0.0)
|
|
317
|
+
fill_price, liquidity, fillable, reason = _fill_price(order, tape, row_index, cfg)
|
|
318
|
+
if not fillable:
|
|
319
|
+
return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, 0.0, "open", reason, liquidity), 0.0, 0.0)
|
|
320
|
+
available = _available_qty(order, tape, row_index)
|
|
321
|
+
fill_qty = min(float(order.qty), available)
|
|
322
|
+
residual = float(order.qty) - fill_qty
|
|
323
|
+
if fill_qty <= 0.0:
|
|
324
|
+
return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "no_top_of_book_size", liquidity), 0.0, 0.0)
|
|
325
|
+
if residual > 1e-12 and order.tif is TimeInForce.FOK:
|
|
326
|
+
return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "fok_insufficient_size", liquidity), 0.0, 0.0)
|
|
327
|
+
if residual > 1e-12 and order.tif is TimeInForce.IOC:
|
|
328
|
+
if not cfg.allow_partial_fill:
|
|
329
|
+
return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "ioc_partial_not_allowed", liquidity), 0.0, 0.0)
|
|
330
|
+
status = "partial"
|
|
331
|
+
reason = "ioc_residual_canceled"
|
|
332
|
+
elif residual > 1e-12 and order.tif is TimeInForce.GTC:
|
|
333
|
+
if not cfg.allow_partial_fill:
|
|
334
|
+
return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "gtc_waiting_for_size", liquidity), 0.0, 0.0)
|
|
335
|
+
status = "partial"
|
|
336
|
+
reason = "gtc_residual_open"
|
|
337
|
+
else:
|
|
338
|
+
status = "filled"
|
|
339
|
+
reason = ""
|
|
340
|
+
fee = fill_qty * fill_price * cfg.fee_rate
|
|
341
|
+
cash_delta = fill_qty * fill_price - fee if order.side is OrderSide.SELL else -(fill_qty * fill_price + fee)
|
|
342
|
+
position_delta = order.side.sign * fill_qty
|
|
343
|
+
fill = Fill(
|
|
344
|
+
timestamp=tape.timestamp_ns[snapshot_index],
|
|
345
|
+
symbol=order.symbol,
|
|
346
|
+
side=order.side,
|
|
347
|
+
qty=fill_qty,
|
|
348
|
+
price=fill_price,
|
|
349
|
+
fee=fee,
|
|
350
|
+
liquidity=liquidity,
|
|
351
|
+
order_id=order.order_id,
|
|
352
|
+
metadata={**order.metadata, "option_row_index": int(row_index), "package_id": package_id},
|
|
353
|
+
)
|
|
354
|
+
row = _row_from_order(package_id, order, tape, row_index, cfg, fill_qty, fill_price, status, reason, liquidity, fee=fee, cash_delta=cash_delta)
|
|
355
|
+
return _OrderEvaluation(fill, row, cash_delta, position_delta)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _fill_price(
|
|
359
|
+
order: OrderIntent,
|
|
360
|
+
tape: PreparedOptionTape,
|
|
361
|
+
row_index: int,
|
|
362
|
+
cfg: OptionExecutionConfig,
|
|
363
|
+
) -> tuple[float, LiquiditySide, bool, str]:
|
|
364
|
+
bid = float(tape.bid_price[row_index])
|
|
365
|
+
ask = float(tape.ask_price[row_index])
|
|
366
|
+
if order.order_type is OrderType.MARKET:
|
|
367
|
+
return (ask if order.side is OrderSide.BUY else bid), LiquiditySide.TAKER, True, ""
|
|
368
|
+
if order.order_type is not OrderType.LIMIT:
|
|
369
|
+
return float("nan"), LiquiditySide.TAKER, False, "unsupported_option_order_type"
|
|
370
|
+
limit = float(order.price)
|
|
371
|
+
if cfg.limit_fidelity is OptionLimitFidelity.CROSS_ONLY:
|
|
372
|
+
if order.side is OrderSide.BUY and limit >= ask:
|
|
373
|
+
return ask, LiquiditySide.TAKER, True, ""
|
|
374
|
+
if order.side is OrderSide.SELL and limit <= bid:
|
|
375
|
+
return bid, LiquiditySide.TAKER, True, ""
|
|
376
|
+
return limit, LiquiditySide.MAKER, False, "limit_not_crossed"
|
|
377
|
+
if order.side is OrderSide.BUY and limit >= bid:
|
|
378
|
+
return min(limit, ask), LiquiditySide.MAKER if limit < ask else LiquiditySide.TAKER, True, "maker_touch_simulated"
|
|
379
|
+
if order.side is OrderSide.SELL and limit <= ask:
|
|
380
|
+
return max(limit, bid), LiquiditySide.MAKER if limit > bid else LiquiditySide.TAKER, True, "maker_touch_simulated"
|
|
381
|
+
return limit, LiquiditySide.MAKER, False, "limit_not_touched"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _available_qty(order: OrderIntent, tape: PreparedOptionTape, row_index: int) -> float:
|
|
385
|
+
return float(tape.ask_size[row_index] if order.side is OrderSide.BUY else tape.bid_size[row_index])
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _find_row_index(tape: PreparedOptionTape, rows: slice, symbol: str) -> Optional[int]:
|
|
389
|
+
for idx in range(rows.start, rows.stop):
|
|
390
|
+
if tape.instrument_id[idx] == symbol:
|
|
391
|
+
return idx
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _apply_evaluation(state: _ExecutionState, evaluation: _OrderEvaluation) -> None:
|
|
396
|
+
if evaluation.fill is None:
|
|
397
|
+
return
|
|
398
|
+
state.cash += float(evaluation.cash_delta)
|
|
399
|
+
state.positions[evaluation.fill.symbol] = state.positions.get(evaluation.fill.symbol, 0.0) + evaluation.position_delta
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _package_cash_guard(package: OptionPackageIntent, evaluations: List[_OrderEvaluation]) -> tuple[bool, str]:
|
|
403
|
+
net_cash_delta = sum(ev.cash_delta for ev in evaluations if ev.fill is not None)
|
|
404
|
+
debit = max(-net_cash_delta, 0.0)
|
|
405
|
+
credit = max(net_cash_delta, 0.0)
|
|
406
|
+
if package.max_debit is not None and debit > float(package.max_debit) + 1e-12:
|
|
407
|
+
return False, "max_debit_exceeded"
|
|
408
|
+
if package.min_credit is not None and credit + 1e-12 < float(package.min_credit):
|
|
409
|
+
return False, "min_credit_not_met"
|
|
410
|
+
return True, ""
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _final_result(
|
|
414
|
+
package: OptionPackageIntent,
|
|
415
|
+
cfg: OptionExecutionConfig,
|
|
416
|
+
initial_state: _ExecutionState,
|
|
417
|
+
final_state: _ExecutionState,
|
|
418
|
+
fills: List[Optional[Fill]],
|
|
419
|
+
rows: List[Dict],
|
|
420
|
+
package_status: str,
|
|
421
|
+
reject_reason: str,
|
|
422
|
+
) -> OptionPackageExecutionResult:
|
|
423
|
+
concrete_fills = tuple(fill for fill in fills if fill is not None)
|
|
424
|
+
order_report = pd.DataFrame(rows, columns=_ORDER_REPORT_COLUMNS)
|
|
425
|
+
filled_orders = int((order_report["status"] == "filled").sum()) if not order_report.empty else 0
|
|
426
|
+
partial_orders = int((order_report["status"] == "partial").sum()) if not order_report.empty else 0
|
|
427
|
+
net_cash_delta = float(final_state.cash - initial_state.cash)
|
|
428
|
+
gross_premium = float(order_report["filled_qty"].mul(order_report["fill_price"]).sum()) if not order_report.empty else 0.0
|
|
429
|
+
package_report = pd.DataFrame(
|
|
430
|
+
[
|
|
431
|
+
{
|
|
432
|
+
"package_id": package.package_id,
|
|
433
|
+
"execution_policy": package.execution_policy.value,
|
|
434
|
+
"status": package_status,
|
|
435
|
+
"reject_reason": reject_reason,
|
|
436
|
+
"requested_orders": len(package.legs),
|
|
437
|
+
"filled_orders": filled_orders,
|
|
438
|
+
"partial_orders": partial_orders,
|
|
439
|
+
"cash_before": initial_state.cash,
|
|
440
|
+
"cash_after": final_state.cash,
|
|
441
|
+
"net_cash_delta": net_cash_delta,
|
|
442
|
+
"gross_premium": gross_premium,
|
|
443
|
+
"debit": max(-net_cash_delta, 0.0),
|
|
444
|
+
"credit": max(net_cash_delta, 0.0),
|
|
445
|
+
"max_debit": package.max_debit,
|
|
446
|
+
"min_credit": package.min_credit,
|
|
447
|
+
"atomicity": _atomicity_for_report(package.execution_policy),
|
|
448
|
+
"exchange_combo": False,
|
|
449
|
+
"block_trade_style": False,
|
|
450
|
+
"depth_fidelity": cfg.depth_fidelity.value,
|
|
451
|
+
}
|
|
452
|
+
],
|
|
453
|
+
columns=_PACKAGE_REPORT_COLUMNS,
|
|
454
|
+
)
|
|
455
|
+
positions = {symbol: qty for symbol, qty in final_state.positions.items() if abs(qty) > 1e-12}
|
|
456
|
+
return OptionPackageExecutionResult(
|
|
457
|
+
fills=concrete_fills,
|
|
458
|
+
order_report=order_report,
|
|
459
|
+
package_report=package_report,
|
|
460
|
+
cash=float(final_state.cash),
|
|
461
|
+
positions=positions,
|
|
462
|
+
margin_report={
|
|
463
|
+
"phase": "phase4_snapshot_execution",
|
|
464
|
+
"margin_model": "not_implemented_until_phase5",
|
|
465
|
+
"gross_premium": gross_premium,
|
|
466
|
+
"position_count": len(positions),
|
|
467
|
+
},
|
|
468
|
+
metadata={
|
|
469
|
+
"backend": "native_option_phase4",
|
|
470
|
+
"execution_scope": "snapshot_package_execution",
|
|
471
|
+
"depth_fidelity": cfg.depth_fidelity.value,
|
|
472
|
+
"limit_fidelity": cfg.limit_fidelity.value,
|
|
473
|
+
"atomicity": _atomicity_for_report(package.execution_policy),
|
|
474
|
+
**cfg.metadata,
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _package_status(evaluations: List[_OrderEvaluation]) -> str:
|
|
480
|
+
statuses = [ev.row["status"] for ev in evaluations]
|
|
481
|
+
if statuses and all(status == "filled" for status in statuses):
|
|
482
|
+
return "filled"
|
|
483
|
+
if any(status == "partial" for status in statuses):
|
|
484
|
+
return "partial"
|
|
485
|
+
if any(status == "filled" for status in statuses):
|
|
486
|
+
return "partial"
|
|
487
|
+
if any(status == "open" for status in statuses):
|
|
488
|
+
return "open"
|
|
489
|
+
return "rejected"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _row_from_order(
|
|
493
|
+
package_id: str,
|
|
494
|
+
order: OrderIntent,
|
|
495
|
+
tape: PreparedOptionTape,
|
|
496
|
+
row_index: int,
|
|
497
|
+
cfg: OptionExecutionConfig,
|
|
498
|
+
filled_qty: float,
|
|
499
|
+
fill_price: float,
|
|
500
|
+
status: str,
|
|
501
|
+
reject_reason: str,
|
|
502
|
+
liquidity: LiquiditySide,
|
|
503
|
+
*,
|
|
504
|
+
fee: float = 0.0,
|
|
505
|
+
cash_delta: float = 0.0,
|
|
506
|
+
) -> Dict:
|
|
507
|
+
snapshot_idx = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns)
|
|
508
|
+
return {
|
|
509
|
+
"package_id": package_id,
|
|
510
|
+
"order_id": order.order_id,
|
|
511
|
+
"symbol": order.symbol,
|
|
512
|
+
"side": order.side.value,
|
|
513
|
+
"order_type": order.order_type.value,
|
|
514
|
+
"tif": order.tif.value,
|
|
515
|
+
"requested_qty": float(order.qty),
|
|
516
|
+
"filled_qty": float(filled_qty),
|
|
517
|
+
"residual_qty": max(float(order.qty) - float(filled_qty), 0.0),
|
|
518
|
+
"fill_price": float(fill_price),
|
|
519
|
+
"fee": float(fee),
|
|
520
|
+
"cash_delta": float(cash_delta),
|
|
521
|
+
"status": status,
|
|
522
|
+
"reject_reason": reject_reason,
|
|
523
|
+
"liquidity": liquidity.value,
|
|
524
|
+
"snapshot_timestamp_ns": int(tape.timestamp_ns[snapshot_idx]),
|
|
525
|
+
"decision_timestamp_ns": int(order.timestamp),
|
|
526
|
+
"row_index": int(row_index),
|
|
527
|
+
"depth_fidelity": cfg.depth_fidelity.value,
|
|
528
|
+
"limit_fidelity": cfg.limit_fidelity.value,
|
|
529
|
+
"residual_risk": bool(status == "partial"),
|
|
530
|
+
"atomicity": order.metadata.get("atomicity", ""),
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _base_rejected_row(package_id: str, order: OrderIntent, reason: str, cfg: OptionExecutionConfig) -> Dict:
|
|
535
|
+
return _base_skipped_row(package_id, order, reason, cfg, status="rejected")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _base_skipped_row(
|
|
539
|
+
package_id: str,
|
|
540
|
+
order: OrderIntent,
|
|
541
|
+
reason: str,
|
|
542
|
+
cfg: OptionExecutionConfig,
|
|
543
|
+
*,
|
|
544
|
+
status: str = "skipped",
|
|
545
|
+
) -> Dict:
|
|
546
|
+
return {
|
|
547
|
+
"package_id": package_id,
|
|
548
|
+
"order_id": order.order_id,
|
|
549
|
+
"symbol": order.symbol,
|
|
550
|
+
"side": order.side.value,
|
|
551
|
+
"order_type": order.order_type.value,
|
|
552
|
+
"tif": order.tif.value,
|
|
553
|
+
"requested_qty": float(order.qty),
|
|
554
|
+
"filled_qty": 0.0,
|
|
555
|
+
"residual_qty": float(order.qty),
|
|
556
|
+
"fill_price": float("nan"),
|
|
557
|
+
"fee": 0.0,
|
|
558
|
+
"cash_delta": 0.0,
|
|
559
|
+
"status": status,
|
|
560
|
+
"reject_reason": reason,
|
|
561
|
+
"liquidity": "",
|
|
562
|
+
"snapshot_timestamp_ns": 0,
|
|
563
|
+
"decision_timestamp_ns": int(order.timestamp),
|
|
564
|
+
"row_index": -1,
|
|
565
|
+
"depth_fidelity": cfg.depth_fidelity.value,
|
|
566
|
+
"limit_fidelity": cfg.limit_fidelity.value,
|
|
567
|
+
"residual_risk": False,
|
|
568
|
+
"atomicity": order.metadata.get("atomicity", ""),
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _rejected_row(row: Dict, reason: str) -> Dict:
|
|
573
|
+
rejected = dict(row)
|
|
574
|
+
rejected["status"] = "rejected"
|
|
575
|
+
rejected["reject_reason"] = reason
|
|
576
|
+
rejected["filled_qty"] = 0.0
|
|
577
|
+
rejected["residual_qty"] = rejected["requested_qty"]
|
|
578
|
+
rejected["fee"] = 0.0
|
|
579
|
+
rejected["cash_delta"] = 0.0
|
|
580
|
+
rejected["residual_risk"] = False
|
|
581
|
+
return rejected
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _atomicity_for_report(policy: OptionPackageExecutionPolicy) -> str:
|
|
585
|
+
if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE:
|
|
586
|
+
return "simulated_atomic_all_or_none"
|
|
587
|
+
if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY:
|
|
588
|
+
return "simulated_primary_then_hedge"
|
|
589
|
+
if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY:
|
|
590
|
+
return "simulated_rebalance_only"
|
|
591
|
+
return f"simulated_{policy.value}"
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _coerce_enum(enum_cls, value, field_name: str):
|
|
595
|
+
if isinstance(value, enum_cls):
|
|
596
|
+
return value
|
|
597
|
+
try:
|
|
598
|
+
return enum_cls(str(value))
|
|
599
|
+
except ValueError as exc:
|
|
600
|
+
raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc
|