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.
Files changed (99) hide show
  1. aether_quant-0.0.1.dist-info/METADATA +7 -0
  2. aether_quant-0.0.1.dist-info/RECORD +99 -0
  3. aether_quant-0.0.1.dist-info/WHEEL +5 -0
  4. aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
  5. aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
  6. aether_quant-0.0.1.dist-info/top_level.txt +13 -0
  7. analyzer/README.md +69 -0
  8. analyzer/__init__.py +17 -0
  9. analyzer/market_analyzer.py +521 -0
  10. aq_cli.py +3633 -0
  11. audit/README.md +47 -0
  12. audit/__init__.py +38 -0
  13. audit/hash_chain.py +52 -0
  14. audit/postgres_audit.py +61 -0
  15. audit/postgres_worker.py +402 -0
  16. audit/redis_queue.py +124 -0
  17. audit/status_export.py +62 -0
  18. data_pipeline/README.md +257 -0
  19. data_pipeline/__init__.py +6 -0
  20. data_pipeline/bar_synthesis.py +97 -0
  21. data_pipeline/dividend_backfill.py +243 -0
  22. data_pipeline/factor_file_backfill.py +276 -0
  23. data_pipeline/fetch.py +206 -0
  24. data_pipeline/fred_backfill.py +435 -0
  25. data_pipeline/ib_backfill.py +287 -0
  26. data_pipeline/v2_manifest.py +75 -0
  27. data_pipeline/yfinance_backfill.py +501 -0
  28. evaluation/README.md +152 -0
  29. evaluation/__init__.py +73 -0
  30. evaluation/ablation.py +310 -0
  31. evaluation/benchmark_comparison.py +195 -0
  32. evaluation/confidence_threshold_calibration.py +157 -0
  33. evaluation/factor_neutralization_check.py +94 -0
  34. evaluation/feature_parity.py +321 -0
  35. evaluation/feature_reconciliation.py +286 -0
  36. evaluation/impact_model.py +77 -0
  37. evaluation/kill_switch_replay.py +186 -0
  38. evaluation/limit_fill_simulator.py +152 -0
  39. evaluation/model_predictions.py +192 -0
  40. evaluation/monte_carlo.py +161 -0
  41. evaluation/public_benchmarks.py +117 -0
  42. evaluation/rank_book_simulator.py +494 -0
  43. evaluation/rank_ic_core.py +204 -0
  44. evaluation/rank_signal_calibration.py +690 -0
  45. evaluation/rolling_ic_gate_calibration.py +190 -0
  46. evaluation/rolling_ic_gate_replay.py +138 -0
  47. execution/README.md +281 -0
  48. execution/__init__.py +80 -0
  49. execution/config_cache.py +48 -0
  50. execution/cost_model.py +213 -0
  51. execution/lean_config_render.py +131 -0
  52. execution/live_credentials.py +38 -0
  53. execution/live_credentials_io.py +52 -0
  54. execution/order_gate.py +388 -0
  55. execution/paper_readiness.py +181 -0
  56. execution/paper_readiness_io.py +54 -0
  57. execution/paper_readiness_report.py +103 -0
  58. execution/paper_readiness_scheduler.py +137 -0
  59. execution/reconciliation.py +160 -0
  60. execution/runtime_config_io.py +38 -0
  61. execution/secret_scan.py +73 -0
  62. features/README.md +77 -0
  63. features/__init__.py +137 -0
  64. features/alt_data_features.py +154 -0
  65. features/bond_features.py +275 -0
  66. features/cross_asset_sensitivity.py +79 -0
  67. features/derivatives_macro_features.py +72 -0
  68. features/macro_features.py +79 -0
  69. features/options_greeks.py +329 -0
  70. features/sector_map.py +53 -0
  71. features/technical_indicators.py +185 -0
  72. generate_backtest_report.py +311 -0
  73. generate_evaluation_report.py +712 -0
  74. inference/README.md +105 -0
  75. inference/__init__.py +36 -0
  76. inference/exported_model.py +929 -0
  77. inference/parallel_inference.py +160 -0
  78. inference/strategy_selector_inference.py +75 -0
  79. json_safety.py +130 -0
  80. portfolio/README.md +447 -0
  81. portfolio/__init__.py +41 -0
  82. portfolio/book_construction.py +786 -0
  83. portfolio/book_neutrality.py +178 -0
  84. portfolio/hrp_allocation.py +159 -0
  85. portfolio/options_arbitrage_detector.py +232 -0
  86. portfolio/options_assignment_risk.py +119 -0
  87. portfolio/options_margin_sizing.py +333 -0
  88. portfolio/options_strategy.py +1999 -0
  89. portfolio/rank_signal.py +219 -0
  90. portfolio/rolling_ic_gate.py +183 -0
  91. risk/README.md +275 -0
  92. risk/__init__.py +38 -0
  93. risk/asset_class_router.py +584 -0
  94. risk/forex_risk.py +158 -0
  95. risk/futures_risk.py +202 -0
  96. risk/kill_switch.py +261 -0
  97. risk/manual_override.py +118 -0
  98. risk/position_sizing.py +303 -0
  99. risk/rl_sizing.py +189 -0
@@ -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]