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
portfolio/README.md ADDED
@@ -0,0 +1,447 @@
1
+ # portfolio
2
+
3
+ Owns Stage-2 cross-sectional long/short book construction (Phase 3 of the
4
+ 5/10 -> 9/10 roadmap) — the first use of the `rank_20d` signal that decides
5
+ *which direction* a symbol trades, not just how large an already-decided
6
+ trade should be.
7
+
8
+ ## Why this is its own package, not `risk/` or `analyzer/`
9
+
10
+ Every prior integration of `rank_20d` into a trading decision (see
11
+ `risk/README.md`'s "Cross-sectional rank_20d → position sizing" section)
12
+ was deliberately **direction-preserving**: `rank_sizing_multiplier()` can
13
+ only scale an already-decided position's magnitude, never flip its sign.
14
+ Book construction is structurally different — it needs to decide, from
15
+ scratch, *which direction* each symbol trades, based on how its predicted
16
+ rank compares to the rest of the universe that bar. That doesn't fit
17
+ either existing home:
18
+
19
+ - **Not `risk/position_sizing.py`**: that module's functions are pure,
20
+ single-symbol calculations by design — they have no way to see every
21
+ other symbol's `rank_20d` for the same bar, which book construction
22
+ fundamentally needs (rank the whole universe, then pick a top-N/bottom-N
23
+ split).
24
+ - **Not `analyzer/market_analyzer.py`**: that module's per-symbol
25
+ `trade`/`simulate`/`observe`/`reduce_risk` categorization stays
26
+ deterministic and per-symbol by design (see `analyzer/README.md`). A
27
+ book-selected symbol still passes through that exact same
28
+ categorization afterward, unchanged — this package only decides the
29
+ symbol's *role* (long/short) before that point, it never bypasses the
30
+ analyzer's safety tiers.
31
+
32
+ ## `book_construction.py::build_rank_based_book()`
33
+
34
+ Pure function, no Lean/torch dependency (same convention as
35
+ `risk/position_sizing.py`). Takes `book_candidates: dict[symbol, {...}]` —
36
+ one entry per symbol with `predicted_rank_20d` and `trading_eligible`,
37
+ collected by `main.py::on_data()`'s Pass 1 (see below) — and `top_n`/
38
+ `bottom_n`/`min_rank_confidence_spread`, returning a `dict[symbol,
39
+ BookAllocation]` covering **only** the symbols the book actively wants
40
+ long or short. A symbol absent from the returned dict means "the book has
41
+ no view on this symbol," not "the book says flat" — those symbols simply
42
+ fall through to whatever the existing (non-book) signal pipeline would
43
+ have decided anyway.
44
+
45
+ - **Discrete top-N/bottom-N selection**, not continuous rank-weighting —
46
+ shipped first because it's simpler to reason about and test.
47
+ Rank-weighted continuous sizing is a documented future extension, not
48
+ built here.
49
+ - **`min_rank_confidence_spread`**: a floor on `(mean long-side rank -
50
+ mean short-side rank)` before the book engages at all. On a day where
51
+ the universe's predicted ranks are all clustered near 0.5 (no real
52
+ cross-sectional dispersion), forcing a long/short split would be noise,
53
+ not signal — the book disengages entirely (`{}`) rather than trading a
54
+ meaningless split.
55
+ - **Observation-only exclusion is automatic**: a candidate is eligible
56
+ only if `trading_eligible` is true, exactly the same
57
+ `phase9.asset_quality`-gated flag every other trading decision already
58
+ respects — observation-only assets can never be book candidates.
59
+ - **Graceful degradation**: `top_n`/`bottom_n` exceeding the number of
60
+ eligible candidates degrades to however many are actually available
61
+ (never raises), and a book that can't form both a long AND a short side
62
+ returns `{}` rather than a one-sided book (not attempted in this pass).
63
+
64
+ ## The one deliberate departure from the "never flips direction" convention
65
+
66
+ `BookAllocation.book_role_multiplier` (+1.0 long, -1.0 short) **sets**
67
+ direction, unlike every existing sizing multiplier
68
+ (`topology_sizing_multiplier()`, `rank_sizing_multiplier()`), which only
69
+ ever scale magnitude. This is intentional and necessary — a cross-sectional
70
+ book's entire purpose is deciding direction from relative rank, not
71
+ confirming a direction some other signal already picked.
72
+
73
+ Short-selling did not exist anywhere in this codebase before this phase
74
+ (`phase5.backtest.strategy_mode: "long_flat"` was the ceiling everywhere
75
+ else). Making it real required two small, additive changes outside this
76
+ package:
77
+
78
+ - `main.py::_apply_signal()` gained a new `signal_name == "short"` branch
79
+ (parallel to the existing `"buy"`/`"sell"` branches), calling
80
+ `self.SetHoldings(symbol, target_weight)` with a genuinely negative
81
+ `target_weight` — Lean's `SetHoldings()` already opens a short position
82
+ for a negative percentage; the branch that actually *did* this never
83
+ existed before (the existing `"sell"` branch only ever liquidates to
84
+ flat, ignoring `target_weight`'s sign entirely). A new
85
+ `max_short_exposure` cap (`phase9.portfolio.max_short_exposure`, default
86
+ `0.30`) bounds it, since nothing bounded short exposure before.
87
+ `experience/simulated_portfolio.py::enter_long()` (despite its name)
88
+ was already sign-generic — `simulate_fill()`'s `notional = target_weight
89
+ * equity` math works unchanged for a negative weight — so the
90
+ observation-mode path needed no new method, just reuse.
91
+ - `analyzer/market_analyzer.py::build_market_analysis_decision()`'s six
92
+ `signal_name in {"buy", "sell"}` safety-tier checks (trade-lock,
93
+ risk-off, elevated topology, liquidity block/thin-market, confidence
94
+ threshold) now also include `"short"` — a real gap caught during
95
+ implementation: without this, a book-selected short would have silently
96
+ bypassed every one of those safety tiers, since none of them recognized
97
+ the new signal name. A book-selected symbol now passes through the
98
+ *exact same* deterministic categorization as any other signal, never
99
+ bypassing it — see that function's own updated docstring note.
100
+
101
+ ## `main.py::on_data()`'s two-pass restructuring
102
+
103
+ A cross-sectional book needs every symbol's `rank_20d` prediction for the
104
+ current bar before it can decide *any* single symbol's role — that
105
+ information doesn't exist until every symbol's inference has run once.
106
+ `on_data()` was restructured into two passes to make this possible:
107
+
108
+ - **Pass 1**: the existing per-symbol feature-build + inference chain
109
+ (baseline/sequence/experts/multitask/gating), through the existing
110
+ `predicted_rank_20d` resolution (see `risk/README.md`) and the
111
+ pre-book `_derive_signal()` call — collected into `book_candidates` and
112
+ a per-symbol state dict, not acted on yet.
113
+ - **Between passes**: `build_rank_based_book()` runs once, given every
114
+ symbol's `book_candidates` entry for this bar.
115
+ - **Pass 2**: the existing sizing/liquidity/analyzer/order-application
116
+ chain, unchanged — except a book-selected symbol's `signal_name`/
117
+ `base_target_weight` are overridden (direction set by
118
+ `book_role_multiplier`, magnitude derived from how extreme the
119
+ symbol's own predicted rank is: `confidence = |rank - 0.5| * 2`) before
120
+ entering that same existing pipeline.
121
+
122
+ **Byte-identical when disabled**: `signals[symbol]` dict objects are
123
+ inserted during Pass 1, in `self.symbols` order, for every symbol with a
124
+ bar — Pass 2 only ever `.update()`s those same dict objects in place,
125
+ never re-inserts. With `phase_v2.portfolio_book.enabled=false` (the
126
+ default), `build_rank_based_book()` is never called, `book_allocations` is
127
+ always `{}`, and every symbol's Pass 2 behavior — including exposure-cap
128
+ consumption *order* across symbols, since `pass1_state` preserves
129
+ `self.symbols` iteration order — is identical to the single-pass loop that
130
+ existed before this phase.
131
+
132
+ ## Config (`phase_v2.portfolio_book`)
133
+
134
+ Off by default, same precedent as `rank_sizing_enabled` — this is a bigger
135
+ structural change (direction-setting, not direction-preserving) than any
136
+ prior `rank_20d` integration, and inherits the same non-overlapping-date
137
+ validation caveat documented in `risk/README.md`.
138
+
139
+ - `enabled` (default `false`)
140
+ - `top_n` / `bottom_n` (default `3` / `3`)
141
+ - `min_rank_confidence_spread` (default `0.2`)
142
+ - `allocation_method` (default `"rank"`; `"hrp"` enables HRP sizing — see
143
+ the V5.4.4 section below)
144
+ - `per_asset_class_slots` (default absent — pooled combined-universe
145
+ ranking via `top_n`/`bottom_n` above; see "Multi-asset-class book
146
+ selection" below for the per-class alternative)
147
+
148
+ Also see `phase9.portfolio.max_short_exposure` (default `0.30`) — the
149
+ dedicated short-exposure cap, independent of this block's own on/off
150
+ switch, since it's a portfolio-wide risk ceiling that should stay in
151
+ effect regardless.
152
+
153
+ ## Multi-asset-class book selection
154
+
155
+ `build_rank_based_book()` originally needed **zero signature change** to
156
+ select across a mixed equity/crypto/bond/future/option universe — it
157
+ operates purely on `predicted_rank_20d`/`trading_eligible` per symbol.
158
+ Once the model's widened, unified feature vector
159
+ (`train.py::add_asset_class_context_features()`, see `risk/README.md`)
160
+ produces `rank_20d` for bond/future/option symbols too, they become
161
+ automatically eligible book candidates alongside equities and crypto —
162
+ this is the direct mechanism satisfying "the model's final decision maker
163
+ should consider all enabled asset classes together as one coherent
164
+ portfolio." Ships with one combined-universe top-N/bottom-N ranking by
165
+ default (`per_asset_class_slots` absent) — simpler, and matches "one
166
+ coherent portfolio" directly.
167
+
168
+ `per_asset_class_slots` (development/Problems.md#29) is the optional
169
+ alternative: a `{asset_class: (top_n, bottom_n)}` map giving each asset
170
+ class its own independent long/short slot budget instead of one pooled
171
+ ranking, so e.g. a handful of high-conviction crypto symbols can't fill
172
+ every book slot and crowd out equities entirely. Each class is ranked and
173
+ `min_rank_confidence_spread`-gated independently; a class not listed in
174
+ the map is excluded from book selection entirely (explicit opt-in, same
175
+ convention `risk/asset_class_router.py` uses for future/option). Both
176
+ paths share the same core selection logic
177
+ (`book_construction.py::_select_book_group()`) so behavior is identical
178
+ per group either way — only the pooling boundary changes.
179
+
180
+ Per-asset-class exposure caps (`phase9.portfolio.max_bond_exposure`/
181
+ `max_futures_exposure`/`max_options_exposure`, mirroring the existing
182
+ `max_equity_exposure`/`max_crypto_exposure`) still apply AFTER book
183
+ selection, in `main.py::_apply_signal()`'s existing per-symbol cap check —
184
+ a book-selected bond/future/option symbol is not exempt from its class's
185
+ exposure ceiling just because the book picked it.
186
+
187
+ ## `options_strategy.py` — greeks-sized options positions (single-leg default; optional 2-leg vertical, or all 43 `OptionStrategies` factories via V4.5)
188
+
189
+ A second, narrower "sets direction/instrument, not just magnitude"
190
+ decision layer, alongside `book_construction.py` above but for a different
191
+ reason: it lives here (not `risk/`) because sizing an options position
192
+ needs the whole option chain, not a scalar signal, and it lives here (not
193
+ `features/`) because it's a decision layer, not a feature. See
194
+ `risk/README.md`'s "Multi-asset-class risk dispatch" section for the full
195
+ design — `build_options_position_sizing()` translates the model's existing
196
+ direction+confidence prediction into a target delta, selects the nearest-
197
+ delta contract from the chain, and sizes contract count by a vega budget.
198
+ Single-leg is the default. Order placement against the selected contract
199
+ is real (`main.py::_apply_option_order()` resolves a live tradable
200
+ contract `Symbol` from Lean's `slice.OptionChains` and places a real
201
+ `LimitOrder()`/`MarketOrder()`) — this was closed in an earlier pass; see
202
+ `development/Problems.md` #34. A 2-leg vertical call/put spread is
203
+ available via `phase_v2.options_risk.spread_strategy: "vertical"`
204
+ (`select_vertical_spread_legs()`/`build_vertical_spread_position_sizing()`
205
+ below `build_options_position_sizing()` in this same file) — both this
206
+ and single-leg remain fully untouched, byte-identical dedicated code
207
+ paths. Automatic selection of anything beyond these 2 (straddles/
208
+ strangles/iron condors/butterflies/calendars/backspreads/ladders/naked
209
+ shorts/covered-protective/collar) — previously an explicit non-goal
210
+ (`development/Problems.md` #29/#38) — **shipped as V4.5**, see below and
211
+ `development/Problems.md` #59.
212
+
213
+ **Adding to an already-open position (V4.3.0)**: `main.py::_apply_option_order()`/
214
+ `_apply_option_spread_order()` compare this bar's freshly-selected
215
+ contract/legs against whatever's currently held for the chain symbol. A
216
+ match scales the position (`phase_v2.functionality.position_scaling.enabled`,
217
+ default off) — **both directions since V4.4**, not just up. See
218
+ `risk/README.md`'s "Allow adding to an existing position" section and
219
+ `development/Problems.md` #57 for the full design.
220
+
221
+ **Architecturally-sound options, multi-position book (V4.4)**: a
222
+ mismatch (the confidence-scaled target delta selected a different
223
+ strike/expiry than what's held) no longer means only "rotate or freeze."
224
+ Three additive pure functions/behaviors close the gap:
225
+
226
+ - `build_options_position_sizing_for_contract(held_contract, portfolio_value,
227
+ max_vega_budget_pct_of_equity)` and `build_vertical_spread_position_sizing_for_legs(
228
+ held_long, held_short, portfolio_value, max_vega_budget_pct_of_equity)` —
229
+ new, additive siblings of `build_options_position_sizing()`/
230
+ `build_vertical_spread_position_sizing()` above. Instead of re-running
231
+ `select_single_leg_contract()`/`select_vertical_spread_legs()`, they
232
+ size the contract/legs **actually held** on their own current greeks —
233
+ what lets a drifted position keep being managed instead of frozen when
234
+ `main.py` chooses not to rotate. Both are pure re-uses of the identical
235
+ vega-budget arithmetic the chain-first sizers already had (factored
236
+ into shared `_size_single_leg_contract()`/`_size_vertical_spread()`
237
+ helpers) — neither existing sizer needed a signature or behavior
238
+ change.
239
+ - **Spread scale-down** via a new `self.Sell(strategy, quantity)` combo
240
+ primitive (the Sell-side sibling of the existing `self.Buy(strategy,
241
+ quantity)` entry path) — spreads could previously only ever scale up.
242
+ - **Multi-position book** — `phase_v2.options_risk.max_positions_per_underlying`
243
+ (default `1`, byte-identical to before) lets `main.py` hold more than
244
+ one position per underlying at once instead of one slot always
245
+ clobbering itself on drift; rotation now liquidates the *oldest* held
246
+ position specifically (`main.py::_liquidate_option_record()`), not
247
+ "whatever was there."
248
+
249
+ Both the spread Sell-combo and the new combo-limit-order path
250
+ (`main.py::_try_submit_spread_limit_order()`) are new Lean API surface
251
+ this codebase has never exercised before — code-complete but
252
+ IB-unverified, the same status the original Buy-combo entry path already
253
+ carried. See `development/Problems.md` #58 for the full design and what
254
+ remains deferred (rotation netting, anti-thrashing).
255
+
256
+ **Full `OptionStrategies` coverage, all 43 factories (V4.5)**: the 2-leg
257
+ vertical above is now one of 43 entries in `MULTI_LEG_STRATEGY_REGISTRY`,
258
+ a `StrategySpec` per strategy (factory name, exact positional `arg_order`,
259
+ per-leg `side`/`ratio`/`right` transcribed from Lean's real
260
+ `OptionStrategies.cs` source, `risk_tier`) resolved through ~10 shared
261
+ shape-family selectors (`select_straddle_legs`, `select_iron_condor_legs`,
262
+ `select_ladder_legs`, etc., dispatched via one `select_strategy_legs()`)
263
+ instead of 41 near-duplicate functions. `main.py`'s record shape
264
+ generalized from the 2-leg-hardcoded `"kind": "spread"`
265
+ (`legs`/`long_strike`/`short_strike`/`expiry`) to fully general
266
+ `"kind": "multi_leg"` (`legs`/`strikes`/`expiries` — any leg count/ratio,
267
+ 1 or 2 expiries); `_apply_option_spread_order()`/`_enter_option_spread()`/
268
+ `_place_option_spread_delta_order()`/`_try_submit_spread_limit_order()`
269
+ generalized to their `_multi_leg` siblings, resolving the real
270
+ `OptionStrategy` via one registry lookup (`_build_option_strategy_object()`)
271
+ instead of a hardcoded 2-strategy ternary — the legacy vertical config
272
+ path now flows through this same generalized machinery (confirmed
273
+ byte-identical via the full pre-existing test suite passing unchanged).
274
+
275
+ **Chain-grouping hoist (V4.9 Priority 3)**: `select_calendar_legs()`/
276
+ `select_arbitrage_jelly_roll_legs()` (the only 2 of the 14 shape-family
277
+ selectors that need `group_chain_by_expiry()`'s O(n) chain-by-expiry
278
+ grouping — the latter permanently unreachable in practice via
279
+ `route_multi_leg_option_sizing()`'s own `unreachable_arbitrage` routing)
280
+ now accept an optional `grouped_chain_by_expiry` param, threaded through
281
+ `select_strategy_legs()`'s own new same-named param (deliberately a
282
+ dedicated top-level parameter, not folded into `tuning_kwargs`, since
283
+ blindly forwarding it would raise for the 12 selectors that don't accept
284
+ it). `risk/asset_class_router.py::route_multi_leg_option_sizing()`
285
+ computes the grouping ONCE per routing call — only when a calendar-family
286
+ strategy is actually among `ordered_names` — instead of once per
287
+ candidate calendar-family strategy tried in the same bar. `None` (the
288
+ default, every existing caller) reproduces the exact original per-call
289
+ regroup behavior, parity-tested in `tests/test_options_strategy_multileg.py`.
290
+ `group_chain_by_expiry()` itself was renamed from the original private
291
+ `_group_chain_by_expiry()` since it's now called across module
292
+ boundaries.
293
+
294
+ New `portfolio/options_margin_sizing.py` sizes the strategies a vega
295
+ budget can't safely represent (naked shorts, short straddles/strangles,
296
+ ladders' uncovered leg, backspreads' defined max loss) — see
297
+ `risk/README.md`'s own V4.5 section for the full 3-sizing-paradigm
298
+ breakdown, the volatility-view signal, and the 2 real risk-tier
299
+ corrections found transcribing Lean's actual leg quantities (only
300
+ `bull_call_ladder`/`bear_put_ladder` are genuinely unbounded of the 4
301
+ ladders; only the inverted `short_*` backspreads are genuinely unbounded
302
+ of the 4 backspreads). Covered/protective/collar's option leg(s) are
303
+ sized as a ratio against the equity leg's held quantity
304
+ (`build_covered_protective_position_sizing()`) and managed by a dedicated
305
+ per-bar sweep (`main.py::_manage_covered_protective_positions()`) —
306
+ entirely independent of the option asset's own buy/sell signal, since
307
+ this overlay exists to manage risk against an *existing* equity position,
308
+ not to express a fresh directional view.
309
+
310
+ Entirely gated behind `phase_v2.options_risk.multi_leg_strategies_enabled`
311
+ (default `false`, byte-identical to V4.4 when off). See
312
+ `development/Problems.md` #59 for the full design.
313
+
314
+ **V4.6 update**: the 6 arbitrage strategies are no longer *permanently*
315
+ stubbed — a new `portfolio/options_arbitrage_detector.py` (standard
316
+ put-call-parity/box-spread/cost-of-carry fair-value formulas, gated
317
+ behind off-by-default `phase_v2.options_risk.arbitrage_detector`) makes
318
+ them conditionally reachable when it confirms a real mispricing this
319
+ bar. A per-asset `options_strategy_override` (resolved via
320
+ `resolve_enabled_strategy_names()`) also shipped, along with an
321
+ anti-thrashing rotation cooldown and same-bar netting closing #58/#59's
322
+ own deferred items. Full early-assignment/corporate-action modeling
323
+ remains explicitly out of scope. See `development/Problems.md` #60.
324
+
325
+ Separately, "single-bond trading" was investigated and found
326
+ **infeasible** under this Lean version (no `SecurityType.Bond` anywhere
327
+ in the real Lean source tree) — `features/bond_features.py` gained real
328
+ analytic duration/convexity/DV01 for the existing bond-ETF sleeve
329
+ instead, deliberately informational only (never merged into the trained
330
+ model's feature vector). See `risk/README.md`'s own V4.6 section.
331
+
332
+ **V4.7 update**: brings V4.6's own deferred items (full early-assignment/
333
+ corporate-action modeling, and #29's learned strategy-selector model)
334
+ fully into scope, plus wires the V4.6 bond analytics into the trained
335
+ model as real signals. See `development/Problems.md` #61 for the full
336
+ writeup; summary here:
337
+
338
+ - **Early-assignment/corporate-action modeling**: new
339
+ `portfolio/options_assignment_risk.py` scores dividend-driven
340
+ early-call-assignment risk (moneyness + extrinsic value vs. expected
341
+ dividend + days-to-next-ex-div) — call-only by construction, puts
342
+ always score 0.0. Sourced from a new `data_pipeline/dividend_backfill.py`
343
+ (real ex-dividend history via `yfinance`, projecting the next date from
344
+ historical cadence rather than yfinance's own unreliable forward
345
+ calendar field) and a new `features/options_greeks.py::baw_american_price()`
346
+ (Barone-Adesi-Whaley American-exercise pricer, verified during
347
+ development against a CRR binomial tree). Wired into a new
348
+ `main.py::_apply_option_assignment_risk_sweep()` — a sibling to (not a
349
+ modification of) the existing expiry-day auto-close sweep, gated behind
350
+ off-by-default `phase_v2.options_risk.assignment_risk_detector`. A real
351
+ Lean source check confirmed Lean itself force-sets
352
+ `DataNormalizationMode.Raw` on option underlyings, so no `main.py`
353
+ normalization change was needed for the corporate-actions half of this
354
+ — same-bar stock splits are now just logged (`corporate_action` field
355
+ on the experience event) for auditability.
356
+ - **Learned multi-leg strategy-selector model (#29)**: the harder half.
357
+ `option_positions_by_symbol` (the originally-assumed data source) turns
358
+ out to only populate on the real-order path, which never fires in this
359
+ environment — so the new strategy-attributed outcome capture
360
+ (`main.py::_emit_option_strategy_outcome_if_pending()`, a new
361
+ `simulated_option_strategy_entries_by_symbol_key` dict) targets the
362
+ observation-mode branch instead, reusing `options_decision.net_debit_or_credit`
363
+ (already computed by the router). A new
364
+ `net_debit_or_credit_for_legs()` (extracted from `_size_multi_leg()`'s
365
+ own inline computation, but stricter — `None`, not `0.0`, on any
366
+ missing bid/ask) values the close; a new `option_strategy_outcome`
367
+ experience event persists the outcome with **zero Postgres DDL
368
+ change** (proven by a regression test, not just asserted). New
369
+ `train_strategy_selector.py` follows `train_topology.py`'s exact
370
+ bolt-on-trainer shape but is dormant in a stronger sense — it has no
371
+ data source at all until real option positions actually trade, not
372
+ just "not enough volume yet" (its own module docstring documents this).
373
+ New `rerank_enabled_strategies_by_score()` is the live inference plug
374
+ point; see `risk/README.md`'s own V4.7 section for how
375
+ `route_multi_leg_option_sizing()` consumes it.
376
+ - **Bond analytics as real model features**: the 3 analytic bond
377
+ functions V4.6 shipped informational-only are now merged into
378
+ `base_features`/the trained model's feature schema (`config.json`,
379
+ `main.py::_build_model_input_impl()`, `train.py::build_bond_features_by_date()`).
380
+ Code-complete but the actual retrain was deliberately not run this
381
+ pass — deploying the schema change without a matching retrain first
382
+ would `KeyError` on every bar.
383
+
384
+ ## Webui visibility
385
+
386
+ `signal_payload["portfolio_book_role"]` (set in `main.py`, see above) is
387
+ surfaced per-symbol as a "Book Role" column in
388
+ `webui/src/components/risk/AssetSizingTable.tsx` (long/short badge, or
389
+ `—` for non-book-controlled symbols / when the overlay is disabled) —
390
+ typed as `Signal.portfolio_book_role` in `webui/src/types/state.ts`.
391
+
392
+ ## Hysteresis-aware sticky selection (V5.1 Phase 0/1)
393
+
394
+ `build_rank_based_book()`/`_select_book_group()` gained `previous_allocations`/
395
+ `hysteresis_rank_margin` parameters (default `None`/`0.0` — a strict no-op,
396
+ byte-identical to the pre-V5.1 plain top/bottom-N slice). When set, an
397
+ incumbent (same role in `previous_allocations`) whose rank has fallen just
398
+ outside the natural N-slot cutoff, but is still within
399
+ `hysteresis_rank_margin` of it, keeps its slot rather than being dropped
400
+ and immediately re-entered on the next small rank wobble — directly
401
+ reduces book turnover/fees. See `_hysteresis_adjusted_selection()`'s
402
+ docstring for the exact "best-first" ordering contract both legs share.
403
+ `BookAllocation` also gained `rank_head`/`target_weight` (both defaulted).
404
+
405
+ ## `book_neutrality.py::apply_book_neutrality()` (V5.1 Phase 0)
406
+
407
+ The one deliberate cross-symbol pass over an already-selected book's raw
408
+ per-symbol weights: per-name cap → sector-neutral demean (shrink-only if a
409
+ bucket's net still exceeds its cap after re-clipping) → dollar-neutral
410
+ (scale the *larger* leg down only, never up) → gross-exposure cap.
411
+ Deliberately its own module, not folded into `book_construction.py`:
412
+ that module decides **which** symbols and **which side**; this one decides
413
+ **how much**, given selection is already final. Reused verbatim by
414
+ `evaluation/rank_book_simulator.py`, so the offline simulator and the live
415
+ decision path apply identical neutrality math. See `evaluation/README.md`.
416
+
417
+ ## HRP book sizing — `allocation_method: "hrp"` (V5.4.4)
418
+
419
+ `phase_v2.portfolio_book.allocation_method` (default `"rank"`) selects how
420
+ an already-selected book is SIZED:
421
+
422
+ - `"rank"` (default) — today's exact per-symbol confidence formula
423
+ (`min(max_position_weight, 0.10 + 0.15 * confidence) *
424
+ book_role_multiplier`), byte-identical to before this key existed.
425
+ - `"hrp"` — `book_construction.py::apply_hrp_weights()` re-weights the
426
+ SAME selected symbols with Hierarchical Risk Parity inverse-variance
427
+ weights (`hrp_allocation.py::build_hrp_allocation()`, López de Prado
428
+ 2016: correlation-distance dendrogram → quasi-diagonal ordering →
429
+ recursive bisection). Selection (which symbols, which roles) is NEVER
430
+ touched — only sizing changes. Return history comes from each selected
431
+ symbol's own rolling 25-bar window (`main.py`'s `self.symbol_windows`)
432
+ via `pct_returns_from_closes()`; thin history degrades to equal-weight
433
+ per leg inside `build_hrp_allocation()`, never a crash.
434
+
435
+ Wiring (`main.py::on_data()`, rebalance bars only): HRP weights replace
436
+ the confidence formula as `apply_book_neutrality()`'s raw input when
437
+ neutrality is enabled (its dollar/sector/gross caps still apply on top),
438
+ or populate `self._book_target_weights` directly when it is disabled —
439
+ Pass 2's `.get(symbol_key, <inline formula>)` consumes either way. With
440
+ both legs populated each leg sums to ±gross/2 (dollar-neutral); a
441
+ single-sided book (the default `long_flat` strategy_mode) gives the
442
+ present leg the FULL gross. This holds on EVERY path — the equal-weight
443
+ fallbacks (thin history, scipy failure) share the same per-leg totals as
444
+ the HRP path itself (V5.4.5, Problems.md #118 — the fallbacks originally
445
+ divided gross across both legs combined, breaking neutrality exactly when
446
+ leg counts were unequal). `apply_hrp_weights()` imports scipy lazily,
447
+ so the default `"rank"` path never pays the import.
portfolio/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Cross-sectional portfolio (long/short book) construction for Aether Quant V2."""
2
+
3
+ from .book_construction import (
4
+ BookAllocation,
5
+ apply_hrp_weights,
6
+ build_book_history_record,
7
+ build_rank_based_book,
8
+ normalize_per_asset_class_slots,
9
+ pct_returns_from_closes,
10
+ should_exit_non_selected_book_symbol,
11
+ should_rebalance_this_bar,
12
+ )
13
+ from .book_neutrality import apply_book_neutrality
14
+ from .options_strategy import (
15
+ OptionsPositionDecision,
16
+ build_options_position_sizing,
17
+ select_single_leg_contract,
18
+ )
19
+ from .rank_signal import (
20
+ cross_sectional_rank_scores,
21
+ resolve_rank_signal_policy,
22
+ select_raw_rank_score,
23
+ )
24
+
25
+ __all__ = [
26
+ "BookAllocation",
27
+ "OptionsPositionDecision",
28
+ "apply_book_neutrality",
29
+ "apply_hrp_weights",
30
+ "build_book_history_record",
31
+ "build_options_position_sizing",
32
+ "build_rank_based_book",
33
+ "cross_sectional_rank_scores",
34
+ "normalize_per_asset_class_slots",
35
+ "pct_returns_from_closes",
36
+ "resolve_rank_signal_policy",
37
+ "select_raw_rank_score",
38
+ "select_single_leg_contract",
39
+ "should_exit_non_selected_book_symbol",
40
+ "should_rebalance_this_bar",
41
+ ]