quantbt-engine 1.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
quantbt/core/schema.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
quantbt.core.schema
|
|
3
|
+
-------------------
|
|
4
|
+
Domain configuration objects shared by native and optional adapter backends.
|
|
5
|
+
|
|
6
|
+
These dataclasses are intentionally lightweight and dependency-free beyond the
|
|
7
|
+
standard library. Hot loops should receive ndarray views derived from these
|
|
8
|
+
objects, not the objects themselves.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Dict, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AssetType(str, Enum):
|
|
19
|
+
CRYPTO = "crypto"
|
|
20
|
+
STOCK = "stock"
|
|
21
|
+
FUTURE = "future"
|
|
22
|
+
FX = "fx"
|
|
23
|
+
OPTION = "option"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MarginMode(str, Enum):
|
|
27
|
+
CASH = "cash"
|
|
28
|
+
ISOLATED = "isolated"
|
|
29
|
+
CROSS = "cross"
|
|
30
|
+
PORTFOLIO = "portfolio"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OmsMode(str, Enum):
|
|
34
|
+
NETTING = "netting"
|
|
35
|
+
HEDGING = "hedging"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OrderSide(str, Enum):
|
|
39
|
+
BUY = "buy"
|
|
40
|
+
SELL = "sell"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def sign(self) -> float:
|
|
44
|
+
return 1.0 if self is OrderSide.BUY else -1.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OrderType(str, Enum):
|
|
48
|
+
MARKET = "market"
|
|
49
|
+
LIMIT = "limit"
|
|
50
|
+
STOP_MARKET = "stop_market"
|
|
51
|
+
STOP_LIMIT = "stop_limit"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TimeInForce(str, Enum):
|
|
55
|
+
GTC = "gtc"
|
|
56
|
+
IOC = "ioc"
|
|
57
|
+
FOK = "fok"
|
|
58
|
+
GTD = "gtd"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LiquiditySide(str, Enum):
|
|
62
|
+
MAKER = "maker"
|
|
63
|
+
TAKER = "taker"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FillPricePolicy(str, Enum):
|
|
67
|
+
CLOSE = "close"
|
|
68
|
+
OPEN = "open"
|
|
69
|
+
TOUCH = "touch"
|
|
70
|
+
NEXT_OPEN = "next_open"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SameBarPolicy(str, Enum):
|
|
74
|
+
CONSERVATIVE = "conservative"
|
|
75
|
+
ENTRY_FIRST = "entry_first"
|
|
76
|
+
EXIT_FIRST = "exit_first"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BasketExecutionPolicy(str, Enum):
|
|
80
|
+
BEST_EFFORT = "best_effort"
|
|
81
|
+
ALL_OR_NONE = "all_or_none"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class FeeModel:
|
|
86
|
+
maker: float = 0.0
|
|
87
|
+
taker: float = 0.0
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
if self.maker < 0.0 or self.taker < 0.0:
|
|
91
|
+
raise ValueError("fee rates must be >= 0")
|
|
92
|
+
|
|
93
|
+
def rate_for(self, liquidity: LiquiditySide) -> float:
|
|
94
|
+
return self.maker if liquidity is LiquiditySide.MAKER else self.taker
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class InstrumentSpec:
|
|
99
|
+
symbol: str
|
|
100
|
+
asset_type: AssetType = AssetType.CRYPTO
|
|
101
|
+
contract_size: float = 1.0
|
|
102
|
+
tick_size: float = 0.0
|
|
103
|
+
lot_size: float = 0.0
|
|
104
|
+
min_qty: float = 0.0
|
|
105
|
+
min_notional: float = 0.0
|
|
106
|
+
price_precision: Optional[int] = None
|
|
107
|
+
qty_precision: Optional[int] = None
|
|
108
|
+
fee_model: FeeModel = field(default_factory=FeeModel)
|
|
109
|
+
metadata: Dict = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
if not self.symbol:
|
|
113
|
+
raise ValueError("symbol is required")
|
|
114
|
+
if self.contract_size <= 0.0:
|
|
115
|
+
raise ValueError("contract_size must be > 0")
|
|
116
|
+
if self.tick_size < 0.0 or self.lot_size < 0.0:
|
|
117
|
+
raise ValueError("tick_size and lot_size must be >= 0")
|
|
118
|
+
if self.min_qty < 0.0 or self.min_notional < 0.0:
|
|
119
|
+
raise ValueError("min_qty and min_notional must be >= 0")
|
|
120
|
+
if self.price_precision is not None and self.price_precision < 0:
|
|
121
|
+
raise ValueError("price_precision must be >= 0")
|
|
122
|
+
if self.qty_precision is not None and self.qty_precision < 0:
|
|
123
|
+
raise ValueError("qty_precision must be >= 0")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class AccountConfig:
|
|
128
|
+
initial_capital: float
|
|
129
|
+
base_currency: str = "USD"
|
|
130
|
+
leverage: float = 1.0
|
|
131
|
+
maintenance_ratio: float = 0.005
|
|
132
|
+
margin_mode: MarginMode = MarginMode.CROSS
|
|
133
|
+
oms_mode: OmsMode = OmsMode.NETTING
|
|
134
|
+
margin_buffer: float = 0.0
|
|
135
|
+
metadata: Dict = field(default_factory=dict)
|
|
136
|
+
|
|
137
|
+
def __post_init__(self) -> None:
|
|
138
|
+
if self.initial_capital <= 0.0:
|
|
139
|
+
raise ValueError("initial_capital must be > 0")
|
|
140
|
+
if self.leverage <= 0.0:
|
|
141
|
+
raise ValueError("leverage must be > 0")
|
|
142
|
+
if self.maintenance_ratio < 0.0:
|
|
143
|
+
raise ValueError("maintenance_ratio must be >= 0")
|
|
144
|
+
if self.margin_buffer < 0.0:
|
|
145
|
+
raise ValueError("margin_buffer must be >= 0")
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def initial_buying_power(self) -> float:
|
|
149
|
+
return self.initial_capital * self.leverage
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class ExecutionConfig:
|
|
154
|
+
fill_price_policy: FillPricePolicy = FillPricePolicy.CLOSE
|
|
155
|
+
same_bar_policy: SameBarPolicy = SameBarPolicy.CONSERVATIVE
|
|
156
|
+
slippage_bps: float = 0.0
|
|
157
|
+
allow_partial_fill: bool = False
|
|
158
|
+
reject_on_insufficient_margin: bool = True
|
|
159
|
+
min_order_notional: float = 0.0
|
|
160
|
+
metadata: Dict = field(default_factory=dict)
|
|
161
|
+
|
|
162
|
+
def __post_init__(self) -> None:
|
|
163
|
+
if self.slippage_bps < 0.0:
|
|
164
|
+
raise ValueError("slippage_bps must be >= 0")
|
|
165
|
+
if self.min_order_notional < 0.0:
|
|
166
|
+
raise ValueError("min_order_notional must be >= 0")
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def slippage_rate(self) -> float:
|
|
170
|
+
return self.slippage_bps / 10_000.0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class SignalSpec:
|
|
175
|
+
timestamp: object
|
|
176
|
+
symbol: str
|
|
177
|
+
value: float
|
|
178
|
+
kind: str = "weight"
|
|
179
|
+
metadata: Dict = field(default_factory=dict)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class BasketLegSpec:
|
|
184
|
+
symbol: str
|
|
185
|
+
ratio: float
|
|
186
|
+
metadata: Dict = field(default_factory=dict)
|
|
187
|
+
|
|
188
|
+
def __post_init__(self) -> None:
|
|
189
|
+
if not self.symbol:
|
|
190
|
+
raise ValueError("symbol is required")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class BasketSpec:
|
|
195
|
+
basket_id: str
|
|
196
|
+
legs: tuple[BasketLegSpec, ...]
|
|
197
|
+
gross_notional: float
|
|
198
|
+
freeze_hedge: bool = True
|
|
199
|
+
hedged_margin_offset: float = 0.0
|
|
200
|
+
execution_policy: BasketExecutionPolicy = BasketExecutionPolicy.BEST_EFFORT
|
|
201
|
+
metadata: Dict = field(default_factory=dict)
|
|
202
|
+
|
|
203
|
+
def __post_init__(self) -> None:
|
|
204
|
+
if not self.basket_id:
|
|
205
|
+
raise ValueError("basket_id is required")
|
|
206
|
+
if len(self.legs) == 0:
|
|
207
|
+
raise ValueError("basket must contain at least one leg")
|
|
208
|
+
if self.gross_notional < 0.0:
|
|
209
|
+
raise ValueError("gross_notional must be >= 0")
|
|
210
|
+
if not 0.0 <= self.hedged_margin_offset <= 1.0:
|
|
211
|
+
raise ValueError("hedged_margin_offset must be in [0, 1]")
|
quantbt/core/scopes.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reporting scope helpers.
|
|
3
|
+
|
|
4
|
+
Walk-forward and train/test split runs store a full stitched timeline, but the
|
|
5
|
+
natural performance report is the OOS/test portion only. These helpers keep
|
|
6
|
+
endpoint-level and result-level metrics/plots consistent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from .results import BacktestResultV2
|
|
14
|
+
from .types import BacktestResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def scoped_result(result, scope: str = "auto"):
|
|
18
|
+
"""
|
|
19
|
+
Return `result` or an OOS/test-sliced copy for reporting.
|
|
20
|
+
|
|
21
|
+
`auto` means OOS/test for walk-forward artifacts and full result for normal
|
|
22
|
+
backtests. Use `full` to audit the complete stitched timeline.
|
|
23
|
+
"""
|
|
24
|
+
normalized = str(scope or "auto").lower().strip()
|
|
25
|
+
if normalized == "auto":
|
|
26
|
+
normalized = "oos" if "walk_forward" in result.metadata else "full"
|
|
27
|
+
if normalized == "full":
|
|
28
|
+
return result
|
|
29
|
+
if normalized in {"test", "oos"}:
|
|
30
|
+
return _slice_result_to_walk_forward_oos(result, scope=normalized)
|
|
31
|
+
raise ValueError("scope must be auto, full, test, or oos")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _slice_result_to_walk_forward_oos(result, scope: str):
|
|
35
|
+
wf_meta = result.metadata.get("walk_forward")
|
|
36
|
+
if not wf_meta:
|
|
37
|
+
raise ValueError(f"scope={scope!r} is only available for walk_forward/train_test_split results")
|
|
38
|
+
fold_table = wf_meta.get("fold_table")
|
|
39
|
+
if fold_table is None or len(fold_table) == 0:
|
|
40
|
+
raise ValueError("walk-forward result does not contain a fold_table")
|
|
41
|
+
|
|
42
|
+
idx = result.equity.index
|
|
43
|
+
mask = pd.Series(False, index=idx)
|
|
44
|
+
for _, row in fold_table.iterrows():
|
|
45
|
+
start = pd.Timestamp(row["test_start"])
|
|
46
|
+
end = pd.Timestamp(row["test_end"])
|
|
47
|
+
mask |= (idx >= start) & (idx <= end)
|
|
48
|
+
if not bool(mask.any()):
|
|
49
|
+
raise ValueError("walk-forward OOS/test scope contains no bars in result index")
|
|
50
|
+
|
|
51
|
+
sliced_metadata = dict(result.metadata)
|
|
52
|
+
sliced_wf_meta = dict(wf_meta)
|
|
53
|
+
sliced_wf_meta["report_scope"] = scope
|
|
54
|
+
sliced_metadata["walk_forward"] = sliced_wf_meta
|
|
55
|
+
|
|
56
|
+
if isinstance(result, BacktestResultV2):
|
|
57
|
+
return BacktestResultV2(
|
|
58
|
+
equity=result.equity.loc[mask].copy(),
|
|
59
|
+
returns=result.returns.loc[mask].copy(),
|
|
60
|
+
positions=result.positions.loc[mask].copy(),
|
|
61
|
+
closes=result.closes.loc[mask].copy(),
|
|
62
|
+
symbols=list(result.symbols),
|
|
63
|
+
initial_capital=float(result.initial_capital),
|
|
64
|
+
leverage=float(result.leverage),
|
|
65
|
+
liquidated=bool(result.liquidated),
|
|
66
|
+
liquidation_bar=int(result.liquidation_bar),
|
|
67
|
+
orders=getattr(result, "orders", ()),
|
|
68
|
+
fills=getattr(result, "fills", ()),
|
|
69
|
+
trades=getattr(result, "trades", ()),
|
|
70
|
+
fees=_slice_indexed_like(result.fees, mask),
|
|
71
|
+
funding=_slice_indexed_like(result.funding, mask),
|
|
72
|
+
margin=_slice_indexed_like(result.margin, mask),
|
|
73
|
+
diagnostics=_slice_indexed_like(result.diagnostics, mask),
|
|
74
|
+
metadata=sliced_metadata,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return BacktestResult(
|
|
78
|
+
equity=result.equity.loc[mask].copy(),
|
|
79
|
+
returns=result.returns.loc[mask].copy(),
|
|
80
|
+
positions=result.positions.loc[mask].copy(),
|
|
81
|
+
closes=result.closes.loc[mask].copy(),
|
|
82
|
+
symbols=list(result.symbols),
|
|
83
|
+
initial_capital=float(result.initial_capital),
|
|
84
|
+
leverage=float(result.leverage),
|
|
85
|
+
liquidated=bool(result.liquidated),
|
|
86
|
+
liquidation_bar=int(result.liquidation_bar),
|
|
87
|
+
metadata=sliced_metadata,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _slice_indexed_like(obj, mask: pd.Series):
|
|
92
|
+
if obj is None:
|
|
93
|
+
return obj
|
|
94
|
+
if isinstance(obj, (pd.Series, pd.DataFrame)) and obj.index.equals(mask.index):
|
|
95
|
+
return obj.loc[mask].copy()
|
|
96
|
+
return obj
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured order package compilers.
|
|
3
|
+
|
|
4
|
+
These helpers convert transparent strategy-package specs into explicit
|
|
5
|
+
``OrderIntent`` objects. They intentionally do not contain alpha logic; the
|
|
6
|
+
generated orders are passed to event backends such as Nautilus for execution
|
|
7
|
+
simulation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Dict, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from .orders import OrderIntent
|
|
18
|
+
from .schema import OrderSide, OrderType, TimeInForce
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class StructuredOrderPlan:
|
|
23
|
+
package_id: str
|
|
24
|
+
package_type: str
|
|
25
|
+
orders: tuple[OrderIntent, ...]
|
|
26
|
+
order_table: pd.DataFrame
|
|
27
|
+
metadata: Dict = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class BracketOrderSpec:
|
|
32
|
+
"""
|
|
33
|
+
Entry plus linked take-profit/stop-loss exits.
|
|
34
|
+
|
|
35
|
+
``exit_timestamp`` defaults to the entry timestamp. In bar-based validation,
|
|
36
|
+
callers may set it to the next bar to model contingent exits becoming
|
|
37
|
+
active only after the entry fill is known.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
symbol: str
|
|
41
|
+
entry_timestamp: object
|
|
42
|
+
side: OrderSide
|
|
43
|
+
qty: float
|
|
44
|
+
package_id: str = "BRACKET-001"
|
|
45
|
+
entry_order_type: OrderType = OrderType.MARKET
|
|
46
|
+
entry_price: Optional[float] = None
|
|
47
|
+
entry_trigger_price: Optional[float] = None
|
|
48
|
+
take_profit_price: Optional[float] = None
|
|
49
|
+
stop_loss_price: Optional[float] = None
|
|
50
|
+
exit_timestamp: Optional[object] = None
|
|
51
|
+
entry_tif: TimeInForce = TimeInForce.IOC
|
|
52
|
+
exit_tif: TimeInForce = TimeInForce.GTC
|
|
53
|
+
reduce_only_exits: bool = True
|
|
54
|
+
tag: Optional[str] = None
|
|
55
|
+
metadata: Dict = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
object.__setattr__(self, "side", _coerce_enum(OrderSide, self.side))
|
|
59
|
+
object.__setattr__(self, "entry_order_type", _coerce_enum(OrderType, self.entry_order_type))
|
|
60
|
+
object.__setattr__(self, "entry_tif", _coerce_enum(TimeInForce, self.entry_tif))
|
|
61
|
+
object.__setattr__(self, "exit_tif", _coerce_enum(TimeInForce, self.exit_tif))
|
|
62
|
+
if not self.symbol:
|
|
63
|
+
raise ValueError("BracketOrderSpec.symbol is required")
|
|
64
|
+
if self.qty <= 0.0:
|
|
65
|
+
raise ValueError("BracketOrderSpec.qty must be > 0")
|
|
66
|
+
if self.take_profit_price is None and self.stop_loss_price is None:
|
|
67
|
+
raise ValueError("BracketOrderSpec requires take_profit_price or stop_loss_price")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class DcaGridSpec:
|
|
72
|
+
"""
|
|
73
|
+
Deterministic DCA/grid order package.
|
|
74
|
+
|
|
75
|
+
Base entry is a market order. Safety orders are GTC limits at grid prices.
|
|
76
|
+
Optional TP/SL exits are reduce-only OCO siblings sized to the maximum
|
|
77
|
+
planned ladder quantity, which is conservative for validation and auditable
|
|
78
|
+
in ``metadata``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
symbol: str
|
|
82
|
+
entry_timestamp: object
|
|
83
|
+
side: OrderSide
|
|
84
|
+
package_id: str = "DCA-GRID-001"
|
|
85
|
+
base_qty: Optional[float] = None
|
|
86
|
+
base_notional: Optional[float] = None
|
|
87
|
+
entry_price: Optional[float] = None
|
|
88
|
+
safety_order_count: int = 0
|
|
89
|
+
safety_qty: Optional[float] = None
|
|
90
|
+
safety_notional: Optional[float] = None
|
|
91
|
+
step_pct: float = 0.01
|
|
92
|
+
step_scale: float = 1.0
|
|
93
|
+
volume_scale: float = 1.0
|
|
94
|
+
take_profit_pct: Optional[float] = None
|
|
95
|
+
stop_loss_pct: Optional[float] = None
|
|
96
|
+
take_profit_price: Optional[float] = None
|
|
97
|
+
stop_loss_price: Optional[float] = None
|
|
98
|
+
exit_timestamp: Optional[object] = None
|
|
99
|
+
entry_tif: TimeInForce = TimeInForce.IOC
|
|
100
|
+
safety_tif: TimeInForce = TimeInForce.GTC
|
|
101
|
+
exit_tif: TimeInForce = TimeInForce.GTC
|
|
102
|
+
reduce_only_exits: bool = True
|
|
103
|
+
tag: Optional[str] = None
|
|
104
|
+
metadata: Dict = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
object.__setattr__(self, "side", _coerce_enum(OrderSide, self.side))
|
|
108
|
+
object.__setattr__(self, "entry_tif", _coerce_enum(TimeInForce, self.entry_tif))
|
|
109
|
+
object.__setattr__(self, "safety_tif", _coerce_enum(TimeInForce, self.safety_tif))
|
|
110
|
+
object.__setattr__(self, "exit_tif", _coerce_enum(TimeInForce, self.exit_tif))
|
|
111
|
+
if not self.symbol:
|
|
112
|
+
raise ValueError("DcaGridSpec.symbol is required")
|
|
113
|
+
if self.base_qty is None and self.base_notional is None:
|
|
114
|
+
raise ValueError("DcaGridSpec requires base_qty or base_notional")
|
|
115
|
+
if self.base_qty is not None and self.base_qty <= 0.0:
|
|
116
|
+
raise ValueError("DcaGridSpec.base_qty must be > 0")
|
|
117
|
+
if self.base_notional is not None and self.base_notional <= 0.0:
|
|
118
|
+
raise ValueError("DcaGridSpec.base_notional must be > 0")
|
|
119
|
+
if self.safety_order_count < 0:
|
|
120
|
+
raise ValueError("DcaGridSpec.safety_order_count must be >= 0")
|
|
121
|
+
if self.safety_order_count and self.safety_qty is None and self.safety_notional is None:
|
|
122
|
+
raise ValueError("DcaGridSpec safety orders require safety_qty or safety_notional")
|
|
123
|
+
if self.step_pct <= 0.0 or self.step_scale <= 0.0 or self.volume_scale <= 0.0:
|
|
124
|
+
raise ValueError("DCA step_pct, step_scale, and volume_scale must be > 0")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_bracket_order_plan(spec: BracketOrderSpec) -> StructuredOrderPlan:
|
|
128
|
+
ts_entry = _utc_timestamp(spec.entry_timestamp)
|
|
129
|
+
ts_exit = _utc_timestamp(spec.exit_timestamp or spec.entry_timestamp)
|
|
130
|
+
package_id = spec.package_id
|
|
131
|
+
oco_group_id = f"{package_id}:oco"
|
|
132
|
+
tag_prefix = spec.tag or package_id
|
|
133
|
+
|
|
134
|
+
common = {
|
|
135
|
+
"package_id": package_id,
|
|
136
|
+
"package_type": "bracket_oco",
|
|
137
|
+
"structured_type": "bracket_oco",
|
|
138
|
+
"oco_group_id": oco_group_id,
|
|
139
|
+
"oco_policy": "cancel_sibling_on_first_exit_fill",
|
|
140
|
+
}
|
|
141
|
+
entry = OrderIntent(
|
|
142
|
+
timestamp=ts_entry,
|
|
143
|
+
symbol=spec.symbol,
|
|
144
|
+
side=spec.side,
|
|
145
|
+
order_type=spec.entry_order_type,
|
|
146
|
+
qty=float(spec.qty),
|
|
147
|
+
price=spec.entry_price,
|
|
148
|
+
trigger_price=spec.entry_trigger_price,
|
|
149
|
+
tif=spec.entry_tif,
|
|
150
|
+
tag=f"{tag_prefix}:entry",
|
|
151
|
+
metadata={**spec.metadata, **common, "leg_role": "entry"},
|
|
152
|
+
)
|
|
153
|
+
orders = [entry]
|
|
154
|
+
exit_side = _opposite_side(spec.side)
|
|
155
|
+
if spec.take_profit_price is not None:
|
|
156
|
+
orders.append(
|
|
157
|
+
OrderIntent(
|
|
158
|
+
timestamp=ts_exit,
|
|
159
|
+
symbol=spec.symbol,
|
|
160
|
+
side=exit_side,
|
|
161
|
+
order_type=OrderType.LIMIT,
|
|
162
|
+
qty=float(spec.qty),
|
|
163
|
+
price=float(spec.take_profit_price),
|
|
164
|
+
tif=spec.exit_tif,
|
|
165
|
+
reduce_only=spec.reduce_only_exits,
|
|
166
|
+
tag=f"{tag_prefix}:take-profit",
|
|
167
|
+
metadata={**spec.metadata, **common, "leg_role": "take_profit", "parent_tag": entry.tag},
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if spec.stop_loss_price is not None:
|
|
171
|
+
orders.append(
|
|
172
|
+
OrderIntent(
|
|
173
|
+
timestamp=ts_exit,
|
|
174
|
+
symbol=spec.symbol,
|
|
175
|
+
side=exit_side,
|
|
176
|
+
order_type=OrderType.STOP_MARKET,
|
|
177
|
+
qty=float(spec.qty),
|
|
178
|
+
trigger_price=float(spec.stop_loss_price),
|
|
179
|
+
tif=spec.exit_tif,
|
|
180
|
+
reduce_only=spec.reduce_only_exits,
|
|
181
|
+
tag=f"{tag_prefix}:stop-loss",
|
|
182
|
+
metadata={**spec.metadata, **common, "leg_role": "stop_loss", "parent_tag": entry.tag},
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return _structured_plan(package_id, "bracket_oco", orders, metadata={**spec.metadata, **common})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_dca_grid_order_plan(spec: DcaGridSpec, close: pd.Series) -> StructuredOrderPlan:
|
|
189
|
+
close = _prepare_close(close)
|
|
190
|
+
ts_entry = _utc_timestamp(spec.entry_timestamp)
|
|
191
|
+
if ts_entry not in close.index:
|
|
192
|
+
raise ValueError("DCA entry_timestamp must exist in close index")
|
|
193
|
+
entry_price = float(spec.entry_price if spec.entry_price is not None else close.loc[ts_entry])
|
|
194
|
+
if entry_price <= 0.0:
|
|
195
|
+
raise ValueError("DCA entry_price must be > 0")
|
|
196
|
+
|
|
197
|
+
package_id = spec.package_id
|
|
198
|
+
oco_group_id = f"{package_id}:exit-oco"
|
|
199
|
+
tag_prefix = spec.tag or package_id
|
|
200
|
+
base_qty = float(spec.base_qty if spec.base_qty is not None else float(spec.base_notional) / entry_price)
|
|
201
|
+
side_sign = spec.side.sign
|
|
202
|
+
common = {
|
|
203
|
+
"package_id": package_id,
|
|
204
|
+
"package_type": "dca_grid",
|
|
205
|
+
"structured_type": "dca_grid",
|
|
206
|
+
"oco_group_id": oco_group_id,
|
|
207
|
+
"oco_policy": "cancel_sibling_on_first_exit_fill",
|
|
208
|
+
"entry_price_reference": entry_price,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
orders = [
|
|
212
|
+
OrderIntent(
|
|
213
|
+
timestamp=ts_entry,
|
|
214
|
+
symbol=spec.symbol,
|
|
215
|
+
side=spec.side,
|
|
216
|
+
order_type=OrderType.MARKET,
|
|
217
|
+
qty=base_qty,
|
|
218
|
+
tif=spec.entry_tif,
|
|
219
|
+
tag=f"{tag_prefix}:base",
|
|
220
|
+
metadata={
|
|
221
|
+
**spec.metadata,
|
|
222
|
+
**common,
|
|
223
|
+
"leg_role": "base",
|
|
224
|
+
"ladder_level": 1,
|
|
225
|
+
"target_units": side_sign * base_qty,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
total_qty = base_qty
|
|
231
|
+
weighted_cost = entry_price * base_qty
|
|
232
|
+
for safety_index in range(int(spec.safety_order_count)):
|
|
233
|
+
level = safety_index + 2
|
|
234
|
+
deviation = _cumulative_grid_deviation(spec.step_pct, spec.step_scale, safety_index)
|
|
235
|
+
trigger = entry_price * (1.0 - deviation if spec.side is OrderSide.BUY else 1.0 + deviation)
|
|
236
|
+
if trigger <= 0.0:
|
|
237
|
+
raise ValueError("DCA grid trigger price must be > 0")
|
|
238
|
+
qty_base = float(spec.safety_qty if spec.safety_qty is not None else float(spec.safety_notional) / trigger)
|
|
239
|
+
qty = qty_base * (float(spec.volume_scale) ** safety_index)
|
|
240
|
+
total_qty += qty
|
|
241
|
+
weighted_cost += trigger * qty
|
|
242
|
+
orders.append(
|
|
243
|
+
OrderIntent(
|
|
244
|
+
timestamp=ts_entry,
|
|
245
|
+
symbol=spec.symbol,
|
|
246
|
+
side=spec.side,
|
|
247
|
+
order_type=OrderType.LIMIT,
|
|
248
|
+
qty=qty,
|
|
249
|
+
price=trigger,
|
|
250
|
+
tif=spec.safety_tif,
|
|
251
|
+
tag=f"{tag_prefix}:safety-{safety_index + 1}",
|
|
252
|
+
metadata={
|
|
253
|
+
**spec.metadata,
|
|
254
|
+
**common,
|
|
255
|
+
"leg_role": "safety",
|
|
256
|
+
"ladder_level": level,
|
|
257
|
+
"grid_deviation": deviation,
|
|
258
|
+
"target_units": side_sign * total_qty,
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
avg_full_ladder = weighted_cost / total_qty
|
|
264
|
+
ts_exit = _utc_timestamp(spec.exit_timestamp or spec.entry_timestamp)
|
|
265
|
+
exit_side = _opposite_side(spec.side)
|
|
266
|
+
tp_price = spec.take_profit_price
|
|
267
|
+
if tp_price is None and spec.take_profit_pct is not None:
|
|
268
|
+
tp_price = avg_full_ladder * (1.0 + spec.take_profit_pct if spec.side is OrderSide.BUY else 1.0 - spec.take_profit_pct)
|
|
269
|
+
sl_price = spec.stop_loss_price
|
|
270
|
+
if sl_price is None and spec.stop_loss_pct is not None:
|
|
271
|
+
sl_price = entry_price * (1.0 - spec.stop_loss_pct if spec.side is OrderSide.BUY else 1.0 + spec.stop_loss_pct)
|
|
272
|
+
|
|
273
|
+
exit_meta = {
|
|
274
|
+
**spec.metadata,
|
|
275
|
+
**common,
|
|
276
|
+
"exit_quantity_policy": "max_planned_ladder_qty",
|
|
277
|
+
"max_planned_ladder_qty": total_qty,
|
|
278
|
+
"full_ladder_avg_entry": avg_full_ladder,
|
|
279
|
+
}
|
|
280
|
+
if tp_price is not None:
|
|
281
|
+
orders.append(
|
|
282
|
+
OrderIntent(
|
|
283
|
+
timestamp=ts_exit,
|
|
284
|
+
symbol=spec.symbol,
|
|
285
|
+
side=exit_side,
|
|
286
|
+
order_type=OrderType.LIMIT,
|
|
287
|
+
qty=total_qty,
|
|
288
|
+
price=float(tp_price),
|
|
289
|
+
tif=spec.exit_tif,
|
|
290
|
+
reduce_only=spec.reduce_only_exits,
|
|
291
|
+
tag=f"{tag_prefix}:take-profit",
|
|
292
|
+
metadata={**exit_meta, "leg_role": "take_profit"},
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
if sl_price is not None:
|
|
296
|
+
orders.append(
|
|
297
|
+
OrderIntent(
|
|
298
|
+
timestamp=ts_exit,
|
|
299
|
+
symbol=spec.symbol,
|
|
300
|
+
side=exit_side,
|
|
301
|
+
order_type=OrderType.STOP_MARKET,
|
|
302
|
+
qty=total_qty,
|
|
303
|
+
trigger_price=float(sl_price),
|
|
304
|
+
tif=spec.exit_tif,
|
|
305
|
+
reduce_only=spec.reduce_only_exits,
|
|
306
|
+
tag=f"{tag_prefix}:stop-loss",
|
|
307
|
+
metadata={**exit_meta, "leg_role": "stop_loss"},
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return _structured_plan(
|
|
312
|
+
package_id,
|
|
313
|
+
"dca_grid",
|
|
314
|
+
orders,
|
|
315
|
+
metadata={
|
|
316
|
+
**spec.metadata,
|
|
317
|
+
**common,
|
|
318
|
+
"max_planned_ladder_qty": total_qty,
|
|
319
|
+
"full_ladder_avg_entry": avg_full_ladder,
|
|
320
|
+
"safety_order_count": int(spec.safety_order_count),
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _structured_plan(package_id: str, package_type: str, orders: Sequence[OrderIntent], metadata: Dict) -> StructuredOrderPlan:
|
|
326
|
+
table = pd.DataFrame(
|
|
327
|
+
[
|
|
328
|
+
{
|
|
329
|
+
"timestamp": _utc_timestamp(order.timestamp),
|
|
330
|
+
"symbol": order.symbol,
|
|
331
|
+
"side": order.side.value,
|
|
332
|
+
"qty": float(order.qty),
|
|
333
|
+
"order_type": order.order_type.value,
|
|
334
|
+
"price": order.price,
|
|
335
|
+
"trigger_price": order.trigger_price,
|
|
336
|
+
"tif": order.tif.value,
|
|
337
|
+
"reduce_only": bool(order.reduce_only),
|
|
338
|
+
"tag": order.tag,
|
|
339
|
+
"leg_role": order.metadata.get("leg_role"),
|
|
340
|
+
"package_id": order.metadata.get("package_id"),
|
|
341
|
+
"oco_group_id": order.metadata.get("oco_group_id"),
|
|
342
|
+
"ladder_level": order.metadata.get("ladder_level"),
|
|
343
|
+
}
|
|
344
|
+
for order in orders
|
|
345
|
+
]
|
|
346
|
+
)
|
|
347
|
+
return StructuredOrderPlan(package_id=package_id, package_type=package_type, orders=tuple(orders), order_table=table, metadata=metadata)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _cumulative_grid_deviation(step_pct: float, step_scale: float, safety_index: int) -> float:
|
|
351
|
+
deviation = 0.0
|
|
352
|
+
step = float(step_pct)
|
|
353
|
+
for _ in range(safety_index + 1):
|
|
354
|
+
deviation += step
|
|
355
|
+
step *= float(step_scale)
|
|
356
|
+
return deviation
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _prepare_close(close: pd.Series) -> pd.Series:
|
|
360
|
+
out = close.copy()
|
|
361
|
+
out.index = pd.DatetimeIndex(out.index)
|
|
362
|
+
out.index = out.index.tz_localize("UTC") if out.index.tz is None else out.index.tz_convert("UTC")
|
|
363
|
+
return out.sort_index()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _utc_timestamp(value) -> pd.Timestamp:
|
|
367
|
+
ts = pd.Timestamp(value)
|
|
368
|
+
return ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _opposite_side(side: OrderSide) -> OrderSide:
|
|
372
|
+
return OrderSide.SELL if side is OrderSide.BUY else OrderSide.BUY
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _coerce_enum(enum_cls, value):
|
|
376
|
+
if isinstance(value, enum_cls):
|
|
377
|
+
return value
|
|
378
|
+
return enum_cls(str(value).lower().strip())
|