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
|
@@ -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
|