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.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. 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())