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,690 @@
1
+ """Calibrates `phase_v2.portfolio_book.min_rank_confidence_spread` from
2
+ real data (V5.1, development/Problems.md) - the same "derive the number,
3
+ don't guess it" discipline `aq evaluate --calibrate-edge` already
4
+ established for `phase_v2.costs.edge_bps_per_rank_unit`.
5
+
6
+ Deliberately reuses, never re-derives: `portfolio.rank_signal.select_raw_rank_score()`
7
+ (the exact blend formula main.py's live Pass 1c applies),
8
+ `portfolio.rank_signal.cross_sectional_rank_scores()` (the exact
9
+ normalization main.py applies before book selection), and
10
+ `portfolio.book_construction.build_rank_based_book()` +
11
+ `compute_confidence_spread()` (the exact selection and gate-comparison
12
+ main.py's live book uses). A calibrated threshold computed here can never
13
+ silently disagree with what the live gate would have done on the same
14
+ historical day, because it is the same code, not a parallel
15
+ approximation.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from portfolio.book_construction import build_rank_based_book, compute_confidence_spread
24
+ from portfolio.rank_signal import cross_sectional_rank_scores, select_raw_rank_score
25
+
26
+ _SPREAD_DISTRIBUTION_PERCENTILES = {"p10": 0.10, "p25": 0.25, "median": 0.50, "p75": 0.75}
27
+
28
+
29
+ def compute_blended_raw_scores(
30
+ dataset: pd.DataFrame,
31
+ predictions_by_model_head: dict[str, dict[str, np.ndarray]],
32
+ policy: dict,
33
+ ) -> pd.Series:
34
+ """Row-by-row call into select_raw_rank_score() - one call per row,
35
+ exactly mirroring main.py's Pass 1c (one blend per symbol per bar).
36
+
37
+ `predictions_by_model_head` is `{"sequence": {"rank_20d": arr,
38
+ "rank_5d": arr}, "multitask": {...}}` - each array is row-index-aligned
39
+ with `dataset` (predict_head()'s own return shape), one entry per head
40
+ the caller actually loaded predictions for. A model absent from this
41
+ dict is treated as unavailable that row (select_raw_rank_score()'s own
42
+ "model still warming up" contract), never a KeyError.
43
+
44
+ Returns a float Series aligned to dataset.index; NaN wherever
45
+ select_raw_rank_score() returns (None, "no_rank_available") for that
46
+ row (every configured head missing a value)."""
47
+ num_rows = len(dataset)
48
+ scores = np.full(num_rows, np.nan, dtype=np.float64)
49
+
50
+ for row_index in range(num_rows):
51
+ payload_by_model: dict[str, dict[str, float] | None] = {}
52
+ for model_name, head_arrays in predictions_by_model_head.items():
53
+ payload: dict[str, float] = {}
54
+ for head_name, values in head_arrays.items():
55
+ value = values[row_index]
56
+ if value is not None and not (isinstance(value, float) and np.isnan(value)):
57
+ payload[head_name] = float(value)
58
+ payload_by_model[model_name] = payload or None
59
+
60
+ score, _source = select_raw_rank_score(
61
+ payload_by_model.get("sequence"), payload_by_model.get("multitask"), policy
62
+ )
63
+ if score is not None:
64
+ scores[row_index] = score
65
+
66
+ return pd.Series(scores, index=dataset.index)
67
+
68
+
69
+ def calibrate_book_confidence_spread(
70
+ dataset: pd.DataFrame,
71
+ *,
72
+ raw_score_column: str,
73
+ ticker_column: str = "ticker",
74
+ date_column: str = "date",
75
+ top_n: int,
76
+ bottom_n: int,
77
+ percentile: float = 0.10,
78
+ ) -> dict:
79
+ """Per date (sorted): normalizes that date's raw scores exactly as
80
+ main.py's Pass 1 does (cross_sectional_rank_scores()), selects the
81
+ natural top_n/bottom_n split with NO gate and NO hysteresis
82
+ (build_rank_based_book(..., min_rank_confidence_spread=0.0,
83
+ previous_allocations=None) - hysteresis is deliberately not replayed
84
+ here: this measures each day's independent natural dispersion, not one
85
+ particular incumbency-dependent walk through history), then computes
86
+ that selection's confidence spread via the SAME compute_confidence_spread()
87
+ the live gate calls.
88
+
89
+ Dates where selection comes back empty (fewer than 2 eligible names,
90
+ or one side has nothing to select at all) are EXCLUDED from the
91
+ distribution, not counted as a 0.0-spread day - a thin-universe date is
92
+ missing data, not evidence of low conviction.
93
+
94
+ Returns:
95
+ {"calibrated_min_rank_confidence_spread": float,
96
+ "percentile": float,
97
+ "num_dates_used": int,
98
+ "num_dates_skipped_thin_universe": int,
99
+ "spread_distribution": {"min", "p10", "p25", "median", "p75", "max"}}
100
+
101
+ An empty/all-thin dataset returns calibrated_min_rank_confidence_spread=0.0
102
+ (the gate's own "always engage" no-op value) and an all-None
103
+ distribution, never raises."""
104
+ spreads: list[float] = []
105
+ num_dates_skipped_thin_universe = 0
106
+
107
+ for _date, group in dataset.groupby(date_column, sort=True):
108
+ raw_scores = {
109
+ str(ticker): float(score)
110
+ for ticker, score in zip(group[ticker_column], group[raw_score_column])
111
+ if score is not None and not (isinstance(score, float) and np.isnan(score))
112
+ }
113
+ normalized = cross_sectional_rank_scores(raw_scores)
114
+ if not normalized:
115
+ num_dates_skipped_thin_universe += 1
116
+ continue
117
+
118
+ candidates = {
119
+ symbol: {"predicted_rank_20d": rank, "trading_eligible": True} for symbol, rank in normalized.items()
120
+ }
121
+ allocations = build_rank_based_book(
122
+ candidates,
123
+ top_n=top_n,
124
+ bottom_n=bottom_n,
125
+ min_rank_confidence_spread=0.0,
126
+ spread_check_ranks=raw_scores,
127
+ )
128
+ if not allocations:
129
+ num_dates_skipped_thin_universe += 1
130
+ continue
131
+
132
+ long_symbols = [symbol for symbol, allocation in allocations.items() if allocation.role == "long"]
133
+ short_symbols = [symbol for symbol, allocation in allocations.items() if allocation.role == "short"]
134
+ spread = compute_confidence_spread(long_symbols, short_symbols, raw_scores, normalized)
135
+ if spread is not None:
136
+ spreads.append(spread)
137
+ else:
138
+ num_dates_skipped_thin_universe += 1
139
+
140
+ if not spreads:
141
+ return {
142
+ "calibrated_min_rank_confidence_spread": 0.0,
143
+ "percentile": percentile,
144
+ "num_dates_used": 0,
145
+ "num_dates_skipped_thin_universe": num_dates_skipped_thin_universe,
146
+ "spread_distribution": {"min": None, "p10": None, "p25": None, "median": None, "p75": None, "max": None},
147
+ }
148
+
149
+ spread_array = np.array(spreads, dtype=np.float64)
150
+ distribution = {"min": float(np.min(spread_array)), "max": float(np.max(spread_array))}
151
+ for name, quantile in _SPREAD_DISTRIBUTION_PERCENTILES.items():
152
+ distribution[name] = float(np.quantile(spread_array, quantile))
153
+
154
+ return {
155
+ "calibrated_min_rank_confidence_spread": float(np.quantile(spread_array, percentile)),
156
+ "percentile": percentile,
157
+ "num_dates_used": len(spreads),
158
+ "num_dates_skipped_thin_universe": num_dates_skipped_thin_universe,
159
+ "spread_distribution": distribution,
160
+ }
161
+
162
+
163
+ def _build_reconciliation_result(
164
+ logged_record: dict,
165
+ offline_allocations: dict,
166
+ raw_scores_by_symbol: dict[str, float],
167
+ ) -> dict:
168
+ """V5.2.3 (development/Problems.md #91) - the diff/shaping core shared
169
+ by `reconcile_book_history_date()` (independent per-date, no
170
+ hysteresis) and `replay_book_history_reconciliation()` (walk-forward,
171
+ hysteresis-replayed): given one logged record and an ALREADY-COMPUTED
172
+ offline `build_rank_based_book()` result for that same date, produces
173
+ the reconciliation dict both callers return. Extracted so the two
174
+ reconciliation modes can never silently disagree on what counts as a
175
+ match/mismatch - see reconcile_book_history_date()'s own docstring for
176
+ the full field-by-field contract this returns (unchanged by this
177
+ refactor)."""
178
+ logged_allocations = logged_record.get("allocations", {})
179
+ logged_long = {symbol for symbol, entry in logged_allocations.items() if entry.get("role") == "long"}
180
+ logged_short = {symbol for symbol, entry in logged_allocations.items() if entry.get("role") == "short"}
181
+
182
+ offline_long = {symbol for symbol, allocation in offline_allocations.items() if allocation.role == "long"}
183
+ offline_short = {symbol for symbol, allocation in offline_allocations.items() if allocation.role == "short"}
184
+
185
+ logged_all = logged_long | logged_short
186
+ offline_all = offline_long | offline_short
187
+
188
+ symbols_matched = sorted(logged_all & offline_all)
189
+ symbols_only_logged = sorted(logged_all - offline_all)
190
+ symbols_only_offline = sorted(offline_all - logged_all)
191
+ role_mismatches = sorted(
192
+ symbol for symbol in symbols_matched if (symbol in logged_long) != (symbol in offline_long)
193
+ )
194
+
195
+ union_size = len(logged_all | offline_all)
196
+ overlap_fraction = (len(symbols_matched) / union_size) if union_size > 0 else None
197
+
198
+ num_offline_long = len(offline_long)
199
+ num_offline_short = len(offline_short)
200
+ per_symbol_deltas: dict[str, dict] = {}
201
+ for symbol in symbols_matched:
202
+ logged_raw_score = logged_allocations[symbol].get("raw_rank_score")
203
+ offline_raw_score = raw_scores_by_symbol.get(symbol)
204
+ raw_score_delta = (
205
+ offline_raw_score - logged_raw_score
206
+ if logged_raw_score is not None and offline_raw_score is not None
207
+ else None
208
+ )
209
+
210
+ logged_target_weight = logged_allocations[symbol].get("target_weight")
211
+ if logged_target_weight is not None and symbol in offline_long and num_offline_long > 0:
212
+ naive_offline_weight = 1.0 / num_offline_long
213
+ elif logged_target_weight is not None and symbol in offline_short and num_offline_short > 0:
214
+ naive_offline_weight = -1.0 / num_offline_short
215
+ else:
216
+ naive_offline_weight = None
217
+ weight_delta = (
218
+ logged_target_weight - naive_offline_weight
219
+ if logged_target_weight is not None and naive_offline_weight is not None
220
+ else None
221
+ )
222
+
223
+ per_symbol_deltas[symbol] = {"raw_score_delta": raw_score_delta, "weight_delta": weight_delta}
224
+
225
+ return {
226
+ "date": logged_record.get("date"),
227
+ "logged_symbols": {"long": sorted(logged_long), "short": sorted(logged_short)},
228
+ "offline_symbols": {"long": sorted(offline_long), "short": sorted(offline_short)},
229
+ "symbols_matched": symbols_matched,
230
+ "symbols_only_logged": symbols_only_logged,
231
+ "symbols_only_offline": symbols_only_offline,
232
+ "role_mismatches": role_mismatches,
233
+ "overlap_fraction": overlap_fraction,
234
+ "per_symbol_deltas": per_symbol_deltas,
235
+ }
236
+
237
+
238
+ def reconcile_book_history_date(
239
+ logged_record: dict,
240
+ raw_scores_by_symbol: dict[str, float],
241
+ *,
242
+ top_n: int,
243
+ bottom_n: int,
244
+ ) -> dict:
245
+ """V5.2.2 (development/Problems.md) - `aq evaluate --reconcile-book-history`'s
246
+ per-date core: compares one `build_book_history_record()` log entry
247
+ (portfolio/book_construction.py - what main.py's LIVE book actually
248
+ selected that date, during a real Lean backtest) against a fresh
249
+ offline re-derivation of what the SAME raw scores would naturally
250
+ select today, with no gate and no hysteresis
251
+ (build_rank_based_book(..., min_rank_confidence_spread=0.0,
252
+ previous_allocations=None) - the exact same "independent natural
253
+ selection" convention calibrate_book_confidence_spread() above
254
+ already established).
255
+
256
+ `previous_allocations=None` is deliberate, not an oversight: this
257
+ cannot replay the live book's actual incumbency history from a single
258
+ date's log entry, so a flagged mismatch here can mean either a real
259
+ divergence OR "hysteresis correctly kept an incumbent that day's
260
+ natural ranking alone wouldn't pick" - callers printing this result
261
+ must carry that caveat, never over-read a mismatch as necessarily a
262
+ bug. `replay_book_history_reconciliation()` below is the hysteresis-
263
+ aware alternative (V5.2.3) when a caller has the FULL date-sorted log,
264
+ not just one record.
265
+
266
+ Weight comparison is best-effort and deliberately naive: offline has
267
+ no access to the live per-symbol `confidence` value or the neutrality
268
+ pass's inputs (both needed to reproduce main.py's real sizing formula),
269
+ so `per_symbol_deltas[symbol]["weight_delta"]` is computed against a
270
+ naive equal-weight baseline (`book_role_multiplier / count-on-that-role`),
271
+ not the live formula - it measures how far the logged (post-neutrality,
272
+ post-confidence-sizing) weight moved from a symmetric equal-weight
273
+ book, not a reconstruction error. `None` whenever the logged record's
274
+ own `target_weight` is `None` (neutrality was off for that run).
275
+
276
+ Returns:
277
+ {"date", "logged_symbols": {"long", "short"}, "offline_symbols":
278
+ {"long", "short"}, "symbols_matched", "symbols_only_logged",
279
+ "symbols_only_offline", "role_mismatches", "overlap_fraction",
280
+ "per_symbol_deltas": {symbol: {"raw_score_delta", "weight_delta"}}}
281
+
282
+ `overlap_fraction` is `symbols_matched / (logged | offline)`, `None`
283
+ when both sides are empty (nothing to compare, not a 0% overlap).
284
+
285
+ V5.3.1 (development/Problems.md #91/#97): `trading_eligible` used to
286
+ be hardcoded `True` for every symbol here, regardless of whether the
287
+ real live run actually considered it eligible that date - now read
288
+ from `logged_record["universe"][symbol]["trading_eligible"]` when
289
+ that data is present (`phase_v2.diagnostics.book_history.
290
+ include_full_universe` was on for the run that produced this log),
291
+ the SAME real per-date eligibility main.py's own book construction
292
+ used (main.py:2768). Falls back to `True` (today's exact prior
293
+ behavior, byte-identical) for any symbol missing from `"universe"` or
294
+ for a log with no `"universe"` key at all - never a KeyError."""
295
+ universe = logged_record.get("universe") or {}
296
+ normalized = cross_sectional_rank_scores(raw_scores_by_symbol)
297
+ candidates = {
298
+ symbol: {
299
+ "predicted_rank_20d": rank,
300
+ "trading_eligible": universe.get(symbol, {}).get("trading_eligible", True),
301
+ }
302
+ for symbol, rank in normalized.items()
303
+ }
304
+ offline_allocations = build_rank_based_book(
305
+ candidates,
306
+ top_n=top_n,
307
+ bottom_n=bottom_n,
308
+ min_rank_confidence_spread=0.0,
309
+ spread_check_ranks=raw_scores_by_symbol,
310
+ )
311
+ return _build_reconciliation_result(logged_record, offline_allocations, raw_scores_by_symbol)
312
+
313
+
314
+ def replay_book_history_reconciliation(
315
+ logged_records: list[dict],
316
+ raw_scores_by_date: dict[str, dict[str, float]],
317
+ *,
318
+ top_n: int,
319
+ bottom_n: int,
320
+ hysteresis_rank_margin: float = 0.0,
321
+ ) -> list[dict]:
322
+ """V5.2.3 (development/Problems.md #91) - the hysteresis-aware
323
+ alternative to calling `reconcile_book_history_date()` independently
324
+ per date. `reconcile_book_history_date()` always passes
325
+ `previous_allocations=None`, so it cannot tell "the live book
326
+ genuinely diverged" apart from "the live book correctly held an
327
+ incumbent a from-scratch reselection wouldn't naturally pick" - this
328
+ function replays offline's OWN walk-forward selection across
329
+ `logged_records` (must already be date-sorted - not re-sorted here,
330
+ same precondition `evaluation/model_predictions.py::build_sequence_windows()`
331
+ already places on its own caller), carrying a `held_allocations` dict
332
+ forward exactly the way `evaluation/rank_book_simulator.py::_simulate_rank_book_core()`
333
+ and `main.py`'s own `_last_book_allocations` both already do: computed
334
+ with the STILL-OLD `held_allocations` as `previous_allocations`, then
335
+ overwritten from THAT SAME call's own output (never from the logged
336
+ record - offline's own history, not live's) before the next date.
337
+
338
+ `hysteresis_rank_margin=0.0` reproduces `build_rank_based_book()`'s
339
+ own no-op default, so passing it unset degrades to (a slower way of
340
+ computing) `reconcile_book_history_date()`'s independent-per-date
341
+ result for every date except that `held_allocations` is still seeded
342
+ from offline's own prior date rather than nothing - only truly
343
+ identical when `hysteresis_rank_margin` is also 0.0 everywhere.
344
+
345
+ Cold-start caveat: `held_allocations` starts EMPTY at the first
346
+ record in `logged_records`, regardless of how long the live book's
347
+ true incumbency history was before that date - early results in the
348
+ returned list are more likely to show a "divergence" that is really
349
+ just this function's own cold start, not live's. A caller printing
350
+ this should say so, not just print the raw numbers.
351
+
352
+ PRECONDITION (V5.3.2, development/Problems.md #91/#97/#99): `logged_records`
353
+ must be a SINGLE run's own records - e.g. one element of
354
+ `segment_logged_records_by_run()`'s return - never the raw cumulative
355
+ `visualization/book_history.jsonl` content as-is. That file accumulates
356
+ every historical backtest forever; passing its unsegmented content here
357
+ silently carries `held_allocations` across a real run boundary as if it
358
+ were one continuous backtest, corrupting incumbency for any symbol near
359
+ a selection boundary on those dates (confirmed empirically: 160 of 174
360
+ unique dates in the real file recur across more than one run, one date
361
+ in as many as 8). This is exactly the bug aq_cli.py's
362
+ --reconcile-book-history dispatch now guards against at the call site by
363
+ segmenting first - this function itself does not enforce it, since it
364
+ has no way to distinguish "one real run" from "one caller-assembled
365
+ slice" and never should (its whole job is to replay whatever forward-
366
+ sorted sequence of dates it's handed).
367
+
368
+ Returns a list of `_build_reconciliation_result()`-shaped dicts, one
369
+ per record in `logged_records`, in the same order. A `logged_record`
370
+ with no matching entry in `raw_scores_by_date` (e.g. that date fell
371
+ outside a caller's re-inference window) is skipped entirely - not a
372
+ KeyError, and `held_allocations` simply carries through unchanged to
373
+ the next date it CAN compute, the same "missing day doesn't reset
374
+ state" behavior a live gap in trading data would also produce."""
375
+ held_allocations: dict = {}
376
+ results: list[dict] = []
377
+ for logged_record in logged_records:
378
+ date = logged_record.get("date")
379
+ raw_scores_by_symbol = raw_scores_by_date.get(date)
380
+ if raw_scores_by_symbol is None:
381
+ continue
382
+
383
+ # V5.3.1 (development/Problems.md #91/#97) - reads real per-date
384
+ # trading_eligible from the logged "universe" payload when
385
+ # available, same as reconcile_book_history_date() above (see its
386
+ # docstring); falls back to True (unchanged prior behavior) when
387
+ # absent.
388
+ universe = logged_record.get("universe") or {}
389
+ normalized = cross_sectional_rank_scores(raw_scores_by_symbol)
390
+ candidates = {
391
+ symbol: {
392
+ "predicted_rank_20d": rank,
393
+ "trading_eligible": universe.get(symbol, {}).get("trading_eligible", True),
394
+ }
395
+ for symbol, rank in normalized.items()
396
+ }
397
+ offline_allocations = build_rank_based_book(
398
+ candidates,
399
+ top_n=top_n,
400
+ bottom_n=bottom_n,
401
+ min_rank_confidence_spread=0.0,
402
+ spread_check_ranks=raw_scores_by_symbol,
403
+ previous_allocations=held_allocations or None,
404
+ hysteresis_rank_margin=hysteresis_rank_margin,
405
+ )
406
+ results.append(_build_reconciliation_result(logged_record, offline_allocations, raw_scores_by_symbol))
407
+ held_allocations = offline_allocations
408
+
409
+ return results
410
+
411
+
412
+ def summarize_book_history_reconciliation(per_date_results: list[dict]) -> dict:
413
+ """Aggregates a list of reconcile_book_history_date() results (one per
414
+ logged date) into a single report. An empty list returns a defined
415
+ all-zero/None summary, never raises - the CLI orchestrator always calls
416
+ this even when the log file turned out to contain zero usable dates.
417
+
418
+ `mean_weight_delta_abs`/`num_dates_with_weight_logged` are computed
419
+ only over dates that had at least one symbol with a non-None logged
420
+ `target_weight` (neutrality was on for that run) - callers must report
421
+ "weight deltas computed for N/M dates" rather than silently averaging
422
+ the missing dates away as zero, per reconcile_book_history_date()'s own
423
+ weight-comparison caveat."""
424
+ num_dates = len(per_date_results)
425
+ if num_dates == 0:
426
+ return {
427
+ "num_dates": 0,
428
+ "num_dates_exact_match": 0,
429
+ "mean_overlap_fraction": None,
430
+ "mean_raw_score_delta_abs": None,
431
+ "mean_weight_delta_abs": None,
432
+ "num_dates_with_weight_logged": 0,
433
+ "num_symbols_only_logged_total": 0,
434
+ "num_symbols_only_offline_total": 0,
435
+ }
436
+
437
+ num_dates_exact_match = sum(
438
+ 1
439
+ for result in per_date_results
440
+ if not result["symbols_only_logged"] and not result["symbols_only_offline"] and not result["role_mismatches"]
441
+ )
442
+ overlap_fractions = [result["overlap_fraction"] for result in per_date_results if result["overlap_fraction"] is not None]
443
+ raw_score_deltas = [
444
+ abs(delta["raw_score_delta"])
445
+ for result in per_date_results
446
+ for delta in result["per_symbol_deltas"].values()
447
+ if delta["raw_score_delta"] is not None
448
+ ]
449
+ weight_deltas = [
450
+ abs(delta["weight_delta"])
451
+ for result in per_date_results
452
+ for delta in result["per_symbol_deltas"].values()
453
+ if delta["weight_delta"] is not None
454
+ ]
455
+ num_dates_with_weight_logged = sum(
456
+ 1
457
+ for result in per_date_results
458
+ if any(delta["weight_delta"] is not None for delta in result["per_symbol_deltas"].values())
459
+ )
460
+
461
+ return {
462
+ "num_dates": num_dates,
463
+ "num_dates_exact_match": num_dates_exact_match,
464
+ "mean_overlap_fraction": float(np.mean(overlap_fractions)) if overlap_fractions else None,
465
+ "mean_raw_score_delta_abs": float(np.mean(raw_score_deltas)) if raw_score_deltas else None,
466
+ "mean_weight_delta_abs": float(np.mean(weight_deltas)) if weight_deltas else None,
467
+ "num_dates_with_weight_logged": num_dates_with_weight_logged,
468
+ "num_symbols_only_logged_total": sum(len(result["symbols_only_logged"]) for result in per_date_results),
469
+ "num_symbols_only_offline_total": sum(len(result["symbols_only_offline"]) for result in per_date_results),
470
+ }
471
+
472
+
473
+ def summarize_universe_snapshot_by_security_type(logged_records: list[dict]) -> dict:
474
+ """V5.2.3 (development/Problems.md #91) - aggregates the OPTIONAL
475
+ `"universe"` key `build_book_history_record()` adds when
476
+ `phase_v2.diagnostics.book_history.include_full_universe` was on for
477
+ the run that produced `logged_records` (V5.2.2 logs, and any V5.2.3
478
+ log written with the toggle still off, simply have no `"universe"`
479
+ key on any record - this function reads straight off the log's own
480
+ data, no re-inference or model loading needed, so it is always cheap
481
+ to attempt).
482
+
483
+ Exists to answer, directly from data, the question code-reading alone
484
+ could not settle: does a `security_type` that's absent from every
485
+ live book (e.g. crypto/FX, Problems.md #91) sit there because it
486
+ never gets a usable feature/score (`feature_ready` false), or because
487
+ it gets a real score that's simply never extreme enough to be
488
+ selected (`feature_ready` true, `mean_raw_rank_score` unremarkable)?
489
+
490
+ Per `security_type`: `num_symbol_dates` (raw count of (symbol, date)
491
+ observations seen for it - NOT unique symbols), `mean_raw_rank_score`
492
+ (over only the observations where `raw_rank_score` is not None -
493
+ never averages a missing score in as 0), `feature_ready_rate`,
494
+ `trading_eligible_rate`.
495
+
496
+ Returns `{"num_dates_with_universe_data": int, "by_security_type":
497
+ {security_type: {...}}}`. `num_dates_with_universe_data` is 0 (and
498
+ `by_security_type` is `{}`) when no record in `logged_records` carries
499
+ a `"universe"` key at all - callers must check this before reading
500
+ `by_security_type`, since a 0 there means "toggle was off for this
501
+ log", not "every security type had zero observations". Never raises
502
+ on an empty or all-missing-universe input."""
503
+ dates_with_universe_data = 0
504
+ observations_by_security_type: dict[str, list[dict]] = {}
505
+
506
+ for logged_record in logged_records:
507
+ universe = logged_record.get("universe")
508
+ if not universe:
509
+ continue
510
+ dates_with_universe_data += 1
511
+ for signal in universe.values():
512
+ security_type = signal.get("security_type") or "unknown"
513
+ observations_by_security_type.setdefault(security_type, []).append(signal)
514
+
515
+ by_security_type: dict[str, dict] = {}
516
+ for security_type, observations in observations_by_security_type.items():
517
+ raw_scores = [obs["raw_rank_score"] for obs in observations if obs.get("raw_rank_score") is not None]
518
+ num_observations = len(observations)
519
+ by_security_type[security_type] = {
520
+ "num_symbol_dates": num_observations,
521
+ "mean_raw_rank_score": float(np.mean(raw_scores)) if raw_scores else None,
522
+ "feature_ready_rate": (
523
+ sum(1 for obs in observations if obs.get("feature_ready")) / num_observations
524
+ if num_observations > 0
525
+ else None
526
+ ),
527
+ "trading_eligible_rate": (
528
+ sum(1 for obs in observations if obs.get("trading_eligible")) / num_observations
529
+ if num_observations > 0
530
+ else None
531
+ ),
532
+ }
533
+
534
+ return {
535
+ "num_dates_with_universe_data": dates_with_universe_data,
536
+ "by_security_type": by_security_type,
537
+ }
538
+
539
+
540
+ def segment_logged_records_by_run(logged_records: list[dict]) -> list[list[dict]]:
541
+ """V5.3.2 (development/Problems.md #91/#97/#99) - `visualization/
542
+ book_history.jsonl` (and any log built the same way) is a cumulative,
543
+ NEVER-rotated file: every real backtest run's records get appended to
544
+ the same file, one after another, forever - a single real backtest
545
+ always logs forward in time, so a run boundary is wherever the date
546
+ sequence goes backwards (`record["date"] < previous_record["date"]`).
547
+ Extracted from summarize_universe_presence_by_symbol() (V5.3.1) so
548
+ aq_cli.py's --reconcile-book-history dispatch can reuse the identical
549
+ boundary-detection logic instead of re-deriving it a second time (see
550
+ that command's own V5.3.2 fix for why reconciling against an
551
+ unsegmented cumulative log silently merges unrelated runs together -
552
+ confirmed against the real file: 160 of 174 unique dates recur across
553
+ more than one run, one date in as many as 8).
554
+
555
+ A record with a missing `"date"` never triggers a boundary and is
556
+ folded into whichever run is currently open.
557
+
558
+ Returns the run segments (each a list of `logged_records` entries, in
559
+ original order) in the same chronological order as `logged_records`
560
+ itself - `result[-1]` is always the most recent run. Empty input
561
+ returns `[]`, never raises."""
562
+ runs: list[list[dict]] = []
563
+ current_run: list[dict] = []
564
+ previous_date: str | None = None
565
+ for record in logged_records:
566
+ date = record.get("date")
567
+ if previous_date is not None and date is not None and date < previous_date:
568
+ if current_run:
569
+ runs.append(current_run)
570
+ current_run = []
571
+ current_run.append(record)
572
+ previous_date = date if date is not None else previous_date
573
+
574
+ if current_run:
575
+ runs.append(current_run)
576
+
577
+ return runs
578
+
579
+
580
+ def summarize_universe_presence_by_symbol(logged_records: list[dict]) -> dict:
581
+ """V5.3.1 (development/Problems.md #91/#97) - `visualization/
582
+ book_history.jsonl` is a cumulative, NEVER-rotated log: every real
583
+ backtest run's records get appended to the same file, one after
584
+ another, forever. Averaging a per-symbol statistic across the whole
585
+ file (as an earlier round of this investigation did, finding "FX/crypto
586
+ absent from the universe on 32% of dates") silently mixes many
587
+ different runs together - a genuine, now-fixed gap in one old run can
588
+ look like a smaller, still-live problem once diluted into the average
589
+ of several newer, clean runs. This function segments `logged_records`
590
+ into contiguous runs FIRST (via segment_logged_records_by_run(), V5.3.2),
591
+ then computes each symbol's universe-absence rate PER RUN, so a stale
592
+ run's numbers can never dilute a fresh run's, and a genuine regression
593
+ in the MOST RECENT run (`result["runs"][-1]`) is always visible on its
594
+ own, not averaged away.
595
+
596
+ Absence here means "not a key in that date's `universe` payload at
597
+ all" (main.py's `signals` dict, main.py:2761-2772 - the SAME data that
598
+ also feeds real book candidacy, main.py:1735-1736's `continue` gate -
599
+ see development/Problems.md #97 for why this is a real book-
600
+ construction signal, not merely a diagnostic one). Only symbols that
601
+ appear in the universe payload on AT LEAST one date within a run are
602
+ scored for that run (an unrelated ticker that's never in ANY
603
+ universe snapshot isn't "100% absent", it's simply out of scope).
604
+
605
+ Same "log carries no data, degrade to zeros" contract as
606
+ summarize_universe_snapshot_by_security_type() - a `logged_records`
607
+ with no `"universe"` key anywhere returns `{"num_runs_detected": 0,
608
+ "runs": []}`, never raises.
609
+
610
+ Returns `{"num_runs_detected": int, "runs": [{"start_date", "end_date",
611
+ "num_records", "absence_rate_by_symbol": {symbol: float}}, ...]}`,
612
+ runs in the same chronological order as `logged_records` itself."""
613
+ runs = segment_logged_records_by_run(logged_records)
614
+
615
+ run_summaries: list[dict] = []
616
+ for run_records in runs:
617
+ universe_records = [record for record in run_records if record.get("universe")]
618
+ num_records = len(universe_records)
619
+ if num_records == 0:
620
+ continue
621
+
622
+ all_symbols: set[str] = set()
623
+ for record in universe_records:
624
+ all_symbols.update(record["universe"].keys())
625
+
626
+ absence_rate_by_symbol = {
627
+ symbol: 1.0 - (sum(1 for record in universe_records if symbol in record["universe"]) / num_records)
628
+ for symbol in all_symbols
629
+ }
630
+
631
+ run_summaries.append(
632
+ {
633
+ "start_date": universe_records[0].get("date"),
634
+ "end_date": universe_records[-1].get("date"),
635
+ "num_records": num_records,
636
+ "absence_rate_by_symbol": absence_rate_by_symbol,
637
+ }
638
+ )
639
+
640
+ return {"num_runs_detected": len(run_summaries), "runs": run_summaries}
641
+
642
+
643
+ def summarize_book_member_diversion(logged_records: list[dict]) -> dict:
644
+ """V5.2.6 (development/Problems.md) - aggregates the OPTIONAL
645
+ `"book_member_decisions"` key `build_book_history_record()` adds when
646
+ `phase_v2.diagnostics.book_history.include_decisions` was on for the
647
+ run that produced `logged_records` - mirrors
648
+ `summarize_universe_snapshot_by_security_type()`'s own shape and
649
+ "log carries no data, degrade to zeros" contract exactly.
650
+
651
+ Exists to replace hypothesis with measurement: `book_allocations`
652
+ alone can never show whether a book-selected symbol actually became
653
+ a real trade, or was silently diverted to `simulate`/`reduce_risk`/
654
+ `retrain_candidate` by a risk/execution gate downstream of selection
655
+ (development/Problems.md - the crypto/FX zero-order-execution
656
+ finding, and the live-risk-gate-vs-offline-evaluator gap). Per-run,
657
+ NOT per-date - a single aggregate is more useful here than a per-date
658
+ breakdown, since the question is "how much, in total, across this
659
+ whole run" rather than "on which specific date".
660
+
661
+ Returns `{"num_records_with_decisions": int, "total_book_member_dates":
662
+ int, "action_counts": {action: count}, "reason_counts": {reason: count}}`.
663
+ `num_records_with_decisions` is 0 (and both count dicts are `{}`) when
664
+ no record in `logged_records` carries a `"book_member_decisions"` key
665
+ at all - callers must check this before reading the counts, same as
666
+ `num_dates_with_universe_data`'s own convention above. Never raises on
667
+ an empty or all-missing-decisions input."""
668
+ num_records_with_decisions = 0
669
+ total_book_member_dates = 0
670
+ action_counts: dict[str, int] = {}
671
+ reason_counts: dict[str, int] = {}
672
+
673
+ for logged_record in logged_records:
674
+ decisions = logged_record.get("book_member_decisions")
675
+ if not decisions:
676
+ continue
677
+ num_records_with_decisions += 1
678
+ for decision in decisions.values():
679
+ total_book_member_dates += 1
680
+ action = decision.get("action") or "unknown"
681
+ action_counts[action] = action_counts.get(action, 0) + 1
682
+ for reason in decision.get("reasons", []) or []:
683
+ reason_counts[reason] = reason_counts.get(reason, 0) + 1
684
+
685
+ return {
686
+ "num_records_with_decisions": num_records_with_decisions,
687
+ "total_book_member_dates": total_book_member_dates,
688
+ "action_counts": action_counts,
689
+ "reason_counts": reason_counts,
690
+ }