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
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
|
+
]
|