aether-quant 0.0.1__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.
- aether_quant-0.0.1.dist-info/METADATA +7 -0
- aether_quant-0.0.1.dist-info/RECORD +99 -0
- aether_quant-0.0.1.dist-info/WHEEL +5 -0
- aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
- aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
- aether_quant-0.0.1.dist-info/top_level.txt +13 -0
- analyzer/README.md +69 -0
- analyzer/__init__.py +17 -0
- analyzer/market_analyzer.py +521 -0
- aq_cli.py +3633 -0
- audit/README.md +47 -0
- audit/__init__.py +38 -0
- audit/hash_chain.py +52 -0
- audit/postgres_audit.py +61 -0
- audit/postgres_worker.py +402 -0
- audit/redis_queue.py +124 -0
- audit/status_export.py +62 -0
- data_pipeline/README.md +257 -0
- data_pipeline/__init__.py +6 -0
- data_pipeline/bar_synthesis.py +97 -0
- data_pipeline/dividend_backfill.py +243 -0
- data_pipeline/factor_file_backfill.py +276 -0
- data_pipeline/fetch.py +206 -0
- data_pipeline/fred_backfill.py +435 -0
- data_pipeline/ib_backfill.py +287 -0
- data_pipeline/v2_manifest.py +75 -0
- data_pipeline/yfinance_backfill.py +501 -0
- evaluation/README.md +152 -0
- evaluation/__init__.py +73 -0
- evaluation/ablation.py +310 -0
- evaluation/benchmark_comparison.py +195 -0
- evaluation/confidence_threshold_calibration.py +157 -0
- evaluation/factor_neutralization_check.py +94 -0
- evaluation/feature_parity.py +321 -0
- evaluation/feature_reconciliation.py +286 -0
- evaluation/impact_model.py +77 -0
- evaluation/kill_switch_replay.py +186 -0
- evaluation/limit_fill_simulator.py +152 -0
- evaluation/model_predictions.py +192 -0
- evaluation/monte_carlo.py +161 -0
- evaluation/public_benchmarks.py +117 -0
- evaluation/rank_book_simulator.py +494 -0
- evaluation/rank_ic_core.py +204 -0
- evaluation/rank_signal_calibration.py +690 -0
- evaluation/rolling_ic_gate_calibration.py +190 -0
- evaluation/rolling_ic_gate_replay.py +138 -0
- execution/README.md +281 -0
- execution/__init__.py +80 -0
- execution/config_cache.py +48 -0
- execution/cost_model.py +213 -0
- execution/lean_config_render.py +131 -0
- execution/live_credentials.py +38 -0
- execution/live_credentials_io.py +52 -0
- execution/order_gate.py +388 -0
- execution/paper_readiness.py +181 -0
- execution/paper_readiness_io.py +54 -0
- execution/paper_readiness_report.py +103 -0
- execution/paper_readiness_scheduler.py +137 -0
- execution/reconciliation.py +160 -0
- execution/runtime_config_io.py +38 -0
- execution/secret_scan.py +73 -0
- features/README.md +77 -0
- features/__init__.py +137 -0
- features/alt_data_features.py +154 -0
- features/bond_features.py +275 -0
- features/cross_asset_sensitivity.py +79 -0
- features/derivatives_macro_features.py +72 -0
- features/macro_features.py +79 -0
- features/options_greeks.py +329 -0
- features/sector_map.py +53 -0
- features/technical_indicators.py +185 -0
- generate_backtest_report.py +311 -0
- generate_evaluation_report.py +712 -0
- inference/README.md +105 -0
- inference/__init__.py +36 -0
- inference/exported_model.py +929 -0
- inference/parallel_inference.py +160 -0
- inference/strategy_selector_inference.py +75 -0
- json_safety.py +130 -0
- portfolio/README.md +447 -0
- portfolio/__init__.py +41 -0
- portfolio/book_construction.py +786 -0
- portfolio/book_neutrality.py +178 -0
- portfolio/hrp_allocation.py +159 -0
- portfolio/options_arbitrage_detector.py +232 -0
- portfolio/options_assignment_risk.py +119 -0
- portfolio/options_margin_sizing.py +333 -0
- portfolio/options_strategy.py +1999 -0
- portfolio/rank_signal.py +219 -0
- portfolio/rolling_ic_gate.py +183 -0
- risk/README.md +275 -0
- risk/__init__.py +38 -0
- risk/asset_class_router.py +584 -0
- risk/forex_risk.py +158 -0
- risk/futures_risk.py +202 -0
- risk/kill_switch.py +261 -0
- risk/manual_override.py +118 -0
- risk/position_sizing.py +303 -0
- risk/rl_sizing.py +189 -0
execution/order_gate.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Pure, Lean-free order gating for Phase V2-15 (Observation Mode).
|
|
2
|
+
|
|
3
|
+
No imports from AlgorithmImports/QCAlgorithm live here on purpose: this
|
|
4
|
+
module owns the mode -> real-vs-simulated order decision table and must be
|
|
5
|
+
unit-testable without a Lean runtime on sys.path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
VALID_MODES = ("backtest", "observation", "paper", "live")
|
|
13
|
+
DEFAULT_FALLBACK_MODE = "observation"
|
|
14
|
+
|
|
15
|
+
# Default ceiling on the per-fill slippage resolve_fill_slippage() will
|
|
16
|
+
# ever apply, regardless of what liquidity/market_liquidity.py's
|
|
17
|
+
# estimated_round_trip_cost computes. 500bps (5%) is far above anything the
|
|
18
|
+
# estimator produces under normal participation (build_liquidity_decision()
|
|
19
|
+
# already blocks orders at 5% participation, long before slippage_factor's
|
|
20
|
+
# default 0.1 multiplier could get anywhere near this), so this only ever
|
|
21
|
+
# binds as a guard against a degenerate estimate (e.g. a volatility spike on
|
|
22
|
+
# a razor-thin bar) corrupting a fill price - never a normal-path limiter.
|
|
23
|
+
# Overridable via phase_v2.liquidity.fill_slippage.max_bps (main.py reads
|
|
24
|
+
# it in _ensure_ready() and passes it into every call below) - this
|
|
25
|
+
# constant is only the fallback when that key is absent from config.json.
|
|
26
|
+
MAX_LIQUIDITY_SLIPPAGE_BPS = 500.0
|
|
27
|
+
|
|
28
|
+
# phase_v2.liquidity.fill_slippage.source: which LiquidityDecision field
|
|
29
|
+
# main.py converts into fill-slippage bps. "round_trip" (default, see
|
|
30
|
+
# execution/README.md's "Real fill slippage" section for the rationale) =
|
|
31
|
+
# estimated_round_trip_cost (price impact + bid-ask spread combined).
|
|
32
|
+
# "impact_only" = estimated_slippage alone, for isolating/excluding the
|
|
33
|
+
# spread-crossing component.
|
|
34
|
+
VALID_FILL_SLIPPAGE_SOURCES = ("round_trip", "impact_only")
|
|
35
|
+
DEFAULT_FILL_SLIPPAGE_SOURCE = "round_trip"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_runtime_mode(raw_mode: str | None) -> str:
|
|
39
|
+
"""Normalize a config-supplied mode value, failing safe to observation."""
|
|
40
|
+
if isinstance(raw_mode, str) and raw_mode in VALID_MODES:
|
|
41
|
+
return raw_mode
|
|
42
|
+
return DEFAULT_FALLBACK_MODE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_order_permission(
|
|
46
|
+
mode: str,
|
|
47
|
+
allow_live_orders: bool,
|
|
48
|
+
broker_config_present: bool,
|
|
49
|
+
risk_locks_healthy: bool,
|
|
50
|
+
) -> tuple[bool, str]:
|
|
51
|
+
"""Decide whether a real broker order may be placed in the given mode.
|
|
52
|
+
|
|
53
|
+
observation always returns False regardless of the other flags - this is
|
|
54
|
+
the single safety invariant the whole phase depends on.
|
|
55
|
+
"""
|
|
56
|
+
if mode == "backtest":
|
|
57
|
+
return True, "backtest_unrestricted"
|
|
58
|
+
|
|
59
|
+
if mode == "observation":
|
|
60
|
+
return False, "observation_mode_no_real_orders"
|
|
61
|
+
|
|
62
|
+
if mode == "paper":
|
|
63
|
+
if allow_live_orders and broker_config_present and risk_locks_healthy:
|
|
64
|
+
return True, "paper_orders_enabled"
|
|
65
|
+
if allow_live_orders and broker_config_present:
|
|
66
|
+
# V5.4.5 (development/Problems.md #116): paper previously
|
|
67
|
+
# ignored risk_locks_healthy entirely - a tripped kill switch /
|
|
68
|
+
# drawdown lock / manual lock could not stop real broker orders
|
|
69
|
+
# in the one mode that actually reaches a brokerage besides
|
|
70
|
+
# live. Same enforcement point as live below.
|
|
71
|
+
return False, "paper_orders_blocked_risk_lock"
|
|
72
|
+
return False, "paper_orders_blocked_missing_flag_or_broker_config"
|
|
73
|
+
|
|
74
|
+
if mode == "live":
|
|
75
|
+
if allow_live_orders and broker_config_present and risk_locks_healthy:
|
|
76
|
+
return True, "live_orders_enabled"
|
|
77
|
+
return False, "live_orders_blocked_missing_flag_or_broker_config_or_risk_lock"
|
|
78
|
+
|
|
79
|
+
return False, "unknown_mode_defaults_to_no_orders"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def slippage_amount(reference_price: float, slippage_bps: float) -> float:
|
|
83
|
+
"""Absolute per-unit price slippage for a given bps estimate.
|
|
84
|
+
|
|
85
|
+
Shared by main.py's Lean SlippageModel (real backtest/live fills, wired
|
|
86
|
+
via security.SetSlippageModel()) and simulate_fill() below
|
|
87
|
+
(observation-mode simulated fills), so both paths apply the exact same
|
|
88
|
+
bps -> price-impact math instead of two independently-drifting
|
|
89
|
+
formulas. Returns 0.0 for a non-positive or non-finite price/bps -
|
|
90
|
+
slippage is never negative (never improves the fill price), and a NaN
|
|
91
|
+
estimate must never reach a fill price (V5.4.5, Problems.md #117:
|
|
92
|
+
`nan <= 0` is False, so the old guard let NaN straight into
|
|
93
|
+
`price * nan`).
|
|
94
|
+
"""
|
|
95
|
+
if (
|
|
96
|
+
not math.isfinite(reference_price)
|
|
97
|
+
or not math.isfinite(slippage_bps)
|
|
98
|
+
or reference_price <= 0
|
|
99
|
+
or slippage_bps <= 0
|
|
100
|
+
):
|
|
101
|
+
return 0.0
|
|
102
|
+
return float(reference_price) * (float(slippage_bps) / 10_000.0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_fill_slippage_source(raw_source: str | None) -> str:
|
|
106
|
+
"""Normalize a phase_v2.liquidity.fill_slippage.source config value,
|
|
107
|
+
failing safe to DEFAULT_FILL_SLIPPAGE_SOURCE for anything unset/unknown
|
|
108
|
+
- same fail-safe-normalize convention as resolve_runtime_mode() above.
|
|
109
|
+
"""
|
|
110
|
+
if isinstance(raw_source, str) and raw_source in VALID_FILL_SLIPPAGE_SOURCES:
|
|
111
|
+
return raw_source
|
|
112
|
+
return DEFAULT_FILL_SLIPPAGE_SOURCE
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def liquidity_cost_fraction(liquidity_payload: dict, source: str) -> float:
|
|
116
|
+
"""Pick which liquidity/market_liquidity.py::LiquidityDecision field
|
|
117
|
+
(already a fraction, e.g. 0.001 = 10bps) feeds the fill-slippage
|
|
118
|
+
estimate, per resolve_fill_slippage_source()'s normalized source.
|
|
119
|
+
Missing/malformed field -> 0.0, never raises.
|
|
120
|
+
"""
|
|
121
|
+
key = "estimated_slippage" if source == "impact_only" else "estimated_round_trip_cost"
|
|
122
|
+
return float(liquidity_payload.get(key, 0.0) or 0.0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_slippage_bps(
|
|
126
|
+
symbol_key: str,
|
|
127
|
+
slippage_bps_by_symbol: dict,
|
|
128
|
+
max_bps: float = MAX_LIQUIDITY_SLIPPAGE_BPS,
|
|
129
|
+
) -> float:
|
|
130
|
+
"""Pure lookup + clamp: how many bps of slippage to charge symbol_key,
|
|
131
|
+
given the per-symbol bps dict main.py refreshes every bar in
|
|
132
|
+
on_data()'s Pass 2 (liquidity_cost_fraction()'s selected field,
|
|
133
|
+
converted to bps). Shared by resolve_fill_slippage() below (real Lean
|
|
134
|
+
fills) and main.py's observation-mode
|
|
135
|
+
SimulatedPortfolioState.enter_long() call sites, so both paths apply
|
|
136
|
+
the identical clamp.
|
|
137
|
+
|
|
138
|
+
Missing symbol -> 0.0 bps (no liquidity estimate computed yet, e.g. an
|
|
139
|
+
order placed before Pass 2 has run for it once). Clamped to max_bps
|
|
140
|
+
(phase_v2.liquidity.fill_slippage.max_bps, defaulting to
|
|
141
|
+
MAX_LIQUIDITY_SLIPPAGE_BPS) as a guard against a degenerate estimate
|
|
142
|
+
corrupting a fill price. Non-finite estimates degrade to 0.0 (V5.4.5,
|
|
143
|
+
Problems.md #117) - max/min pass NaN through untouched, so the clamp
|
|
144
|
+
alone would not.
|
|
145
|
+
"""
|
|
146
|
+
raw = slippage_bps_by_symbol.get(symbol_key, 0.0) or 0.0
|
|
147
|
+
try:
|
|
148
|
+
bps = float(raw)
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
return 0.0
|
|
151
|
+
if not math.isfinite(bps):
|
|
152
|
+
return 0.0
|
|
153
|
+
return min(max(bps, 0.0), float(max_bps))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_fill_slippage(
|
|
157
|
+
symbol_key: str,
|
|
158
|
+
reference_price: float,
|
|
159
|
+
slippage_bps_by_symbol: dict,
|
|
160
|
+
max_bps: float = MAX_LIQUIDITY_SLIPPAGE_BPS,
|
|
161
|
+
) -> float:
|
|
162
|
+
"""Pure lookup + clamp + apply, used by main.py's real Lean
|
|
163
|
+
SlippageModel (_LiquidityAwareSlippageModel.GetSlippageApproximation)
|
|
164
|
+
to turn the per-symbol bps dict into an absolute fill-price delta."""
|
|
165
|
+
return slippage_amount(reference_price, resolve_slippage_bps(symbol_key, slippage_bps_by_symbol, max_bps))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def simulate_fill(
|
|
169
|
+
close_price: float,
|
|
170
|
+
target_weight: float,
|
|
171
|
+
equity: float,
|
|
172
|
+
slippage_bps: float = 0.0,
|
|
173
|
+
) -> dict:
|
|
174
|
+
"""Pure hypothetical fill-price/quantity/notional math.
|
|
175
|
+
|
|
176
|
+
Quantity/notional are purely a function of the target portfolio weight,
|
|
177
|
+
current equity, and fill price - the same math applies whether the
|
|
178
|
+
caller is entering, exiting (target_weight=0.0), or liquidating.
|
|
179
|
+
"""
|
|
180
|
+
if close_price <= 0:
|
|
181
|
+
return {"fill_price": 0.0, "notional": 0.0, "quantity": 0.0}
|
|
182
|
+
|
|
183
|
+
fill_price = close_price + slippage_amount(close_price, slippage_bps)
|
|
184
|
+
notional = target_weight * equity
|
|
185
|
+
quantity = notional / fill_price
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"fill_price": fill_price,
|
|
189
|
+
"notional": notional,
|
|
190
|
+
"quantity": quantity,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def resolve_limit_price(
|
|
195
|
+
reference_price: float,
|
|
196
|
+
spread_fraction: float,
|
|
197
|
+
is_buy: bool,
|
|
198
|
+
offset_multiplier: float = 1.0,
|
|
199
|
+
) -> float:
|
|
200
|
+
"""Pure limit-price placement for main.py's real limit orders
|
|
201
|
+
(execution/risk realism pass, part 2).
|
|
202
|
+
|
|
203
|
+
Offsets reference_price by half of spread_fraction (already a
|
|
204
|
+
fraction, e.g. 0.001 = 10bps - the same unit family
|
|
205
|
+
liquidity_cost_fraction() above consumes) times offset_multiplier
|
|
206
|
+
(phase_v2.limit_orders.offset_multiplier). Halving matches the
|
|
207
|
+
standard "bid = mid - spread/2, ask = mid + spread/2" convention -
|
|
208
|
+
spread_fraction is a round-trip/full-spread estimate
|
|
209
|
+
(liquidity/market_liquidity.py's spread_proxy), a single-sided limit
|
|
210
|
+
order should only cross half of it.
|
|
211
|
+
|
|
212
|
+
A buy limit sits BELOW reference_price (never overpay above the last
|
|
213
|
+
close); a sell/short limit sits ABOVE it (never undersell below the
|
|
214
|
+
last close). offset_multiplier > 1.0 places a more passive (further
|
|
215
|
+
from reference, lower fill probability, more price improvement if
|
|
216
|
+
filled) limit; < 1.0 places a more aggressive one (closer to/crossing
|
|
217
|
+
reference, higher fill probability, less improvement).
|
|
218
|
+
|
|
219
|
+
Returns reference_price unchanged whenever reference_price or
|
|
220
|
+
spread_fraction is non-positive - never returns a non-positive or
|
|
221
|
+
crossed-through price, matching slippage_amount()'s fail-safe-to-
|
|
222
|
+
neutral convention above.
|
|
223
|
+
"""
|
|
224
|
+
if reference_price <= 0 or spread_fraction <= 0:
|
|
225
|
+
return float(reference_price)
|
|
226
|
+
offset = float(reference_price) * (float(spread_fraction) / 2.0) * float(offset_multiplier)
|
|
227
|
+
return reference_price - offset if is_buy else reference_price + offset
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Pure classification of a Lean OrderStatus enum member's name (or
|
|
231
|
+
# str(status)) into "pending" / "filled" / "canceled" / "unknown" -
|
|
232
|
+
# isolates the one place this pass has to guess at Lean's real OrderStatus
|
|
233
|
+
# spelling (see execution/README.md's "Real limit orders" section for the
|
|
234
|
+
# full casing-risk writeup) into a single small function, so a casing fix
|
|
235
|
+
# is a one-line change here rather than a hunt through main.py's
|
|
236
|
+
# on_order_event()/_process_pending_limit_order_timeouts().
|
|
237
|
+
#
|
|
238
|
+
# V5.2.8 (development/Problems.md #34) - "CancelPending" added after
|
|
239
|
+
# scanning 33 real order-events.json files across backtests/2026-07-*/
|
|
240
|
+
# and backtests/2026-08-*/: Lean's real OrderStatus.CancelPending appears
|
|
241
|
+
# 23 times (e.g. backtests/2026-08-11_09-56-24, paired 1:1 with 23
|
|
242
|
+
# "canceled" records - a genuine unfilled-timeout cancel lifecycle,
|
|
243
|
+
# directly confirmed on individual records, e.g. HYG qty 15 @
|
|
244
|
+
# limitPrice=54.30, fillQuantity=0.0). Previously fell through to
|
|
245
|
+
# "unknown", which every caller already treats identically to "pending" -
|
|
246
|
+
# so this is a precision/self-documentation fix, not a behavior change.
|
|
247
|
+
# PartiallyFilled has never once appeared across those 33 files - a real,
|
|
248
|
+
# permanent, still-unexercised-in-practice caveat, not a gap to close here.
|
|
249
|
+
PENDING_ORDER_STATUS_NAMES = ("New", "Submitted", "PartiallyFilled", "UpdateSubmitted", "CancelPending")
|
|
250
|
+
TERMINAL_FILLED_STATUS_NAMES = ("Filled",)
|
|
251
|
+
TERMINAL_CANCELED_STATUS_NAMES = ("Canceled", "Invalid")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def classify_order_status(status_name: str) -> str:
|
|
255
|
+
"""Pure string classification, never raises on an unrecognized value -
|
|
256
|
+
returns "unknown" instead, which callers treat as still-pending
|
|
257
|
+
(conservative: never mistakes an unrecognized status for a fill)."""
|
|
258
|
+
if status_name in TERMINAL_FILLED_STATUS_NAMES:
|
|
259
|
+
return "filled"
|
|
260
|
+
if status_name in TERMINAL_CANCELED_STATUS_NAMES:
|
|
261
|
+
return "canceled"
|
|
262
|
+
if status_name in PENDING_ORDER_STATUS_NAMES:
|
|
263
|
+
return "pending"
|
|
264
|
+
return "unknown"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# V5.3.1 (development/Problems.md #34/#96) - main.py's on_order_event()/
|
|
268
|
+
# _process_pending_limit_order_timeouts() cannot themselves be unit-tested
|
|
269
|
+
# (main.py imports QCAlgorithm from AlgorithmImports, which only resolves
|
|
270
|
+
# inside a real Lean process - confirmed: `python -c "import main"` raises
|
|
271
|
+
# NameError outside one). These two functions are a pure restatement of
|
|
272
|
+
# those methods' decision logic, extracted so classify_order_status("Partially
|
|
273
|
+
# Filled") == "pending" (already tested above) has a testable path all the
|
|
274
|
+
# way through to "does the pending-order record correctly stay open, and
|
|
275
|
+
# does a subsequent timeout fall back for only the REMAINING quantity" -
|
|
276
|
+
# not just proven at the classification step. main.py calls these two
|
|
277
|
+
# functions instead of inlining the equivalent branches.
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def should_clear_pending_limit_order(status: str, all_legs_filled: bool) -> bool:
|
|
281
|
+
"""Mirrors on_order_event()'s clearing decision: a tracked pending-
|
|
282
|
+
limit-order record should be POPPED (stop tracking it) only when it's
|
|
283
|
+
genuinely resolved - "filled" (and only once every leg of a multi-leg
|
|
284
|
+
combo has filled, not on the first leg alone) or "canceled". Every
|
|
285
|
+
other status - "pending", "unknown", and critically "PartiallyFilled"
|
|
286
|
+
(which classify_order_status() maps to "pending") - returns False, so
|
|
287
|
+
a partial fill leaves the record tracked for
|
|
288
|
+
resolve_limit_order_timeout_action() to eventually resolve, exactly as
|
|
289
|
+
main.py's own on_order_event() docstring already documents.
|
|
290
|
+
|
|
291
|
+
`all_legs_filled` is irrelevant (never even inspected) unless
|
|
292
|
+
status == "filled" - a "canceled" leg always clears regardless of the
|
|
293
|
+
other legs' state, matching main.py's existing elif branch."""
|
|
294
|
+
if status == "filled":
|
|
295
|
+
return all_legs_filled
|
|
296
|
+
return status == "canceled"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def resolve_limit_order_timeout_action(
|
|
300
|
+
status: str,
|
|
301
|
+
quantity_remaining: float,
|
|
302
|
+
fallback_enabled: bool,
|
|
303
|
+
) -> dict:
|
|
304
|
+
"""Mirrors _process_pending_limit_order_timeouts()'s per-leg branch
|
|
305
|
+
once a tracked order has sat unfilled for
|
|
306
|
+
phase_v2.limit_orders.unfilled_timeout_bars bars: an already-resolved
|
|
307
|
+
leg ("filled"/"canceled" - main.py's own comment: resolved by
|
|
308
|
+
on_order_event() this bar or an earlier one) needs no action here.
|
|
309
|
+
Any other status - "pending", "unknown", or "PartiallyFilled" (which
|
|
310
|
+
classify_order_status() maps to "pending") - gets canceled, with a
|
|
311
|
+
market-order fallback for exactly `quantity_remaining` (never the
|
|
312
|
+
order's original size, which this function deliberately never
|
|
313
|
+
receives - a partial fill's leftover quantity is structurally the
|
|
314
|
+
only number that can ever be returned here) when fallback_enabled and
|
|
315
|
+
there IS a nonzero remainder.
|
|
316
|
+
|
|
317
|
+
Returns {"should_cancel": bool, "fallback_market_quantity": float | None}.
|
|
318
|
+
"""
|
|
319
|
+
if status in ("filled", "canceled"):
|
|
320
|
+
return {"should_cancel": False, "fallback_market_quantity": None}
|
|
321
|
+
fallback_market_quantity = quantity_remaining if (fallback_enabled and quantity_remaining != 0) else None
|
|
322
|
+
return {"should_cancel": True, "fallback_market_quantity": fallback_market_quantity}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Pure classification of main.py::_apply_signal()/_apply_option_order()'s
|
|
326
|
+
# execution_note return strings into "was a REAL order actually placed" -
|
|
327
|
+
# used by the audit-log hook (development/Problems.md #42) at its single
|
|
328
|
+
# call site in on_data()'s Pass 2 loop, rather than scattering a push call
|
|
329
|
+
# across every one of _apply_signal()'s many branches/asset-class routings
|
|
330
|
+
# (equity/futures/option x buy/sell/short, each with its own f-string
|
|
331
|
+
# execution_note). An explicit denylist, not a broader heuristic, so a new
|
|
332
|
+
# no-op reason added to main.py in the future fails safe (gets audited as
|
|
333
|
+
# "real" and is easy to notice/fix) rather than silently never being
|
|
334
|
+
# audited if it happened to match a loose pattern.
|
|
335
|
+
_NO_OP_EXECUTION_NOTES = frozenset(
|
|
336
|
+
{
|
|
337
|
+
"kept_long",
|
|
338
|
+
"kept_short",
|
|
339
|
+
"already_flat",
|
|
340
|
+
"cooldown_active",
|
|
341
|
+
"no_action",
|
|
342
|
+
"max_active_positions_reached",
|
|
343
|
+
"futures_zero_contract_count",
|
|
344
|
+
"options_no_usable_contract",
|
|
345
|
+
"options_multi_leg_no_usable_legs",
|
|
346
|
+
# V4.3.0 - allow adding to an existing position (development/
|
|
347
|
+
# Changelog.md). Plain constants only, never f-strings - this
|
|
348
|
+
# denylist is exact-string matching, and an f-string note would
|
|
349
|
+
# silently misclassify as a REAL placement, the wrong-direction
|
|
350
|
+
# failure mode this module's own docstring warns about.
|
|
351
|
+
"kept_long_futures",
|
|
352
|
+
"kept_short_futures",
|
|
353
|
+
"futures_zero_delta_kept",
|
|
354
|
+
"options_kept",
|
|
355
|
+
"options_contract_drifted_kept",
|
|
356
|
+
"options_multi_leg_kept",
|
|
357
|
+
"options_multi_leg_mismatch_kept",
|
|
358
|
+
# V4.4 - architecturally-sound options (development/Problems.md):
|
|
359
|
+
# single-leg/spread scale-down replaced "options_zero_or_negative_
|
|
360
|
+
# delta_kept"/"options_spread_shrink_unsupported" (both retired -
|
|
361
|
+
# a negative delta is now a REAL reduce order, never a no-op) with
|
|
362
|
+
# a delta==0-only no-op; the multi-position book adds two more.
|
|
363
|
+
"options_zero_delta_kept",
|
|
364
|
+
"options_at_position_cap_kept",
|
|
365
|
+
"options_held_contract_not_in_chain_kept",
|
|
366
|
+
# V4.5 (full OptionStrategies coverage, §9.1): the at-cap re-price
|
|
367
|
+
# search is scoped to records sharing this bar's strategy_name -
|
|
368
|
+
# when no held record is a comparable shape, there's nothing
|
|
369
|
+
# sensible to re-price, a distinct no-op from every "kept" reason
|
|
370
|
+
# above (renamed from V4.4's "spread"-suffixed strings - "spread"
|
|
371
|
+
# retired as a record kind, folded into "multi_leg", see main.py).
|
|
372
|
+
"options_no_comparable_position_to_reprice_kept",
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
_NO_OP_EXECUTION_NOTE_SUFFIXES = ("_exposure_cap_reached",)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def is_real_order_placement(execution_note: str, orders_allowed: bool) -> bool:
|
|
379
|
+
"""True only for a genuinely-placed real order - never a simulated/
|
|
380
|
+
observation-mode fill (execution_note prefixed "simulated_") and never
|
|
381
|
+
one of the known no-op/blocked outcomes above."""
|
|
382
|
+
if not orders_allowed or execution_note.startswith("simulated_"):
|
|
383
|
+
return False
|
|
384
|
+
if execution_note in _NO_OP_EXECUTION_NOTES:
|
|
385
|
+
return False
|
|
386
|
+
if execution_note.endswith(_NO_OP_EXECUTION_NOTE_SUFFIXES):
|
|
387
|
+
return False
|
|
388
|
+
return True
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Pure, Lean-free paper/live broker-readiness gating (Phase V2-21/V2-22).
|
|
2
|
+
|
|
3
|
+
`execution/order_gate.py::resolve_order_permission()` already implements the
|
|
4
|
+
correct mode -> real-vs-simulated decision table, but the `broker_config_present`
|
|
5
|
+
flag it depends on used to be `bool(self.paper_brokerage)` in `main.py` - a
|
|
6
|
+
string that is never empty by construction, so the check was a no-op. This
|
|
7
|
+
module is what actually decides whether that flag should be True, for both
|
|
8
|
+
`paper` mode (Lean's built-in PaperBrokerage, no real credentials needed) and
|
|
9
|
+
`live` mode (V2-22, real broker credentials).
|
|
10
|
+
|
|
11
|
+
No imports from AlgorithmImports/QCAlgorithm/psycopg live here on purpose -
|
|
12
|
+
see execution/paper_readiness_io.py for the IO layer that feeds these
|
|
13
|
+
functions, and execution/paper_readiness_report.py for the offline report
|
|
14
|
+
that additionally evaluates observation-mode readiness.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
_PAPER_REASON_ALL_CONFIRMED = "paper_broker_config_confirmed"
|
|
20
|
+
_PAPER_REASON_MISSING_BROKERAGE = "paper_broker_config_missing_brokerage"
|
|
21
|
+
_PAPER_REASON_MISSING_DATA_PROVIDER = "paper_broker_config_missing_live_data_provider"
|
|
22
|
+
_PAPER_REASON_MISSING_MANUAL_REVIEW = "paper_broker_config_missing_manual_review"
|
|
23
|
+
|
|
24
|
+
_LIVE_REASON_ALL_CONFIRMED = "live_broker_config_confirmed"
|
|
25
|
+
_LIVE_REASON_PAPER_NOT_READY = "live_broker_config_paper_corridor_not_ready"
|
|
26
|
+
_LIVE_REASON_MISSING_CREDENTIALS = "live_broker_config_missing_credentials"
|
|
27
|
+
_LIVE_REASON_RISK_POSTURE_UNSAFE = "live_broker_config_risk_posture_unsafe"
|
|
28
|
+
_LIVE_REASON_UNSAFE_DB_PASSWORD = "live_broker_config_unsafe_db_password"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def evaluate_paper_broker_config(paper_trading_config: dict) -> tuple[bool, str]:
|
|
32
|
+
"""mode='paper' gate. All three must be true:
|
|
33
|
+
- brokerage is a non-empty known value
|
|
34
|
+
- live_data_provider_configured (human-set attestation - a live market
|
|
35
|
+
data feed is required for Lean's live-paper environment even though
|
|
36
|
+
PaperBrokerage itself needs no broker credentials)
|
|
37
|
+
- manual_review_confirmed (human-set attestation, replaces the old
|
|
38
|
+
phase6.paper_trading.ready_for_live_paper stub)
|
|
39
|
+
"""
|
|
40
|
+
if not str(paper_trading_config.get("brokerage", "")).strip():
|
|
41
|
+
return False, _PAPER_REASON_MISSING_BROKERAGE
|
|
42
|
+
if not bool(paper_trading_config.get("live_data_provider_configured", False)):
|
|
43
|
+
return False, _PAPER_REASON_MISSING_DATA_PROVIDER
|
|
44
|
+
if not bool(paper_trading_config.get("manual_review_confirmed", False)):
|
|
45
|
+
return False, _PAPER_REASON_MISSING_MANUAL_REVIEW
|
|
46
|
+
return True, _PAPER_REASON_ALL_CONFIRMED
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def evaluate_live_risk_posture(risk_config: dict, live_config: dict) -> tuple[bool, str]:
|
|
50
|
+
"""Sanity ceiling for mode='live' - not a redesign of the drawdown
|
|
51
|
+
circuit breaker, just a guard against live mode running with an
|
|
52
|
+
accidentally-loose (or disabled) risk config that was fine for
|
|
53
|
+
paper/observation but is unacceptable once real capital is at risk."""
|
|
54
|
+
max_daily_drawdown_pct = float(risk_config.get("max_daily_drawdown_pct", 1.0))
|
|
55
|
+
max_total_drawdown_pct = float(risk_config.get("max_total_drawdown_pct", 1.0))
|
|
56
|
+
liquidate_on_risk_breach = bool(risk_config.get("liquidate_on_risk_breach", False))
|
|
57
|
+
|
|
58
|
+
allowed_daily = float(live_config.get("max_allowed_daily_drawdown_pct", 0.0))
|
|
59
|
+
allowed_total = float(live_config.get("max_allowed_total_drawdown_pct", 0.0))
|
|
60
|
+
|
|
61
|
+
if not liquidate_on_risk_breach:
|
|
62
|
+
return False, _LIVE_REASON_RISK_POSTURE_UNSAFE
|
|
63
|
+
if max_daily_drawdown_pct > allowed_daily:
|
|
64
|
+
return False, _LIVE_REASON_RISK_POSTURE_UNSAFE
|
|
65
|
+
if max_total_drawdown_pct > allowed_total:
|
|
66
|
+
return False, _LIVE_REASON_RISK_POSTURE_UNSAFE
|
|
67
|
+
return True, _LIVE_REASON_ALL_CONFIRMED
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def evaluate_live_broker_config(
|
|
71
|
+
paper_trading_config: dict,
|
|
72
|
+
live_credentials_present: bool,
|
|
73
|
+
risk_config: dict | None = None,
|
|
74
|
+
live_config: dict | None = None,
|
|
75
|
+
postgres_dsn: str | None = None,
|
|
76
|
+
) -> tuple[bool, str]:
|
|
77
|
+
"""mode='live' gate (V2-22). You cannot go straight to live without a
|
|
78
|
+
validated paper corridor - the paper check must also pass - AND real
|
|
79
|
+
broker credentials must be present AND (if risk/live config supplied)
|
|
80
|
+
the live risk posture must be safe AND (if a Postgres DSN is supplied) it
|
|
81
|
+
must not still use the published dev-default password.
|
|
82
|
+
|
|
83
|
+
`postgres_dsn` is optional and defaults to None (unchecked) so existing
|
|
84
|
+
callers/tests are unaffected; main.py threads the real DSN through so a
|
|
85
|
+
live run against the public default password fails closed."""
|
|
86
|
+
paper_ready, paper_reason = evaluate_paper_broker_config(paper_trading_config)
|
|
87
|
+
if not paper_ready:
|
|
88
|
+
return False, _LIVE_REASON_PAPER_NOT_READY
|
|
89
|
+
|
|
90
|
+
if not live_credentials_present:
|
|
91
|
+
return False, _LIVE_REASON_MISSING_CREDENTIALS
|
|
92
|
+
|
|
93
|
+
if postgres_dsn is not None:
|
|
94
|
+
from execution.live_credentials import postgres_dsn_is_live_safe
|
|
95
|
+
|
|
96
|
+
if not postgres_dsn_is_live_safe(postgres_dsn):
|
|
97
|
+
return False, _LIVE_REASON_UNSAFE_DB_PASSWORD
|
|
98
|
+
|
|
99
|
+
if risk_config is not None and live_config is not None:
|
|
100
|
+
risk_ok, _ = evaluate_live_risk_posture(risk_config, live_config)
|
|
101
|
+
if not risk_ok:
|
|
102
|
+
return False, _LIVE_REASON_RISK_POSTURE_UNSAFE
|
|
103
|
+
|
|
104
|
+
return True, _LIVE_REASON_ALL_CONFIRMED
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def evaluate_broker_config(
|
|
108
|
+
mode: str,
|
|
109
|
+
paper_trading_config: dict,
|
|
110
|
+
live_credentials_present: bool = False,
|
|
111
|
+
risk_config: dict | None = None,
|
|
112
|
+
live_config: dict | None = None,
|
|
113
|
+
postgres_dsn: str | None = None,
|
|
114
|
+
) -> tuple[bool, str]:
|
|
115
|
+
"""Single entrypoint main.py._order_permission() calls regardless of
|
|
116
|
+
mode - dispatches to the two functions above. Any mode other than
|
|
117
|
+
'live' (including 'backtest'/'observation', where the result is unused
|
|
118
|
+
since resolve_order_permission() never consults broker_config_present
|
|
119
|
+
for those modes) goes through the paper path."""
|
|
120
|
+
if mode == "live":
|
|
121
|
+
return evaluate_live_broker_config(
|
|
122
|
+
paper_trading_config,
|
|
123
|
+
live_credentials_present,
|
|
124
|
+
risk_config,
|
|
125
|
+
live_config,
|
|
126
|
+
postgres_dsn,
|
|
127
|
+
)
|
|
128
|
+
return evaluate_paper_broker_config(paper_trading_config)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def evaluate_observation_readiness(summary: dict, thresholds: dict) -> dict:
|
|
132
|
+
"""Turns 4 of the 5 bullets in development/infrastructure.md's "Bereit
|
|
133
|
+
fuer Paper Trading?" checklist into code, given an already-computed
|
|
134
|
+
observation summary (experience.observation_metrics.compute_observation_summary()
|
|
135
|
+
output) and threshold config. The 5th bullet (manual review of trade
|
|
136
|
+
history for plausible entry/exit prices) is deliberately NOT automated -
|
|
137
|
+
it stays a human judgment call, surfaced via paper_trading_config's
|
|
138
|
+
manual_review_confirmed flag (see evaluate_paper_broker_config above).
|
|
139
|
+
"""
|
|
140
|
+
min_observations = int(thresholds.get("min_observations", 500))
|
|
141
|
+
min_simulated_sharpe = float(thresholds.get("min_simulated_sharpe", 0.3))
|
|
142
|
+
max_simulated_drawdown_floor = float(thresholds.get("max_simulated_drawdown_floor", -0.15))
|
|
143
|
+
max_single_rejection_reason_share = float(thresholds.get("max_single_rejection_reason_share", 0.5))
|
|
144
|
+
|
|
145
|
+
count_observations = int(summary.get("count_observations", 0))
|
|
146
|
+
simulated_sharpe = float(summary.get("simulated_sharpe", 0.0))
|
|
147
|
+
simulated_max_drawdown = float(summary.get("simulated_max_drawdown", 0.0))
|
|
148
|
+
rejected_by_reason = summary.get("rejected_by_reason") or {}
|
|
149
|
+
total_rejections = sum(rejected_by_reason.values())
|
|
150
|
+
top_rejection_share = (max(rejected_by_reason.values()) / total_rejections) if total_rejections > 0 else 0.0
|
|
151
|
+
|
|
152
|
+
checks = {
|
|
153
|
+
"observation_count": {
|
|
154
|
+
"pass": count_observations >= min_observations,
|
|
155
|
+
"value": count_observations,
|
|
156
|
+
"threshold": min_observations,
|
|
157
|
+
},
|
|
158
|
+
"simulated_sharpe": {
|
|
159
|
+
"pass": simulated_sharpe >= min_simulated_sharpe,
|
|
160
|
+
"value": simulated_sharpe,
|
|
161
|
+
"threshold": min_simulated_sharpe,
|
|
162
|
+
},
|
|
163
|
+
"simulated_max_drawdown": {
|
|
164
|
+
"pass": simulated_max_drawdown >= max_simulated_drawdown_floor,
|
|
165
|
+
"value": simulated_max_drawdown,
|
|
166
|
+
"threshold": max_simulated_drawdown_floor,
|
|
167
|
+
},
|
|
168
|
+
"dominant_rejection_reason": {
|
|
169
|
+
"pass": top_rejection_share <= max_single_rejection_reason_share,
|
|
170
|
+
"value": top_rejection_share,
|
|
171
|
+
"threshold": max_single_rejection_reason_share,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
blocking_reasons = [name for name, check in checks.items() if not check["pass"]]
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"ready": len(blocking_reasons) == 0,
|
|
179
|
+
"checks": checks,
|
|
180
|
+
"blocking_reasons": blocking_reasons,
|
|
181
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""IO layer for paper/live broker readiness (Phase V2-21/V2-22).
|
|
2
|
+
|
|
3
|
+
Mirrors risk/manual_override.py's read pattern: a single-key, mtime-gated
|
|
4
|
+
config.json reader (see execution/config_cache.py) so a long-running
|
|
5
|
+
paper/live process can pick up a config edit at the next session rollover
|
|
6
|
+
without a restart. Also provides the one new Postgres query
|
|
7
|
+
paper_readiness_report.py needs - observation-mode experience_events, which
|
|
8
|
+
performance/postgres_triggers.py's existing fetch_recent_events()/
|
|
9
|
+
fetch_events_since() don't filter by mode.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from execution.config_cache import read_cached
|
|
19
|
+
|
|
20
|
+
_PHASE_V2_KEY = "phase_v2"
|
|
21
|
+
_PAPER_TRADING_KEY = "paper_trading"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_paper_trading_config(config_path: Path) -> dict:
|
|
25
|
+
"""Mtime-gated read of phase_v2.paper_trading. Returns {} if the file or
|
|
26
|
+
key is absent - never raises."""
|
|
27
|
+
return read_cached(config_path, _read_paper_trading_config_uncached)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_paper_trading_config_uncached(config_path: Path) -> dict:
|
|
31
|
+
if not config_path.exists():
|
|
32
|
+
return {}
|
|
33
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
34
|
+
config = json.load(f)
|
|
35
|
+
value = config.get(_PHASE_V2_KEY, {}).get(_PAPER_TRADING_KEY, {})
|
|
36
|
+
return value if isinstance(value, dict) else {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_observation_mode_events(conn, limit: int = 10_000, since: datetime | None = None) -> list[dict]:
|
|
40
|
+
"""Fetch experience_events payloads logged in mode='observation', oldest
|
|
41
|
+
first - feeds evaluate_observation_readiness() via
|
|
42
|
+
experience.observation_metrics.compute_observation_summary()."""
|
|
43
|
+
if since is None:
|
|
44
|
+
since = datetime.fromtimestamp(0, tz=timezone.utc)
|
|
45
|
+
|
|
46
|
+
with conn.cursor() as cur:
|
|
47
|
+
cur.execute(
|
|
48
|
+
"SELECT payload FROM experience_events WHERE mode = %(mode)s AND created_at > %(since)s "
|
|
49
|
+
"ORDER BY created_at ASC LIMIT %(limit)s;",
|
|
50
|
+
{"mode": "observation", "since": since, "limit": limit},
|
|
51
|
+
)
|
|
52
|
+
rows = cur.fetchall()
|
|
53
|
+
|
|
54
|
+
return [json.loads(row[0]) if isinstance(row[0], str) else row[0] for row in rows]
|