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,786 @@
1
+ """Stage-2 cross-sectional long/short book construction (Phase 3 of the
2
+ 5/10 -> 9/10 roadmap) - the first use of the rank_20d signal that decides
3
+ *which direction* a symbol trades, not just how large an already-decided
4
+ trade should be.
5
+
6
+ Every existing sizing multiplier in this codebase (topology_sizing_multiplier(),
7
+ rank_sizing_multiplier() in risk/position_sizing.py) is deliberately
8
+ bounded and direction-PRESERVING: it can only shrink or (for rank_sizing_multiplier())
9
+ modestly scale an already-decided position, never flip its sign. This
10
+ module is the one deliberate departure from that convention -
11
+ build_rank_based_book()'s `book_role_multiplier` (+1.0 long, -1.0 short)
12
+ SETS direction for the symbols it selects, because that is the entire
13
+ point of a cross-sectional book (rank the universe, go long the top,
14
+ short the bottom) - short-selling doesn't exist anywhere else in this
15
+ codebase today (`phase5.backtest.strategy_mode: "long_flat"` is the
16
+ existing ceiling everywhere else).
17
+
18
+ Deliberately its own package, not folded into risk/ or analyzer/:
19
+ - Not risk/position_sizing.py: book construction needs the WHOLE-universe
20
+ view (every symbol's rank_20d for this bar, seen at once) that
21
+ position_sizing.py's pure single-symbol functions structurally don't
22
+ have - see main.py::on_data()'s two-pass restructuring, which collects
23
+ every symbol's candidate data in Pass 1 before this module ever runs.
24
+ - Not analyzer/market_analyzer.py: that module's per-symbol trade/simulate/
25
+ observe/reduce_risk categorization stays deterministic and per-symbol by
26
+ design (see analyzer/README.md) - a book-selected symbol still passes
27
+ through that exact same categorization afterward, unchanged; this module
28
+ only ever decides the role (long/short) BEFORE that, never bypasses it.
29
+
30
+ Ships config-gated OFF by default (phase_v2.portfolio_book.enabled: false)
31
+ - same precedent as rank_sizing_enabled - since this is a bigger structural
32
+ change (direction-setting, not just direction-preserving) than any prior
33
+ rank_20d integration, and the same non-overlapping-date-subsample caveat
34
+ documented in risk/position_sizing.py::rank_sizing_multiplier() applies
35
+ here too.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import math
41
+ from collections.abc import Mapping
42
+ from dataclasses import asdict, dataclass
43
+
44
+
45
+ def _is_finite_number(value) -> bool:
46
+ """V5.4.5 (development/Problems.md #117): a NaN rank score is not None -
47
+ it passed the old `is not None` eligibility check, poisoned the sort
48
+ order, made every hysteresis comparison False, and (worst of all) made
49
+ the spread veto's `spread < floor` comparison False, silently disabling
50
+ it. Non-finite = not a usable rank, same degrade path as missing."""
51
+ if value is None:
52
+ return False
53
+ try:
54
+ return math.isfinite(float(value))
55
+ except (TypeError, ValueError):
56
+ return False
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class BookAllocation:
61
+ role: str
62
+ book_role_multiplier: float
63
+ predicted_rank_20d: float
64
+ book_reason: str
65
+ # V5.1 Phase 0/1 (development/Problems.md - item 6 of the V5.1 roadmap):
66
+ # both defaulted, so every existing positional construction and
67
+ # asdict()/to_dict() consumer is unaffected. rank_head records which
68
+ # blended signal (see portfolio/rank_signal.py) drove this allocation;
69
+ # target_weight is filled in by callers that run the neutrality pass
70
+ # (portfolio/book_neutrality.py) - None means "neutrality not applied,
71
+ # read book_role_multiplier * the caller's own weight formula instead",
72
+ # same convention as every other Optional payload field in this codebase.
73
+ rank_head: str = "blend"
74
+ target_weight: float | None = None
75
+
76
+ def to_dict(self) -> dict:
77
+ return asdict(self)
78
+
79
+
80
+ def normalize_per_asset_class_slots(
81
+ raw: dict[str, object] | None,
82
+ ) -> tuple[dict[str, tuple[int, int]], list[str]]:
83
+ """Validates and coerces a raw `phase_v2.portfolio_book.per_asset_class_slots`
84
+ config dict into build_rank_based_book()'s expected shape.
85
+ `build_rank_based_book()` unpacks each value unconditionally
86
+ (`for asset_class, (top_n, bottom_n) in per_asset_class_slots.items()`),
87
+ so a malformed entry (wrong length, wrong type) would otherwise hard-
88
+ crash every bar instead of degrading gracefully like every other
89
+ optional config-driven feature in this codebase - development/
90
+ Problems.md. Each value must be a 2-element `[top_n, bottom_n]`
91
+ list/tuple; anything else is skipped, not fatal.
92
+
93
+ Returns `(valid_slots, skipped_asset_classes)` - pure and no-logging
94
+ by design (so it stays independently testable); callers with access
95
+ to a logger (main.py's `self.Debug()`) should report the skipped
96
+ list themselves."""
97
+ valid: dict[str, tuple[int, int]] = {}
98
+ skipped: list[str] = []
99
+ for asset_class, slots in (raw or {}).items():
100
+ if isinstance(slots, (list, tuple)) and len(slots) == 2:
101
+ valid[asset_class] = tuple(slots)
102
+ else:
103
+ skipped.append(asset_class)
104
+ return valid, skipped
105
+
106
+
107
+ def _hysteresis_adjusted_selection(
108
+ strength_ordered_candidates: list[str],
109
+ eligible_ranks: dict[str, float],
110
+ n: int,
111
+ role: str,
112
+ previous_allocations: dict[str, BookAllocation] | None,
113
+ hysteresis_rank_margin: float,
114
+ ) -> list[str]:
115
+ """Sticky top/bottom-N selection (V5.1 Phase 0/1, development/Problems.md
116
+ #43's rebalance-churn work continued): an incumbent (same `role` in
117
+ `previous_allocations`) whose rank has fallen just outside the natural
118
+ n-slot cutoff, but is still within `hysteresis_rank_margin` of it, keeps
119
+ its slot rather than being dropped and immediately re-entered on the
120
+ next small rank wobble - directly reduces turnover/fees, the whole point
121
+ of this parameter (see book_neutrality.py's module docstring).
122
+
123
+ `strength_ordered_candidates` must already be sorted BEST-FIRST for this
124
+ leg - i.e. descending by predicted_rank_20d for role="long" (highest
125
+ rank = most long-worthy), ascending by predicted_rank_20d for
126
+ role="short" (lowest rank = most short-worthy). Normalizing both legs to
127
+ "best first" here (rather than each leg carrying its own sign
128
+ convention) is what keeps this function's cutoff/margin arithmetic
129
+ identical for both roles - only the caller's ordering differs.
130
+
131
+ Bounded at exactly `n` slots (never grows the book past what was
132
+ requested). `previous_allocations` empty/None or `hysteresis_rank_margin
133
+ <= 0` returns `strength_ordered_candidates[:n]` unchanged - BYTE-
134
+ IDENTICAL to the pre-hysteresis plain top-n slice, so every existing
135
+ caller/test that doesn't pass these new params is unaffected."""
136
+ natural = strength_ordered_candidates[:n]
137
+ if not previous_allocations or hysteresis_rank_margin <= 0.0 or n <= 0 or not natural:
138
+ return natural
139
+
140
+ cutoff_rank = eligible_ranks[natural[-1]]
141
+ incumbents = [
142
+ symbol
143
+ for symbol in strength_ordered_candidates
144
+ if symbol in previous_allocations and previous_allocations[symbol].role == role
145
+ ]
146
+ if role == "long":
147
+ retained_incumbents = [
148
+ symbol for symbol in incumbents if eligible_ranks[symbol] >= cutoff_rank - hysteresis_rank_margin
149
+ ]
150
+ else:
151
+ retained_incumbents = [
152
+ symbol for symbol in incumbents if eligible_ranks[symbol] <= cutoff_rank + hysteresis_rank_margin
153
+ ]
154
+ # Cap incumbents at n slots, keeping the strongest ones first (order
155
+ # already inherited from strength_ordered_candidates) - an incumbent
156
+ # list longer than n would otherwise grow the book past what was
157
+ # requested, which hysteresis is meant to reduce churn within, not
158
+ # override entirely.
159
+ retained_incumbents = [
160
+ symbol for symbol in strength_ordered_candidates if symbol in retained_incumbents
161
+ ][:n]
162
+
163
+ final = list(retained_incumbents)
164
+ for symbol in strength_ordered_candidates:
165
+ if len(final) >= n:
166
+ break
167
+ if symbol not in final:
168
+ final.append(symbol)
169
+ return final[:n]
170
+
171
+
172
+ def compute_confidence_spread(
173
+ long_symbols: list[str],
174
+ short_symbols: list[str],
175
+ spread_ranks: dict[str, float],
176
+ eligible_ranks: dict[str, float],
177
+ ) -> float | None:
178
+ """mean(long-side score) - mean(short-side score), the exact quantity
179
+ _select_book_group()'s min_rank_confidence_spread gate compares
180
+ against a floor. Public (not module-private) so a calibration tool can
181
+ compute the identical statistic against historical data without
182
+ re-deriving the formula - the two can then never disagree on what
183
+ "spread" means for a given day's selection.
184
+
185
+ `spread_ranks.get(symbol, eligible_ranks[symbol])` - a symbol missing
186
+ from spread_ranks (e.g. a caller-supplied dict that doesn't cover
187
+ every eligible symbol) falls back to its own eligible_ranks value
188
+ rather than a KeyError, same defensive convention as every other
189
+ optional-override lookup in this module.
190
+
191
+ None if either list is empty - spread is undefined without both
192
+ sides (mirrors the caller never reaching this check for a long-only
193
+ book)."""
194
+ if not long_symbols or not short_symbols:
195
+ return None
196
+ long_mean = sum(spread_ranks.get(symbol, eligible_ranks[symbol]) for symbol in long_symbols) / len(long_symbols)
197
+ short_mean = sum(spread_ranks.get(symbol, eligible_ranks[symbol]) for symbol in short_symbols) / len(short_symbols)
198
+ return long_mean - short_mean
199
+
200
+
201
+ def _select_book_group(
202
+ eligible_ranks: dict[str, float],
203
+ top_n: int,
204
+ bottom_n: int,
205
+ min_rank_confidence_spread: float,
206
+ previous_allocations: dict[str, BookAllocation] | None = None,
207
+ hysteresis_rank_margin: float = 0.0,
208
+ spread_check_ranks: dict[str, float] | None = None,
209
+ rolling_ic_gate_result: dict | None = None,
210
+ veto_reason_out: dict | None = None,
211
+ ) -> dict[str, BookAllocation]:
212
+ """Core discrete top-N-long / bottom-N-short selection over an already-
213
+ filtered `{symbol: predicted_rank_20d}` pool - shared by the pooled
214
+ (build_rank_based_book()'s default) and per-asset-class
215
+ (`per_asset_class_slots`) paths, so both apply identical selection
216
+ logic to whatever pool they're given, just scoped differently. See
217
+ build_rank_based_book()'s docstring for the full behavior contract.
218
+
219
+ `previous_allocations`/`hysteresis_rank_margin` (V5.1 Phase 0/1,
220
+ defaults None/0.0): optional sticky-selection pass over the natural
221
+ top/bottom-N slice - see _hysteresis_adjusted_selection()'s docstring.
222
+ Both defaults are a strict no-op, reproducing the pre-hysteresis
223
+ selection exactly.
224
+
225
+ `spread_check_ranks` (V5.1 Phase 1, development/Problems.md #77):
226
+ optional separate `{symbol: raw_score}` dict used ONLY for the
227
+ min_rank_confidence_spread comparison below - defaults to
228
+ `eligible_ranks` itself when not given (byte-identical to before this
229
+ parameter existed). Selection (WHICH symbols end up long/short) is
230
+ unaffected either way, since sorting is invariant under a monotone
231
+ transform - `eligible_ranks` may already be a per-bar cross-sectional
232
+ percentile (portfolio/rank_signal.py::cross_sectional_rank_scores()),
233
+ and a fixed top-N/bottom-N split out of that ALWAYS shows a large
234
+ spread by construction (e.g. ~0.9 for top-6/bottom-6 of ~74 names),
235
+ regardless of whether the underlying model had any real conviction
236
+ that day - a normalized-scale spread check is statistically
237
+ meaningless, it can never distinguish a genuinely dispersed day from a
238
+ noisy one. Passing the pre-normalization raw scores here restores the
239
+ gate's actual purpose: does the RAW output show real dispersion.
240
+
241
+ `rolling_ic_gate_result` (V5.3.5, development/Problems.md #102):
242
+ optional output of portfolio.rolling_ic_gate.evaluate_rolling_ic_gate() -
243
+ an INDEPENDENT second veto alongside min_rank_confidence_spread above.
244
+ Default None is a strict no-op (byte-identical to before this
245
+ parameter existed) - same "missing/degraded signal never changes
246
+ trading behavior" contract every optional overlay in this function
247
+ already guarantees. Where the dispersion check asks "did the model
248
+ differentiate symbols today," this one asks "has the model's recent
249
+ trailing prediction quality actually been good" - #102 found a real
250
+ backtest where the answer to the first question was "yes" throughout
251
+ a stretch where the honest answer to the second was "no."
252
+
253
+ `veto_reason_out` (V5.3.5, development/Problems.md #102): optional
254
+ output-by-mutation dict - when not None, this function sets
255
+ `veto_reason_out["reason"]` to a short string identifying WHY `{}` was
256
+ returned, for each of the veto paths below. Deliberately NOT a return-
257
+ type change (this function has real callers beyond main.py - the
258
+ offline reconciliation/ablation/capacity tools - that must never need
259
+ to change how they read this function's result just to get a
260
+ diagnostic only main.py's own book-history logging wants); a caller
261
+ that doesn't pass this argument is completely unaffected."""
262
+ if len(eligible_ranks) < 2 or top_n <= 0:
263
+ if veto_reason_out is not None:
264
+ veto_reason_out["reason"] = "thin_universe_or_zero_top_n"
265
+ return {}
266
+ spread_ranks = spread_check_ranks if spread_check_ranks is not None else eligible_ranks
267
+
268
+ ranked_symbols = sorted(eligible_ranks, key=lambda symbol: eligible_ranks[symbol], reverse=True)
269
+ natural_long_symbols = ranked_symbols[:top_n]
270
+ remaining_symbols = [symbol for symbol in ranked_symbols if symbol not in natural_long_symbols]
271
+
272
+ long_symbols = _hysteresis_adjusted_selection(
273
+ ranked_symbols, eligible_ranks, top_n, "long", previous_allocations, hysteresis_rank_margin
274
+ )
275
+ # Short leg's "best first" ordering is ascending rank (lowest = most
276
+ # short-worthy) - the mirror image of ranked_symbols' descending order.
277
+ short_ordered_candidates = sorted(remaining_symbols, key=lambda symbol: eligible_ranks[symbol])
278
+ short_symbols = _hysteresis_adjusted_selection(
279
+ short_ordered_candidates, eligible_ranks, bottom_n, "short", previous_allocations, hysteresis_rank_margin
280
+ )
281
+ # Guard against a pathological case: hysteresis retaining a symbol on
282
+ # both legs simultaneously (only possible if previous_allocations
283
+ # itself was inconsistent - it never is, since a symbol can only ever
284
+ # hold ONE role) - re-derive remaining_symbols/short candidates from the
285
+ # ACTUAL long_symbols chosen above so the two legs never overlap.
286
+ if long_symbols != natural_long_symbols:
287
+ remaining_symbols = [symbol for symbol in ranked_symbols if symbol not in long_symbols]
288
+ short_ordered_candidates = sorted(remaining_symbols, key=lambda symbol: eligible_ranks[symbol])
289
+ short_symbols = _hysteresis_adjusted_selection(
290
+ short_ordered_candidates, eligible_ranks, bottom_n, "short", previous_allocations, hysteresis_rank_margin
291
+ )
292
+
293
+ if not long_symbols:
294
+ if veto_reason_out is not None:
295
+ veto_reason_out["reason"] = "no_long_candidates"
296
+ return {}
297
+ # bottom_n > 0 means a short side was actually requested - if nothing
298
+ # was left over to fill it (long_n consumed the whole eligible pool),
299
+ # stay strict and refuse the whole book, matching this function's
300
+ # original behavior. bottom_n == 0 (main.py passes this for
301
+ # phase5.backtest.strategy_mode == "long_flat" - see main.py's own
302
+ # strategy_mode enforcement) is a deliberate long-only request, not a
303
+ # degenerate case - falls through to the long-only book below.
304
+ if bottom_n > 0 and not short_symbols:
305
+ if veto_reason_out is not None:
306
+ veto_reason_out["reason"] = "no_short_candidates"
307
+ return {}
308
+
309
+ if short_symbols:
310
+ # compute_confidence_spread() (module-level, above) - shared with
311
+ # the calibration tool that derives min_rank_confidence_spread
312
+ # from real data, so the two can never disagree on what "spread"
313
+ # means for a given selection.
314
+ spread = compute_confidence_spread(long_symbols, short_symbols, spread_ranks, eligible_ranks)
315
+ if spread is not None and spread < min_rank_confidence_spread:
316
+ if veto_reason_out is not None:
317
+ veto_reason_out["reason"] = "min_rank_confidence_spread_below_floor"
318
+ return {}
319
+
320
+ # V5.3.5 (development/Problems.md #102) - an INDEPENDENT second veto,
321
+ # checked regardless of whether a short leg was requested (unlike the
322
+ # spread check above, which only has meaning for a two-sided book):
323
+ # the model can look genuinely dispersed today (clearing the check
324
+ # above) while its recent TRAILING predictions have carried near-zero
325
+ # or negative realized skill - a real backtest showed this happens,
326
+ # and that the dispersion-only gate can't tell the difference.
327
+ if rolling_ic_gate_result is not None and not rolling_ic_gate_result["engaged"]:
328
+ if veto_reason_out is not None:
329
+ veto_reason_out["reason"] = rolling_ic_gate_result["reason"]
330
+ return {}
331
+
332
+ allocations: dict[str, BookAllocation] = {}
333
+ for symbol in long_symbols:
334
+ allocations[symbol] = BookAllocation(
335
+ role="long",
336
+ book_role_multiplier=1.0,
337
+ predicted_rank_20d=eligible_ranks[symbol],
338
+ book_reason="rank_based_book_long",
339
+ )
340
+ for symbol in short_symbols:
341
+ allocations[symbol] = BookAllocation(
342
+ role="short",
343
+ book_role_multiplier=-1.0,
344
+ predicted_rank_20d=eligible_ranks[symbol],
345
+ book_reason="rank_based_book_short",
346
+ )
347
+ return allocations
348
+
349
+
350
+ def build_rank_based_book(
351
+ book_candidates: dict[str, dict],
352
+ top_n: int,
353
+ bottom_n: int,
354
+ min_rank_confidence_spread: float = 0.0,
355
+ per_asset_class_slots: dict[str, tuple[int, int]] | None = None,
356
+ previous_allocations: dict[str, BookAllocation] | None = None,
357
+ hysteresis_rank_margin: float = 0.0,
358
+ spread_check_ranks: dict[str, float] | None = None,
359
+ rolling_ic_gate_result: dict | None = None,
360
+ veto_reason_out: dict | None = None,
361
+ ) -> dict[str, BookAllocation]:
362
+ """Discrete top-N-long / bottom-N-short book construction from each
363
+ symbol's predicted_rank_20d for this bar. Continuous rank-weighted
364
+ sizing (rather than a hard top/bottom-N cutoff) is a documented future
365
+ extension, not built here - the discrete version is simpler to reason
366
+ about and test first.
367
+
368
+ `book_candidates` is `{symbol: {"predicted_rank_20d": float | None,
369
+ "trading_eligible": bool, "asset_class": str | None, ...}}` - one entry
370
+ per symbol Pass 1 of main.py::on_data() collected this bar (extra keys
371
+ like probability_up/confidence are ignored here, kept only so callers
372
+ can pass the same dict they already built for other purposes). A
373
+ symbol is eligible for book selection only if `trading_eligible` is
374
+ true (this is exactly how phase9.asset_quality's observation-only
375
+ assets get excluded - main.py already computes this per-symbol flag
376
+ identically for every other decision) and `predicted_rank_20d` is not
377
+ None (model unavailable/still warming up that bar).
378
+
379
+ `bottom_n == 0` requests a deliberate long-only book (no shorts at all) -
380
+ used by main.py to honor `phase5.backtest.strategy_mode == "long_flat"`
381
+ while still getting rank-driven entry/rotation on the long side. Any
382
+ `bottom_n > 0` still requires at least one eligible symbol on EACH side
383
+ to form a book at all - a one-sided book isn't attempted when a short
384
+ side was actually requested but nothing was available for it; returns
385
+ {} in that case, same as every other degenerate-input case below.
386
+ `top_n`/`bottom_n` (or each
387
+ asset class's own pair, see `per_asset_class_slots` below) exceeding
388
+ the number of eligible symbols degrades gracefully to however many are
389
+ actually available (never raises) - same "pad/truncate rather than
390
+ error on a thin universe" convention as topology/market_topology.py's
391
+ rank_correlated_peers().
392
+
393
+ `min_rank_confidence_spread` is a floor on (mean long-side rank - mean
394
+ short-side rank) before a book (or, with `per_asset_class_slots`, each
395
+ individual asset class's own book) engages at all - on a day where the
396
+ ranked pool's predicted ranks are all clustered near 0.5 (no real
397
+ dispersion), forcing a long/short split would be noise, not signal;
398
+ the book disengages entirely (returns {} for that pool) rather than
399
+ trading a meaningless split. Every symbol then falls through to
400
+ whatever non-book decision main.py's Pass 2 would have made anyway -
401
+ byte-identical to this module not existing at all, the same "missing/
402
+ degraded signal never changes trading behavior" contract this
403
+ codebase's other optional overlays already guarantee.
404
+
405
+ `per_asset_class_slots` (default None - pooled ranking, this function's
406
+ original and only behavior before this parameter existed) is an
407
+ optional `{asset_class: (top_n, bottom_n)}` map. When provided, `top_n`/
408
+ `bottom_n` are ignored and every eligible symbol is ranked *within its
409
+ own asset_class group only* instead of one combined-universe pool - so
410
+ e.g. equities and crypto each get their own long/short slot budget
411
+ instead of one side potentially being filled entirely by a single
412
+ dominant asset class. A symbol whose `asset_class` isn't a key in
413
+ `per_asset_class_slots` is excluded from book selection entirely (not
414
+ silently folded into some catch-all group) - same explicit-opt-in
415
+ convention risk/asset_class_router.py already uses for future/option.
416
+ `min_rank_confidence_spread` is applied independently per asset class
417
+ (each class's own long/short spread must individually clear the bar -
418
+ pooling the check across classes with potentially very different rank
419
+ distributions would be misleading). Results from every class are
420
+ unioned into one returned dict, same shape as the pooled path.
421
+
422
+ Returns a dict covering ONLY the symbols the book actively wants long
423
+ or short (not a "flat" entry for every candidate) - callers should
424
+ treat "symbol absent from the returned dict" as "book has no view on
425
+ this symbol," not "book says flat."
426
+
427
+ `previous_allocations`/`hysteresis_rank_margin` (V5.1 Phase 0/1,
428
+ defaults None/0.0 - a strict no-op reproducing pre-hysteresis selection
429
+ exactly): threaded straight through to _select_book_group() (pooled
430
+ path) or applied identically within each asset class (per-asset-class
431
+ path) - see _hysteresis_adjusted_selection()'s docstring for the actual
432
+ sticky-selection behavior.
433
+
434
+ `spread_check_ranks` (V5.1 Phase 1, development/Problems.md #77):
435
+ optional `{symbol: raw_score}` override for the min_rank_confidence_spread
436
+ check only - see _select_book_group()'s docstring for why this must be
437
+ the PRE-normalization score when the caller's `book_candidates`
438
+ predicted_rank_20d values are already a cross-sectional percentile.
439
+ Defaults to None (falls back to book_candidates' own predicted_rank_20d,
440
+ byte-identical to before this parameter existed).
441
+
442
+ `rolling_ic_gate_result`/`veto_reason_out` (V5.3.5, development/
443
+ Problems.md #102): threaded straight through to _select_book_group() -
444
+ see that function's own docstring. Both default to None, a strict
445
+ no-op. With `per_asset_class_slots`, `rolling_ic_gate_result` is
446
+ applied identically to every class's own selection (this signal is
447
+ portfolio-wide, not asset-class-specific, unlike min_rank_confidence_spread
448
+ which already gets a note above about being checked per class); if
449
+ more than one class vetoes, `veto_reason_out` ends up holding
450
+ whichever class's reason was computed last - an accepted, documented
451
+ simplification, since the only thing that consumes this diagnostic
452
+ (main.py's book-history logging) only reads it when the OVERALL
453
+ `allocations` result comes back completely empty, at which point any
454
+ one real reason is more useful than none."""
455
+ eligible = {
456
+ symbol: candidate
457
+ for symbol, candidate in book_candidates.items()
458
+ if candidate.get("trading_eligible") and _is_finite_number(candidate.get("predicted_rank_20d"))
459
+ }
460
+
461
+ if per_asset_class_slots is None:
462
+ eligible_ranks = {symbol: candidate["predicted_rank_20d"] for symbol, candidate in eligible.items()}
463
+ return _select_book_group(
464
+ eligible_ranks, top_n, bottom_n, min_rank_confidence_spread,
465
+ previous_allocations, hysteresis_rank_margin, spread_check_ranks,
466
+ rolling_ic_gate_result, veto_reason_out,
467
+ )
468
+
469
+ allocations: dict[str, BookAllocation] = {}
470
+ for asset_class, (class_top_n, class_bottom_n) in per_asset_class_slots.items():
471
+ class_eligible_ranks = {
472
+ symbol: candidate["predicted_rank_20d"]
473
+ for symbol, candidate in eligible.items()
474
+ if candidate.get("asset_class") == asset_class
475
+ }
476
+ allocations.update(
477
+ _select_book_group(
478
+ class_eligible_ranks, class_top_n, class_bottom_n, min_rank_confidence_spread,
479
+ previous_allocations, hysteresis_rank_margin, spread_check_ranks,
480
+ rolling_ic_gate_result, veto_reason_out,
481
+ )
482
+ )
483
+ return allocations
484
+
485
+
486
+ def pct_returns_from_closes(closes: list) -> list[float]:
487
+ """V5.4.4 - close-to-close simple returns from a symbol's rolling
488
+ close window (main.py's `self.symbol_windows[symbol]` "close" values,
489
+ oldest first). The per-symbol return history `apply_hrp_weights()`
490
+ consumes for its covariance estimation.
491
+
492
+ Pairs containing a missing/non-positive/non-finite close are SKIPPED
493
+ (not emitted as NaN/inf/-1.0) - a split-adjusted 0 or a None from a
494
+ partially-populated window would otherwise poison the covariance
495
+ matrix HRP estimates downstream (a 0 CURRENT close would emit a
496
+ -100% return, a variance bomb, even though its previous close is
497
+ technically usable). V5.4.5 (#117): NaN closes pass the old `<= 0.0`
498
+ check (`nan <= 0` is False) and poisoned the covariance the same way.
499
+ Fewer than 2 usable closes returns [] (no return can exist without at
500
+ least one previous close to diff against)."""
501
+ returns: list[float] = []
502
+ for previous_close, current_close in zip(closes, closes[1:]):
503
+ if (
504
+ previous_close is None
505
+ or current_close is None
506
+ or not math.isfinite(float(previous_close))
507
+ or not math.isfinite(float(current_close))
508
+ or previous_close <= 0.0
509
+ or current_close <= 0.0
510
+ ):
511
+ continue
512
+ returns.append(float(current_close) / float(previous_close) - 1.0)
513
+ return returns
514
+
515
+
516
+ def apply_hrp_weights(
517
+ book_allocations: dict[str, BookAllocation],
518
+ returns_by_symbol: dict[str, list[float]],
519
+ gross_exposure: float = 1.0,
520
+ ) -> dict[str, float]:
521
+ """V5.4.4 - HRP book SIZING over an already-selected book. Replaces
522
+ the per-symbol confidence formula (min(max_position_weight,
523
+ 0.10+0.15*confidence) * book_role_multiplier) with Hierarchical Risk
524
+ Parity inverse-variance weights derived from the selected symbols'
525
+ return correlation structure - SAME symbols, SAME roles, different
526
+ sizing only. Selection (build_rank_based_book()) always runs first
527
+ and is never affected; this function must be called with its output.
528
+
529
+ `returns_by_symbol` is `{symbol_key: [close-to-close returns]}` -
530
+ main.py builds it from each selected symbol's own rolling window
531
+ (self.symbol_windows, the 25-bar deque) via pct_returns_from_closes();
532
+ symbols missing from the dict (or with < 2 usable returns) are simply
533
+ excluded from the covariance estimation by build_hrp_allocation()'s
534
+ own filtering, never a KeyError.
535
+
536
+ Per-leg weight contract (mirrors portfolio/hrp_allocation.py::
537
+ build_hrp_allocation(), which this delegates to): with BOTH legs
538
+ populated each leg sums to +/-gross_exposure/2 (dollar-neutral book);
539
+ a single-sided book (long_flat strategy_mode, bottom_n=0) gives the
540
+ present leg the FULL gross. Insufficient history (< 2 symbols with
541
+ usable returns) or an HRP convergence failure degrades to equal
542
+ weight within each leg - sizing always covers exactly the selected
543
+ symbol set, {} only when book_allocations itself is empty.
544
+
545
+ Deliberately a lazy import of portfolio.hrp_allocation inside the
546
+ function body: that module imports scipy at module scope, and this
547
+ keeps book_construction.py (imported by main.py on EVERY run, HRP
548
+ engaged or not) scipy-free on the default "rank" path - the same
549
+ isolator-timeout/import-weight concern that keeps evaluation/
550
+ rank_ic_core.py torch-free (development/Problems.md #16/#102)."""
551
+ if not book_allocations:
552
+ return {}
553
+ long_symbols = [symbol for symbol, allocation in book_allocations.items() if allocation.role == "long"]
554
+ short_symbols = [symbol for symbol, allocation in book_allocations.items() if allocation.role == "short"]
555
+ from portfolio.hrp_allocation import build_hrp_allocation
556
+
557
+ return build_hrp_allocation(
558
+ returns_by_symbol,
559
+ selected_tickers_long=long_symbols,
560
+ selected_tickers_short=short_symbols,
561
+ gross_exposure=gross_exposure,
562
+ )
563
+
564
+
565
+ def should_rebalance_this_bar(
566
+ bar_index: int,
567
+ rebalance_every_bars: int,
568
+ has_previous_allocation: bool,
569
+ is_trading_day_bar: bool = True,
570
+ ) -> bool:
571
+ """Stage 2 of the rank-pivot roadmap (development/Problems.md#43): the
572
+ book previously re-ranked and re-formed on EVERY bar, churning positions
573
+ far faster than rank_20d's actual ~20-trading-day horizon can support
574
+ (653 orders/~2yr, no edge left after costs). This is a pure, main.py-
575
+ independent extraction of main.py::on_data()'s rebalance-gating decision
576
+ so it's unit-testable without a running Lean/QCAlgorithm instance (this
577
+ module has no Lean import, main.py does).
578
+
579
+ `bar_index` is main.py's 1-indexed `self.bar_index` (incremented to 1 on
580
+ the very first `on_data()` call, before any bar has been processed).
581
+ `rebalance_every_bars` is `phase_v2.portfolio_book.rebalance_every_bars`
582
+ (1 reproduces the previous every-bar-rebalance behavior exactly, since
583
+ `(n - 1) % 1 == 0` for every n). `has_previous_allocation` should be
584
+ False only on a genuinely fresh run (no cached book yet) - it forces a
585
+ rebalance regardless of the modulo so the book is never left empty.
586
+
587
+ `is_trading_day_bar` (V5.2.4, development/Problems.md #91) - default
588
+ True, fully backward-compatible with every existing caller. False on a
589
+ tick main.py has determined isn't the equity-session tick (e.g. an
590
+ off-session crypto/forex-only Lean Slice, see main.py's own
591
+ `is_equity_session_bar`) - never rebalances on such a tick, full stop,
592
+ regardless of `bar_index`'s modulo or `has_previous_allocation`. This
593
+ exists because `main.py`'s `bar_index` only advances on equity-session
594
+ ticks post-V5.2.4 - without this explicit gate, several consecutive
595
+ off-session ticks following a real rebalance-eligible bar would keep
596
+ re-evaluating the SAME already-satisfied modulo condition and could
597
+ re-trigger a rebalance that already happened.
598
+
599
+ Returns True on bar_index 1, 1+N, 1+2N, ... (i.e. 0, N, 2N, ... in
600
+ 0-indexed bar terms) - False on every bar in between, where the caller
601
+ should reuse its previously cached allocation and simply hold."""
602
+ if not is_trading_day_bar:
603
+ return False
604
+ if not has_previous_allocation:
605
+ return True
606
+ return (bar_index - 1) % max(1, rebalance_every_bars) == 0
607
+
608
+
609
+ def should_exit_non_selected_book_symbol(
610
+ book_is_active: bool,
611
+ portfolio_book_enabled: bool,
612
+ is_currently_invested: bool,
613
+ ) -> bool:
614
+ """V5.2.4 (development/Problems.md #91) - whether main.py's Pass 2
615
+ should force-close a currently-invested, book-managed position because
616
+ the book engaged this bar and didn't select it (the "rotation exit").
617
+
618
+ Returns False whenever `book_is_active` is False (this bar's
619
+ `book_allocations` came back empty) - matching `build_rank_based_book()`'s
620
+ own documented contract just above: a disengaged book
621
+ (`min_rank_confidence_spread` gate failure, or - pre-V5.2.4 - a thin/
622
+ off-session-tick artifact) is "byte-identical to this module not
623
+ existing at all", never a liquidation trigger. Before this function
624
+ existed, main.py's own Pass 2 violated that documented contract -
625
+ force-selling EVERY currently-invested book symbol whenever
626
+ `book_allocations` didn't contain it, without distinguishing "the whole
627
+ book disengaged today" from "this specific symbol got rotated out of a
628
+ REAL, active book" (the only case that should ever force an exit).
629
+
630
+ Callers pass `book_is_active=bool(book_allocations)` - `symbol_key not
631
+ in book_allocations` (i.e. "this symbol specifically wasn't selected")
632
+ is the caller's own responsibility to check separately, same as before;
633
+ this function only answers "is a force-exit ever appropriate given the
634
+ book's overall state this bar", not "for this particular symbol"."""
635
+ return portfolio_book_enabled and book_is_active and is_currently_invested
636
+
637
+
638
+ def build_book_history_record(
639
+ date_str: str,
640
+ book_allocations: dict[str, BookAllocation],
641
+ raw_scores_by_symbol: dict[str, float],
642
+ target_weights_by_symbol: dict[str, float],
643
+ sector_by_symbol: dict[str, str],
644
+ rank_signal_policy: dict,
645
+ full_universe_signals: dict[str, dict] | None = None,
646
+ book_member_decisions: dict[str, dict] | None = None,
647
+ gate_veto_reason: str | None = None,
648
+ feature_snapshot: dict[str, dict] | None = None,
649
+ ) -> dict:
650
+ """V5.2.2 (development/Problems.md) - a diagnostic snapshot of one
651
+ rebalance bar's book, for `main.py`'s optional (config-gated, off by
652
+ default, backtest-only) book-history log. Exists to make a real,
653
+ ground-truth reconciliation possible between what a live/backtest run
654
+ actually selected and what `evaluation/rank_book_simulator.py`'s
655
+ offline simulator would select for the identical date - the tool
656
+ this project needed after six other hypotheses for a large live-vs-
657
+ offline Sharpe gap were tested and ruled out without finding the
658
+ cause (see Problems.md #89-#90).
659
+
660
+ Pure: no I/O, no `self`, never raises on well-formed inputs - every
661
+ per-symbol field degrades via `.get(..., None)` rather than
662
+ `KeyError`, since a symbol missing from `raw_scores_by_symbol` or
663
+ `target_weights_by_symbol` (e.g. `target_weight` is never populated
664
+ when `phase_v2.portfolio_book.neutrality.enabled` is False - see
665
+ `BookAllocation.target_weight`'s own `None`-means-"not applicable"
666
+ convention) is expected, not exceptional.
667
+
668
+ `rank_signal_policy` (main.py's own `self._rank_signal_policy`,
669
+ resolved once in `Initialize()`) is embedded INLINE in every record,
670
+ not referenced externally - deliberately, so the log stays self-
671
+ contained and correctly interpretable even if config or training
672
+ metrics change between when a run produced this line and when a
673
+ later reconciliation reads it back.
674
+
675
+ `full_universe_signals` (V5.2.3, opt-in via
676
+ `phase_v2.diagnostics.book_history.include_full_universe`): `None`
677
+ (default) reproduces this function's exact V5.2.2 return shape - no
678
+ `"universe"` key at all, so every existing caller/log consumer is
679
+ unaffected. When given `{symbol: {"raw_rank_score", "feature_ready",
680
+ "reason", "trading_eligible", "security_type"}}` (main.py's own Phase
681
+ 1a `signals` payload, one entry per symbol with a bar this tick,
682
+ selected or not), adds a `"universe"` key with the same shape -
683
+ closes the exact gap that made the V5.2.2 tool unable to see WHY a
684
+ symbol was never selected (e.g. crypto/FX consistently absent from
685
+ every live book - Problems.md #91): the V5.2.2 log only ever recorded
686
+ `book_allocations`, i.e. symbols that already made the cut, with no
687
+ way to see what score/readiness a NON-selected symbol had that day.
688
+ Each per-symbol sub-dict degrades the same `.get(..., None)` way as
689
+ every other field here - a caller-supplied dict missing a key never
690
+ raises.
691
+
692
+ `book_member_decisions` (V5.2.6, opt-in via
693
+ `phase_v2.diagnostics.book_history.include_decisions`): `None`
694
+ (default) reproduces this function's exact pre-V5.2.6 return shape -
695
+ no `"book_member_decisions"` key at all, same convention as
696
+ `full_universe_signals`. When given
697
+ `{symbol: MarketAnalysisDecision.to_dict()}` (main.py's own Pass 2,
698
+ collected per book member as each symbol's decision is computed),
699
+ adds a `"book_member_decisions"` key recording each book member's
700
+ final `action`/`reasons` - closes the gap where a symbol could be
701
+ logged here as "selected" via `book_allocations` even though it was
702
+ later diverted to `simulate`/`reduce_risk`/`retrain_candidate` by a
703
+ risk/execution gate `book_allocations` alone can never reveal (see
704
+ development/Problems.md - the crypto/FX zero-order-execution
705
+ finding).
706
+
707
+ `gate_veto_reason` (V5.3.5, development/Problems.md #102): `None`
708
+ (default) omits the key entirely, same convention as the two fields
709
+ above. When main.py calls this on a genuine rebalance bar whose
710
+ `build_rank_based_book()` call came back empty, it passes the
711
+ `veto_reason_out["reason"]` string that call populated (see
712
+ `_select_book_group()`'s own docstring) - without this, a vetoed
713
+ rebalance bar left ZERO trace in this log (the write was gated on
714
+ `book_allocations` being truthy), making it impossible to tell from
715
+ the log alone whether a disengaged stretch came from
716
+ `min_rank_confidence_spread` or the new rolling-IC gate, or anything
717
+ else. main.py now writes a record on every genuine rebalance bar
718
+ regardless of whether `book_allocations` came back empty.
719
+
720
+ `feature_snapshot` (V5.3.5.3 Workstream B, development/Problems.md
721
+ #91/#100): `None` (default) omits the key entirely, same convention
722
+ as the three fields above. When given `{symbol: {feature: value}}`
723
+ (main.py's own `pass1_state[symbol]["feature_payload"]["base_features"]`
724
+ dict, captured ONLY for the symbols in
725
+ `phase_v2.diagnostics.book_history.feature_snapshot_symbols`'s
726
+ allowlist and only when
727
+ `phase_v2.diagnostics.book_history.include_feature_snapshot` is on),
728
+ adds a `"feature_snapshot"` key - the raw per-feature values the live
729
+ model actually saw for those symbols that bar. Exists so
730
+ `evaluation/feature_reconciliation.py::reconcile_feature_snapshot()`
731
+ can diff these against the same (ticker, date) row of
732
+ `ml/datasets/full_dataset.csv`: XOM is the one tracked ticker whose
733
+ live-vs-offline book-selection mismatch never resolved across
734
+ #91/#97/#99/#100, and until this field existed there was nothing in
735
+ the log to diff feature-by-feature. Allowlist-bounded by design -
736
+ without it every bar would serialize ~77 symbols x dozens of floats,
737
+ ballooning this log for a diagnostic only ever needed for a handful
738
+ of tickers."""
739
+ record = {
740
+ "date": date_str,
741
+ "rank_signal_policy": {
742
+ "heads": dict(rank_signal_policy.get("heads", {})),
743
+ "model_priority": list(rank_signal_policy.get("model_priority", [])),
744
+ "demoted": list(rank_signal_policy.get("demoted", [])),
745
+ "normalization": rank_signal_policy.get("normalization", "cross_sectional"),
746
+ },
747
+ "allocations": {
748
+ symbol_key: {
749
+ "role": allocation.role,
750
+ "book_role_multiplier": allocation.book_role_multiplier,
751
+ "predicted_rank_20d": allocation.predicted_rank_20d,
752
+ "rank_head": allocation.rank_head,
753
+ "raw_rank_score": raw_scores_by_symbol.get(symbol_key),
754
+ "target_weight": target_weights_by_symbol.get(symbol_key),
755
+ "sector": sector_by_symbol.get(symbol_key, "Unknown"),
756
+ }
757
+ for symbol_key, allocation in book_allocations.items()
758
+ },
759
+ }
760
+ if full_universe_signals is not None:
761
+ record["universe"] = {
762
+ symbol_key: {
763
+ "raw_rank_score": signal.get("raw_rank_score"),
764
+ "feature_ready": signal.get("feature_ready"),
765
+ "reason": signal.get("reason"),
766
+ "trading_eligible": signal.get("trading_eligible"),
767
+ "security_type": signal.get("security_type"),
768
+ }
769
+ for symbol_key, signal in full_universe_signals.items()
770
+ }
771
+ if book_member_decisions is not None:
772
+ record["book_member_decisions"] = {
773
+ symbol_key: {
774
+ "action": decision.get("action"),
775
+ "reasons": list(decision.get("reasons", [])),
776
+ }
777
+ for symbol_key, decision in book_member_decisions.items()
778
+ }
779
+ if gate_veto_reason is not None:
780
+ record["gate_veto_reason"] = gate_veto_reason
781
+ if feature_snapshot is not None:
782
+ record["feature_snapshot"] = {
783
+ symbol_key: dict(snapshot) if isinstance(snapshot, Mapping) else snapshot
784
+ for symbol_key, snapshot in feature_snapshot.items()
785
+ }
786
+ return record