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,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
|
+
}
|