fa-quant 1.2.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.
- fa_quant-1.2.1.dist-info/METADATA +236 -0
- fa_quant-1.2.1.dist-info/RECORD +106 -0
- fa_quant-1.2.1.dist-info/WHEEL +4 -0
- fa_quant-1.2.1.dist-info/entry_points.txt +2 -0
- fa_quant-1.2.1.dist-info/licenses/LICENSE +21 -0
- financial_agent/__init__.py +1 -0
- financial_agent/agents/__init__.py +0 -0
- financial_agent/agents/claude/__init__.py +0 -0
- financial_agent/agents/gpt/__init__.py +0 -0
- financial_agent/agents/grok/__init__.py +0 -0
- financial_agent/dl/__init__.py +0 -0
- financial_agent/dl/models/__init__.py +0 -0
- financial_agent/dl/models/baseline_model.py +23 -0
- financial_agent/dl/preprocessing/__init__.py +0 -0
- financial_agent/dl/preprocessing/data_windowing.py +135 -0
- financial_agent/harness/__init__.py +73 -0
- financial_agent/harness/__main__.py +430 -0
- financial_agent/harness/_system/factor_scoring.py +175 -0
- financial_agent/harness/_system/settlement_ledger.py +1222 -0
- financial_agent/harness/audit.py +393 -0
- financial_agent/harness/equity_policy.toml +179 -0
- financial_agent/harness/fingerprint.py +55 -0
- financial_agent/harness/gates.py +522 -0
- financial_agent/harness/handlers.py +77 -0
- financial_agent/harness/helpers.py +47 -0
- financial_agent/harness/kernels.py +696 -0
- financial_agent/harness/ledger.py +325 -0
- financial_agent/harness/lifecycle.py +127 -0
- financial_agent/harness/manifest.py +113 -0
- financial_agent/harness/market_calendar.py +50 -0
- financial_agent/harness/mcp.py +505 -0
- financial_agent/harness/policy.py +248 -0
- financial_agent/harness/runner.py +275 -0
- financial_agent/harness/schema.py +451 -0
- financial_agent/harness/scoring.py +363 -0
- financial_agent/harness/skills.py +458 -0
- financial_agent/quant/__init__.py +1 -0
- financial_agent/quant/__main__.py +506 -0
- financial_agent/quant/backtest/__init__.py +16 -0
- financial_agent/quant/backtest/config.py +74 -0
- financial_agent/quant/backtest/costs.py +62 -0
- financial_agent/quant/backtest/engine.py +248 -0
- financial_agent/quant/backtest/metrics.py +102 -0
- financial_agent/quant/backtest/result.py +51 -0
- financial_agent/quant/data/__init__.py +21 -0
- financial_agent/quant/data/bars.py +103 -0
- financial_agent/quant/data/cache.py +168 -0
- financial_agent/quant/data/providers.py +138 -0
- financial_agent/quant/data/quality.py +104 -0
- financial_agent/quant/data/synthetic.py +67 -0
- financial_agent/quant/execution/__init__.py +24 -0
- financial_agent/quant/execution/alpaca.py +293 -0
- financial_agent/quant/execution/broker.py +98 -0
- financial_agent/quant/execution/ibkr.py +634 -0
- financial_agent/quant/execution/journal.py +139 -0
- financial_agent/quant/execution/paper.py +254 -0
- financial_agent/quant/execution/reconcile.py +35 -0
- financial_agent/quant/mcp.py +210 -0
- financial_agent/quant/portfolio.py +56 -0
- financial_agent/quant/quant_policy.toml +40 -0
- financial_agent/quant/risk/__init__.py +21 -0
- financial_agent/quant/risk/killswitch.py +61 -0
- financial_agent/quant/risk/limits.py +109 -0
- financial_agent/quant/risk/orders.py +91 -0
- financial_agent/quant/risk/pretrade.py +157 -0
- financial_agent/quant/risk/state.py +86 -0
- financial_agent/quant/runner/__init__.py +20 -0
- financial_agent/quant/runner/config.py +254 -0
- financial_agent/quant/runner/promotion.py +277 -0
- financial_agent/quant/runner/report.py +231 -0
- financial_agent/quant/runner/session.py +705 -0
- financial_agent/quant/strategies/__init__.py +19 -0
- financial_agent/quant/strategies/base.py +44 -0
- financial_agent/quant/strategies/benchmarks.py +54 -0
- financial_agent/quant/strategies/momentum.py +80 -0
- financial_agent/quant/strategies/registry.py +65 -0
- financial_agent/quant/strategies/research.py +226 -0
- financial_agent/quant/strategies/trend.py +91 -0
- financial_agent/quant/validation/__init__.py +41 -0
- financial_agent/quant/validation/bootstrap.py +39 -0
- financial_agent/quant/validation/certificate.py +189 -0
- financial_agent/quant/validation/gate.py +304 -0
- financial_agent/quant/validation/overfitting.py +148 -0
- financial_agent/quant/validation/stress.py +33 -0
- financial_agent/quant/validation/walkforward.py +157 -0
- financial_agent/rl/__init__.py +13 -0
- financial_agent/rl/agent.py +112 -0
- financial_agent/rl/environment.py +177 -0
- financial_agent/rl/trainer.py +142 -0
- financial_agent/trading/__init__.py +0 -0
- financial_agent/trading/autotrain/__init__.py +0 -0
- financial_agent/trading/autotrain/training.py +92 -0
- financial_agent/trading/backtest.py +118 -0
- financial_agent/trading/config/__init__.py +0 -0
- financial_agent/trading/config/config.py +1587 -0
- financial_agent/trading/marketdata/__init__.py +0 -0
- financial_agent/trading/marketdata/yahoodownloader.py +99 -0
- financial_agent/trading/model/__init__.py +0 -0
- financial_agent/trading/model/models.py +466 -0
- financial_agent/trading/preprocessing/__init__.py +0 -0
- financial_agent/trading/preprocessing/data.py +32 -0
- financial_agent/trading/preprocessing/preprocessors.py +162 -0
- financial_agent/trading/utils/__init__.py +0 -0
- financial_agent/trading/utils/gpu_config.py +213 -0
- financial_agent/trading/utils/main.py +52 -0
- financial_agent/trading/utils/setup_gpu_for_notebooks.py +130 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Canonical settlement ledger for the daily investment system.
|
|
3
|
+
|
|
4
|
+
Prediction source ledgers (`15_predictions.json`) are immutable and retain
|
|
5
|
+
`status: OPEN` forever; every model's daily run independently re-scans prior
|
|
6
|
+
packages and appends its own `settlements` rows for whatever it believes is
|
|
7
|
+
due. Because many runs settle the same logical prediction, the same
|
|
8
|
+
canonical settlement accumulates duplicate rows across packages -- often
|
|
9
|
+
with conflicting settlement-timing conventions (see `rules.md` Settlement
|
|
10
|
+
Rules) and with key names that drifted across sessions (`settle_price` /
|
|
11
|
+
`settlement_price` / `current_price`, `settle_date` / `settlement_date` /
|
|
12
|
+
`current_price_date`, etc.).
|
|
13
|
+
|
|
14
|
+
This module is the single normalizer, timing validator, and precedence
|
|
15
|
+
resolver for that data. It only reads `agents/equity/output/*/15_predictions.json`
|
|
16
|
+
files and produces a derived, machine-readable manifest -- it never edits a
|
|
17
|
+
source file. See `plan/2026-07-15-canonical-settlement-ledger.md` for the
|
|
18
|
+
design this implements.
|
|
19
|
+
|
|
20
|
+
Canonical key: (model, vintage, ticker, type, target_date), where `vintage`
|
|
21
|
+
is the run_date of the *original* prediction and `type` defaults to
|
|
22
|
+
"EQUITY_ALPHA" when absent, per rules.md.
|
|
23
|
+
|
|
24
|
+
A package whose prediction book is a copy of another package's (the same
|
|
25
|
+
forecasts republished under a second model name) would put the same forecasts
|
|
26
|
+
under a second set of keys and double-count them. `find_duplicate_books` detects
|
|
27
|
+
these by content fingerprint; the copy is reported as DUPLICATE_OF its original
|
|
28
|
+
and contributes nothing to canonical settlements, due inventory, or metrics.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import datetime as dt
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
import re
|
|
39
|
+
import sys
|
|
40
|
+
from collections import defaultdict
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, Iterable
|
|
44
|
+
from zoneinfo import ZoneInfo
|
|
45
|
+
|
|
46
|
+
CanonicalKey = tuple[str, str, str, str, str]
|
|
47
|
+
|
|
48
|
+
FORECAST_HORIZON_DAYS = 28
|
|
49
|
+
TRACK_A_MIN_SETTLED_N = 20
|
|
50
|
+
TRACK_A_MIN_EFFECTIVE_N = 3
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Trading calendar (weekends + computed NYSE holiday rules; no hardcoded
|
|
55
|
+
# per-year table so this keeps working past 2026 without maintenance).
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _nth_weekday(year: int, month: int, weekday: int, n: int) -> dt.date:
|
|
60
|
+
"""The n-th (1-indexed) occurrence of `weekday` (Mon=0) in year/month."""
|
|
61
|
+
first = dt.date(year, month, 1)
|
|
62
|
+
offset = (weekday - first.weekday()) % 7
|
|
63
|
+
return first + dt.timedelta(days=offset + 7 * (n - 1))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _last_weekday(year: int, month: int, weekday: int) -> dt.date:
|
|
67
|
+
if month == 12:
|
|
68
|
+
last_of_month = dt.date(year + 1, 1, 1) - dt.timedelta(days=1)
|
|
69
|
+
else:
|
|
70
|
+
last_of_month = dt.date(year, month + 1, 1) - dt.timedelta(days=1)
|
|
71
|
+
offset = (last_of_month.weekday() - weekday) % 7
|
|
72
|
+
return last_of_month - dt.timedelta(days=offset)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _easter_sunday(year: int) -> dt.date:
|
|
76
|
+
"""Anonymous Gregorian algorithm for the date of Easter Sunday."""
|
|
77
|
+
a = year % 19
|
|
78
|
+
b = year // 100
|
|
79
|
+
c = year % 100
|
|
80
|
+
d = b // 4
|
|
81
|
+
e = b % 4
|
|
82
|
+
f = (b + 8) // 25
|
|
83
|
+
g = (b - f + 1) // 3
|
|
84
|
+
h = (19 * a + b - d - g + 15) % 30
|
|
85
|
+
i = c // 4
|
|
86
|
+
k = c % 4
|
|
87
|
+
ll = (32 + 2 * e + 2 * i - h - k) % 7
|
|
88
|
+
m = (a + 11 * h + 22 * ll) // 451
|
|
89
|
+
month = (h + ll - 7 * m + 114) // 31
|
|
90
|
+
day = ((h + ll - 7 * m + 114) % 31) + 1
|
|
91
|
+
return dt.date(year, month, day)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _observed(holiday: dt.date) -> dt.date:
|
|
95
|
+
"""NYSE fixed-date holidays: Sat -> preceding Fri, Sun -> following Mon."""
|
|
96
|
+
if holiday.weekday() == 5:
|
|
97
|
+
return holiday - dt.timedelta(days=1)
|
|
98
|
+
if holiday.weekday() == 6:
|
|
99
|
+
return holiday + dt.timedelta(days=1)
|
|
100
|
+
return holiday
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def us_market_holidays(year: int) -> set[dt.date]:
|
|
104
|
+
"""Approximate NYSE holiday calendar for `year`."""
|
|
105
|
+
holidays = {
|
|
106
|
+
_observed(dt.date(year, 1, 1)), # New Year's Day
|
|
107
|
+
_nth_weekday(year, 1, 0, 3), # MLK Day
|
|
108
|
+
_nth_weekday(year, 2, 0, 3), # Washington's Birthday
|
|
109
|
+
_easter_sunday(year) - dt.timedelta(days=2), # Good Friday
|
|
110
|
+
_last_weekday(year, 5, 0), # Memorial Day
|
|
111
|
+
_observed(dt.date(year, 6, 19)), # Juneteenth
|
|
112
|
+
_observed(dt.date(year, 7, 4)), # Independence Day
|
|
113
|
+
_nth_weekday(year, 9, 0, 1), # Labor Day
|
|
114
|
+
_nth_weekday(year, 11, 3, 4), # Thanksgiving
|
|
115
|
+
_observed(dt.date(year, 12, 25)), # Christmas
|
|
116
|
+
}
|
|
117
|
+
# New Year's Day observance is the only holiday whose Sat/Sun shift can
|
|
118
|
+
# cross a calendar-year boundary (Jan 1 on a Saturday -> observed the
|
|
119
|
+
# prior Dec 31, e.g. 2022-01-01 -> 2021-12-31). is_trading_day() looks
|
|
120
|
+
# holidays up by the *queried* date's own year, so that shifted date
|
|
121
|
+
# must also be included in the set for the year it actually falls in.
|
|
122
|
+
next_new_year_observed = _observed(dt.date(year + 1, 1, 1))
|
|
123
|
+
if next_new_year_observed.year == year:
|
|
124
|
+
holidays.add(next_new_year_observed)
|
|
125
|
+
return holidays
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_HOLIDAY_CACHE: dict[int, set[dt.date]] = {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_trading_day(day: dt.date) -> bool:
|
|
132
|
+
if day.weekday() >= 5:
|
|
133
|
+
return False
|
|
134
|
+
if day.year not in _HOLIDAY_CACHE:
|
|
135
|
+
_HOLIDAY_CACHE[day.year] = us_market_holidays(day.year)
|
|
136
|
+
return day not in _HOLIDAY_CACHE[day.year]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def prior_trading_day(day: dt.date) -> dt.date:
|
|
140
|
+
"""The most recent trading day strictly before `day`."""
|
|
141
|
+
cur = day - dt.timedelta(days=1)
|
|
142
|
+
while not is_trading_day(cur):
|
|
143
|
+
cur -= dt.timedelta(days=1)
|
|
144
|
+
return cur
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def most_recent_trading_day_at_or_before(day: dt.date) -> dt.date:
|
|
148
|
+
cur = day
|
|
149
|
+
while not is_trading_day(cur):
|
|
150
|
+
cur -= dt.timedelta(days=1)
|
|
151
|
+
return cur
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Data model
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Prediction:
|
|
161
|
+
model: str
|
|
162
|
+
vintage: str
|
|
163
|
+
ticker: str
|
|
164
|
+
type: str
|
|
165
|
+
target_date: str
|
|
166
|
+
adj_score: float | None
|
|
167
|
+
mu: float | None
|
|
168
|
+
sigma: float | None
|
|
169
|
+
entry_price: float | None
|
|
170
|
+
source_file: str
|
|
171
|
+
|
|
172
|
+
def key(self) -> CanonicalKey:
|
|
173
|
+
return (self.model, self.vintage, self.ticker, self.type, self.target_date)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class SettlementCandidate:
|
|
178
|
+
model: str
|
|
179
|
+
vintage: str
|
|
180
|
+
ticker: str
|
|
181
|
+
type: str
|
|
182
|
+
target_date: str
|
|
183
|
+
|
|
184
|
+
source_file: str
|
|
185
|
+
settlement_run_date: str
|
|
186
|
+
settled_at: str
|
|
187
|
+
|
|
188
|
+
price: float | None
|
|
189
|
+
price_date: str | None
|
|
190
|
+
price_tag: str | None
|
|
191
|
+
declared_timing_flag: str | None
|
|
192
|
+
|
|
193
|
+
entry_price: float | None
|
|
194
|
+
mu: float | None
|
|
195
|
+
sigma: float | None
|
|
196
|
+
realized_return: float | None
|
|
197
|
+
benchmark_return: float | None
|
|
198
|
+
realized_alpha: float | None
|
|
199
|
+
direction: str | None
|
|
200
|
+
ci_result: str | None
|
|
201
|
+
z: float | None
|
|
202
|
+
note: str | None
|
|
203
|
+
|
|
204
|
+
settlement_convention: str = "UNVALIDATED"
|
|
205
|
+
is_timing_valid: bool = False
|
|
206
|
+
rejection_reason: str = ""
|
|
207
|
+
|
|
208
|
+
def key(self) -> CanonicalKey:
|
|
209
|
+
return (self.model, self.vintage, self.ticker, self.type, self.target_date)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def is_complete(self) -> bool:
|
|
213
|
+
return (
|
|
214
|
+
self.price is not None
|
|
215
|
+
and self.price_date is not None
|
|
216
|
+
and self.direction is not None
|
|
217
|
+
and self.ci_result is not None
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def to_dict(self) -> dict[str, Any]:
|
|
221
|
+
return {
|
|
222
|
+
"model": self.model,
|
|
223
|
+
"vintage": self.vintage,
|
|
224
|
+
"ticker": self.ticker,
|
|
225
|
+
"type": self.type,
|
|
226
|
+
"target_date": self.target_date,
|
|
227
|
+
"source_file": self.source_file,
|
|
228
|
+
"settlement_run_date": self.settlement_run_date,
|
|
229
|
+
"settled_at": self.settled_at,
|
|
230
|
+
"price": self.price,
|
|
231
|
+
"price_date": self.price_date,
|
|
232
|
+
"price_tag": self.price_tag,
|
|
233
|
+
"declared_timing_flag": self.declared_timing_flag,
|
|
234
|
+
"settlement_convention": self.settlement_convention,
|
|
235
|
+
"is_timing_valid": self.is_timing_valid,
|
|
236
|
+
"rejection_reason": self.rejection_reason,
|
|
237
|
+
"entry_price": self.entry_price,
|
|
238
|
+
"mu": self.mu,
|
|
239
|
+
"sigma": self.sigma,
|
|
240
|
+
"realized_return": self.realized_return,
|
|
241
|
+
"benchmark_return": self.benchmark_return,
|
|
242
|
+
"realized_alpha": self.realized_alpha,
|
|
243
|
+
"direction": self.direction,
|
|
244
|
+
"ci_result": self.ci_result,
|
|
245
|
+
"z": self.z,
|
|
246
|
+
"note": self.note,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class ConflictRecord:
|
|
252
|
+
key: CanonicalKey
|
|
253
|
+
reason: str
|
|
254
|
+
candidates: list[SettlementCandidate]
|
|
255
|
+
|
|
256
|
+
def to_dict(self) -> dict[str, Any]:
|
|
257
|
+
model, vintage, ticker, type_, target_date = self.key
|
|
258
|
+
return {
|
|
259
|
+
"model": model,
|
|
260
|
+
"vintage": vintage,
|
|
261
|
+
"ticker": ticker,
|
|
262
|
+
"type": type_,
|
|
263
|
+
"target_date": target_date,
|
|
264
|
+
"reason": self.reason,
|
|
265
|
+
"candidate_source_files": [c.source_file for c in self.candidates],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Loading + normalization
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
274
|
+
_SOURCE_LEDGER_DATE_RE = re.compile(r"-(\d{4}-\d{2}-\d{2})/")
|
|
275
|
+
|
|
276
|
+
# The first entry in each tuple is the canonical field name new settlement
|
|
277
|
+
# rows should use (see rules.md's Canonical Settlement Ledger contract);
|
|
278
|
+
# every other entry is a legacy variant kept only to reconcile packages that
|
|
279
|
+
# already exist.
|
|
280
|
+
_PRICE_KEYS = ("settle_price", "settlement_price", "current_price")
|
|
281
|
+
_PRICE_DATE_KEYS = (
|
|
282
|
+
"settle_price_date",
|
|
283
|
+
"settlement_date",
|
|
284
|
+
"current_price_date",
|
|
285
|
+
"settle_date",
|
|
286
|
+
)
|
|
287
|
+
# Every schema variant observed in the repo pairs its price key with exactly
|
|
288
|
+
# one of these date keys (verified against all settlement rows). Prefer a
|
|
289
|
+
# same-convention pair over independently picking the first-present price
|
|
290
|
+
# and first-present date, so a row that mixes a live field from one
|
|
291
|
+
# convention with a stale leftover field from another can never silently
|
|
292
|
+
# pair an unrelated date with a price.
|
|
293
|
+
_PRICE_SCHEMA_PAIRS: tuple[tuple[str, str], ...] = (
|
|
294
|
+
("settle_price", "settle_price_date"),
|
|
295
|
+
("settle_price", "settle_date"),
|
|
296
|
+
("settlement_price", "settlement_date"),
|
|
297
|
+
("current_price", "current_price_date"),
|
|
298
|
+
)
|
|
299
|
+
_PRICE_TAG_KEYS = ("settlement_price_tag", "current_price_tag")
|
|
300
|
+
_TIMING_FLAG_KEYS = ("timing_flag", "settlement_timing_flag")
|
|
301
|
+
_SETTLED_AT_KEYS = ("settled_at", "settled_on")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _looks_like_date(value: Any) -> bool:
|
|
305
|
+
return isinstance(value, str) and bool(_DATE_RE.match(value))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _as_float(value: Any) -> float | None:
|
|
309
|
+
if value is None:
|
|
310
|
+
return None
|
|
311
|
+
try:
|
|
312
|
+
return float(value)
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _first_present(raw: dict[str, Any], keys: tuple[str, ...]) -> Any:
|
|
318
|
+
for k in keys:
|
|
319
|
+
if raw.get(k) is not None:
|
|
320
|
+
return raw[k]
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _resolve_price_and_date(raw: dict[str, Any]) -> tuple[Any, Any]:
|
|
325
|
+
for price_key, date_key in _PRICE_SCHEMA_PAIRS:
|
|
326
|
+
if raw.get(price_key) is not None and raw.get(date_key) is not None:
|
|
327
|
+
return raw[price_key], raw[date_key]
|
|
328
|
+
# No single schema variant supplied both fields together -- fall back to
|
|
329
|
+
# independent resolution so a row with a genuinely partial schema still
|
|
330
|
+
# yields whatever it has (timing validation will reject it if the two
|
|
331
|
+
# values turn out not to describe the same observation).
|
|
332
|
+
return _first_present(raw, _PRICE_KEYS), _first_present(raw, _PRICE_DATE_KEYS)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _strip_model_prefix(vintage: Any, model: str | None) -> str | None:
|
|
336
|
+
"""Some settlement rows store vintage as "{model}-{date}" instead of
|
|
337
|
+
just "{date}" (observed e.g. vintage="gpt-5-2026-06-11" alongside
|
|
338
|
+
model="gpt-5"). Strip the bug when it is safely detectable.
|
|
339
|
+
"""
|
|
340
|
+
if not isinstance(vintage, str):
|
|
341
|
+
return None
|
|
342
|
+
if model and vintage.startswith(model + "-"):
|
|
343
|
+
prefix_len = len(model) + 1
|
|
344
|
+
candidate = vintage[prefix_len:]
|
|
345
|
+
if _looks_like_date(candidate):
|
|
346
|
+
return candidate
|
|
347
|
+
return vintage if _looks_like_date(vintage) else None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _vintage_from_source_ledger(source_ledger: Any) -> str | None:
|
|
351
|
+
if not isinstance(source_ledger, str):
|
|
352
|
+
return None
|
|
353
|
+
m = _SOURCE_LEDGER_DATE_RE.search(source_ledger)
|
|
354
|
+
return m.group(1) if m else None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
358
|
+
with path.open() as fh:
|
|
359
|
+
result: dict[str, Any] = json.load(fh)
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def load_packages(output_dir: Path) -> list[dict[str, Any]]:
|
|
364
|
+
"""Load every dated `15_predictions.json` package, tagging each with its
|
|
365
|
+
source folder name."""
|
|
366
|
+
packages = []
|
|
367
|
+
for path in sorted(output_dir.glob("*/15_predictions.json")):
|
|
368
|
+
data = _read_json(path)
|
|
369
|
+
data["_source_file"] = path.parent.name
|
|
370
|
+
packages.append(data)
|
|
371
|
+
return packages
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def extract_predictions(packages: Iterable[dict[str, Any]]) -> list[Prediction]:
|
|
375
|
+
out = []
|
|
376
|
+
for pkg in packages:
|
|
377
|
+
for p in pkg.get("predictions", []):
|
|
378
|
+
ticker = p.get("ticker")
|
|
379
|
+
target_date = p.get("target_date")
|
|
380
|
+
run_date = p.get("run_date") or pkg.get("run_date")
|
|
381
|
+
model = p.get("model") or pkg.get("model")
|
|
382
|
+
if not (ticker and target_date and run_date and model):
|
|
383
|
+
continue
|
|
384
|
+
out.append(
|
|
385
|
+
Prediction(
|
|
386
|
+
model=model,
|
|
387
|
+
vintage=run_date,
|
|
388
|
+
ticker=ticker,
|
|
389
|
+
type=p.get("type") or "EQUITY_ALPHA",
|
|
390
|
+
target_date=target_date,
|
|
391
|
+
adj_score=_as_float(p.get("adj_score")),
|
|
392
|
+
mu=_as_float(p.get("mu")),
|
|
393
|
+
sigma=_as_float(p.get("sigma")),
|
|
394
|
+
entry_price=_as_float(p.get("entry_price")),
|
|
395
|
+
source_file=pkg.get("_source_file", ""),
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Duplicate prediction books
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
# Originals of prediction books that were also published under a second model
|
|
406
|
+
# name, with the provenance evidence. find_duplicate_books() decides from
|
|
407
|
+
# content alone *whether* two books are copies; this table only decides which
|
|
408
|
+
# copy is the original, which content cannot tell when both share a run_date.
|
|
409
|
+
# An entry never excludes anything by itself.
|
|
410
|
+
PINNED_ORIGINAL_BOOKS: dict[str, str] = {
|
|
411
|
+
"gpt-5-2026-06-21": (
|
|
412
|
+
"gemini-3.5-flash-2026-06-21 is the copy: its 13_evolution_log.md "
|
|
413
|
+
"reviews gemini-3.5-flash-2026-06-14..20 packages that never existed "
|
|
414
|
+
"(this book's review list with the model name swapped); both copies "
|
|
415
|
+
"landed in commit f801a31"
|
|
416
|
+
),
|
|
417
|
+
"gpt-5-2026-06-29": (
|
|
418
|
+
"gemini-3.5-flash-2026-06-29 is the copy: its 13_evolution_log.md "
|
|
419
|
+
"reviews gemini-3.5-flash-2026-06-22 and -24 packages that never "
|
|
420
|
+
"existed (this book's review list with the model name swapped); both "
|
|
421
|
+
"copies landed in commit f801a31"
|
|
422
|
+
),
|
|
423
|
+
"claude-fable-5-2026-07-13": (
|
|
424
|
+
"gemini-3.5-flash-2026-07-13 is the copy: an uncommitted package left "
|
|
425
|
+
"by a dead session (see claude-fable-5-2026-07-15/00_run_manifest.md), "
|
|
426
|
+
"swept into commit f70fbc1 three days after this one landed in 256771e"
|
|
427
|
+
),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
_MODEL_MASK = "\x00model\x00" # json.dumps escapes NUL, so no dump contains it
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@dataclass(frozen=True)
|
|
434
|
+
class DuplicateBook:
|
|
435
|
+
source_file: str
|
|
436
|
+
duplicate_of: str
|
|
437
|
+
original_rule: str
|
|
438
|
+
reason: str
|
|
439
|
+
fingerprint: str
|
|
440
|
+
|
|
441
|
+
def to_dict(self) -> dict[str, Any]:
|
|
442
|
+
return {
|
|
443
|
+
"source_file": self.source_file,
|
|
444
|
+
"duplicate_of": self.duplicate_of,
|
|
445
|
+
"original_rule": self.original_rule,
|
|
446
|
+
"reason": self.reason,
|
|
447
|
+
"fingerprint": self.fingerprint,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def prediction_book_fingerprint(pkg: dict[str, Any]) -> str | None:
|
|
452
|
+
"""SHA-256 of a package's `predictions` with its own model name masked
|
|
453
|
+
wherever it appears, so the same book republished under another model name
|
|
454
|
+
hashes identically. None for a package with no predictions to double-count.
|
|
455
|
+
"""
|
|
456
|
+
predictions = pkg.get("predictions") or []
|
|
457
|
+
if not predictions:
|
|
458
|
+
return None
|
|
459
|
+
text = json.dumps(predictions, sort_keys=True)
|
|
460
|
+
models = {pkg.get("model")} | {p.get("model") for p in predictions}
|
|
461
|
+
for model in sorted(
|
|
462
|
+
(m for m in models if isinstance(m, str) and m), key=len, reverse=True
|
|
463
|
+
):
|
|
464
|
+
text = text.replace(model, _MODEL_MASK)
|
|
465
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def find_duplicate_books(
|
|
469
|
+
packages: Iterable[dict[str, Any]],
|
|
470
|
+
pinned_originals: dict[str, str] = PINNED_ORIGINAL_BOOKS,
|
|
471
|
+
) -> dict[str, DuplicateBook]:
|
|
472
|
+
"""Packages whose prediction book is a copy of another package's, keyed by
|
|
473
|
+
source folder.
|
|
474
|
+
|
|
475
|
+
Packages are grouped by `prediction_book_fingerprint`. In each group of two
|
|
476
|
+
or more, the original is the member named in `pinned_originals`, else the
|
|
477
|
+
one with the earliest run_date, ties broken by folder name. Every other
|
|
478
|
+
member is DUPLICATE_OF it.
|
|
479
|
+
"""
|
|
480
|
+
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
481
|
+
for pkg in packages:
|
|
482
|
+
fingerprint = prediction_book_fingerprint(pkg)
|
|
483
|
+
if fingerprint is not None:
|
|
484
|
+
groups[fingerprint].append(pkg)
|
|
485
|
+
|
|
486
|
+
duplicates: dict[str, DuplicateBook] = {}
|
|
487
|
+
for fingerprint, members in groups.items():
|
|
488
|
+
if len(members) < 2:
|
|
489
|
+
continue
|
|
490
|
+
ordered = sorted(
|
|
491
|
+
members, key=lambda p: (p.get("run_date", ""), p.get("_source_file", ""))
|
|
492
|
+
)
|
|
493
|
+
names = [p.get("_source_file", "") for p in ordered]
|
|
494
|
+
pinned = [name for name in names if name in pinned_originals]
|
|
495
|
+
if pinned:
|
|
496
|
+
original = pinned[0]
|
|
497
|
+
rule, reason = "PINNED", pinned_originals[original]
|
|
498
|
+
elif ordered[0].get("run_date") == ordered[1].get("run_date"):
|
|
499
|
+
original = names[0]
|
|
500
|
+
rule = "FOLDER_NAME_TIEBREAK"
|
|
501
|
+
reason = (
|
|
502
|
+
"copies share a run_date, so folder name picked the original; "
|
|
503
|
+
"pin the true original in PINNED_ORIGINAL_BOOKS if this is wrong"
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
original = names[0]
|
|
507
|
+
rule, reason = "EARLIEST_RUN_DATE", "earliest run_date is the original"
|
|
508
|
+
for name in names:
|
|
509
|
+
if name != original:
|
|
510
|
+
duplicates[name] = DuplicateBook(
|
|
511
|
+
source_file=name,
|
|
512
|
+
duplicate_of=original,
|
|
513
|
+
original_rule=rule,
|
|
514
|
+
reason=reason,
|
|
515
|
+
fingerprint=fingerprint,
|
|
516
|
+
)
|
|
517
|
+
return duplicates
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def normalize_settlement_candidate(
|
|
521
|
+
raw: dict[str, Any],
|
|
522
|
+
container_run_date: str,
|
|
523
|
+
container_model: str,
|
|
524
|
+
source_file: str,
|
|
525
|
+
lookup_by_no_vintage: dict[tuple[str, str, str, str], list[Prediction]],
|
|
526
|
+
) -> SettlementCandidate | None:
|
|
527
|
+
"""Normalize one raw settlement row from any observed schema variant.
|
|
528
|
+
|
|
529
|
+
Returns None only when the row is too malformed to key at all (missing
|
|
530
|
+
ticker/target_date, or no vintage can be determined by any fallback).
|
|
531
|
+
"""
|
|
532
|
+
ticker = raw.get("ticker")
|
|
533
|
+
target_date = raw.get("target_date")
|
|
534
|
+
if not ticker or not target_date:
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
model = raw.get("model") or container_model
|
|
538
|
+
ptype = raw.get("type") or "EQUITY_ALPHA"
|
|
539
|
+
|
|
540
|
+
vintage = _strip_model_prefix(raw.get("vintage"), model)
|
|
541
|
+
if not vintage and _looks_like_date(raw.get("vintage_run_date")):
|
|
542
|
+
vintage = raw["vintage_run_date"]
|
|
543
|
+
if not vintage:
|
|
544
|
+
vintage = _vintage_from_source_ledger(raw.get("source_ledger"))
|
|
545
|
+
if not vintage and _looks_like_date(raw.get("entry_date")):
|
|
546
|
+
vintage = raw["entry_date"]
|
|
547
|
+
if not vintage:
|
|
548
|
+
# Prefer a validated match against the real prediction ledger over
|
|
549
|
+
# blindly trusting an ambiguous key (below) -- only fires when
|
|
550
|
+
# exactly one prediction matches on every other key field.
|
|
551
|
+
matches = lookup_by_no_vintage.get((model, ticker, ptype, target_date), [])
|
|
552
|
+
if len(matches) == 1:
|
|
553
|
+
vintage = matches[0].vintage
|
|
554
|
+
if not vintage and _looks_like_date(raw.get("run_date")):
|
|
555
|
+
# Last resort: some rows reuse "run_date" for the *prediction's*
|
|
556
|
+
# vintage. This key is never the settlement computation date --
|
|
557
|
+
# that always comes from the containing package's own top-level
|
|
558
|
+
# run_date -- and is unvalidated against the prediction ledger, so
|
|
559
|
+
# every safer fallback above is tried first.
|
|
560
|
+
vintage = raw["run_date"]
|
|
561
|
+
|
|
562
|
+
if not vintage:
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
ci_result = raw.get("ci_result") or raw.get("ci")
|
|
566
|
+
benchmark_return = raw.get("benchmark_return")
|
|
567
|
+
if benchmark_return is None:
|
|
568
|
+
benchmark_return = raw.get("spy_return")
|
|
569
|
+
price, price_date = _resolve_price_and_date(raw)
|
|
570
|
+
|
|
571
|
+
return SettlementCandidate(
|
|
572
|
+
model=model,
|
|
573
|
+
vintage=vintage,
|
|
574
|
+
ticker=ticker,
|
|
575
|
+
type=ptype,
|
|
576
|
+
target_date=target_date,
|
|
577
|
+
source_file=source_file,
|
|
578
|
+
settlement_run_date=container_run_date,
|
|
579
|
+
settled_at=str(_first_present(raw, _SETTLED_AT_KEYS) or container_run_date),
|
|
580
|
+
price=_as_float(price),
|
|
581
|
+
price_date=price_date,
|
|
582
|
+
price_tag=_first_present(raw, _PRICE_TAG_KEYS),
|
|
583
|
+
declared_timing_flag=_first_present(raw, _TIMING_FLAG_KEYS),
|
|
584
|
+
entry_price=_as_float(raw.get("entry_price")),
|
|
585
|
+
mu=_as_float(raw.get("mu")),
|
|
586
|
+
sigma=_as_float(raw.get("sigma")),
|
|
587
|
+
realized_return=_as_float(raw.get("realized_return")),
|
|
588
|
+
benchmark_return=_as_float(benchmark_return),
|
|
589
|
+
realized_alpha=_as_float(raw.get("realized_alpha")),
|
|
590
|
+
direction=raw.get("direction"),
|
|
591
|
+
ci_result=ci_result,
|
|
592
|
+
z=_as_float(raw.get("z")),
|
|
593
|
+
note=raw.get("note") or raw.get("settlement_note"),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def extract_settlement_candidates(
|
|
598
|
+
packages: Iterable[dict[str, Any]],
|
|
599
|
+
lookup_by_no_vintage: dict[tuple[str, str, str, str], list[Prediction]],
|
|
600
|
+
) -> list[SettlementCandidate]:
|
|
601
|
+
out = []
|
|
602
|
+
for pkg in packages:
|
|
603
|
+
container_run_date = pkg.get("run_date", "")
|
|
604
|
+
container_model = pkg.get("model", "")
|
|
605
|
+
source_file = pkg.get("_source_file", "")
|
|
606
|
+
for raw in pkg.get("settlements", []):
|
|
607
|
+
cand = normalize_settlement_candidate(
|
|
608
|
+
raw,
|
|
609
|
+
container_run_date,
|
|
610
|
+
container_model,
|
|
611
|
+
source_file,
|
|
612
|
+
lookup_by_no_vintage,
|
|
613
|
+
)
|
|
614
|
+
if cand is not None:
|
|
615
|
+
out.append(cand)
|
|
616
|
+
return out
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
# Timing validation
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def validate_timing(
|
|
625
|
+
target_date_s: str,
|
|
626
|
+
settlement_run_date_s: str,
|
|
627
|
+
price_date_s: str | None,
|
|
628
|
+
declared_timing_flag: str | None = None,
|
|
629
|
+
settled_at_s: str | None = None,
|
|
630
|
+
) -> tuple[str, bool, str]:
|
|
631
|
+
"""Returns (settlement_convention, is_valid, rejection_reason).
|
|
632
|
+
|
|
633
|
+
Per rules.md Settlement Rules, the settlement price must be the close of
|
|
634
|
+
`target_date` itself, with exactly two exceptions:
|
|
635
|
+
|
|
636
|
+
- WEEKEND_TARGET: target_date is not a trading session -> settle at the
|
|
637
|
+
last completed trading close at or before target_date.
|
|
638
|
+
- TARGET_EQ_RUN_DATE: target_date is a trading session but equals the
|
|
639
|
+
settlement's own run date and the run occurs before the close exists ->
|
|
640
|
+
settle at the prior completed session.
|
|
641
|
+
|
|
642
|
+
A run after the target session closes may use that same-day close only
|
|
643
|
+
when the row explicitly declares ``TARGET_DATE_CLOSE`` and its
|
|
644
|
+
timezone-aware ``settled_at`` timestamp is at or after the 16:00 ET close
|
|
645
|
+
*of target_date* -- with no upper bound, so a settlement stamped later
|
|
646
|
+
(e.g. a long post-close pipeline that crosses midnight) still validates.
|
|
647
|
+
This preserves rejection of unlabeled or pre-close same-day prints while
|
|
648
|
+
allowing the ordinary target-close rule to operate after the close.
|
|
649
|
+
|
|
650
|
+
Any other target-date/price-date combination is timing-invalid.
|
|
651
|
+
"""
|
|
652
|
+
try:
|
|
653
|
+
target_date = dt.date.fromisoformat(target_date_s)
|
|
654
|
+
settlement_run_date = dt.date.fromisoformat(settlement_run_date_s)
|
|
655
|
+
except (TypeError, ValueError):
|
|
656
|
+
return (
|
|
657
|
+
"MALFORMED",
|
|
658
|
+
False,
|
|
659
|
+
"target_date or settlement_run_date is not a valid date",
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
if settlement_run_date < target_date:
|
|
663
|
+
return (
|
|
664
|
+
"PREMATURE",
|
|
665
|
+
False,
|
|
666
|
+
f"settled on {settlement_run_date_s}, before target_date "
|
|
667
|
+
f"{target_date_s} was reached",
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if not price_date_s:
|
|
671
|
+
return ("UNKNOWN", False, "candidate has no price_date")
|
|
672
|
+
try:
|
|
673
|
+
price_date = dt.date.fromisoformat(price_date_s)
|
|
674
|
+
except ValueError:
|
|
675
|
+
return ("MALFORMED", False, f"price_date {price_date_s!r} is not a valid date")
|
|
676
|
+
|
|
677
|
+
if not is_trading_day(target_date):
|
|
678
|
+
expected = most_recent_trading_day_at_or_before(target_date)
|
|
679
|
+
if price_date == expected:
|
|
680
|
+
return ("WEEKEND_TARGET", True, "")
|
|
681
|
+
return (
|
|
682
|
+
"WEEKEND_TARGET",
|
|
683
|
+
False,
|
|
684
|
+
f"target_date {target_date_s} is not a trading day: expected the "
|
|
685
|
+
f"last completed close at or before it ({expected.isoformat()}), "
|
|
686
|
+
f"got price_date {price_date_s}",
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
if settlement_run_date == target_date:
|
|
690
|
+
expected = prior_trading_day(target_date)
|
|
691
|
+
if price_date == expected:
|
|
692
|
+
return ("TARGET_EQ_RUN_DATE", True, "")
|
|
693
|
+
if price_date == target_date and declared_timing_flag == "TARGET_DATE_CLOSE":
|
|
694
|
+
try:
|
|
695
|
+
settled_at = dt.datetime.fromisoformat(
|
|
696
|
+
(settled_at_s or "").replace("Z", "+00:00")
|
|
697
|
+
)
|
|
698
|
+
if settled_at.tzinfo is None:
|
|
699
|
+
raise ValueError("settled_at must include a timezone")
|
|
700
|
+
settled_at_et = settled_at.astimezone(ZoneInfo("America/New_York"))
|
|
701
|
+
except (TypeError, ValueError):
|
|
702
|
+
settled_at_et = None
|
|
703
|
+
target_close_et = dt.datetime.combine(
|
|
704
|
+
target_date, dt.time(16, 0), tzinfo=ZoneInfo("America/New_York")
|
|
705
|
+
)
|
|
706
|
+
if settled_at_et is not None and settled_at_et >= target_close_et:
|
|
707
|
+
return ("TARGET_DATE_CLOSE", True, "")
|
|
708
|
+
return (
|
|
709
|
+
"TARGET_DATE_CLOSE",
|
|
710
|
+
False,
|
|
711
|
+
"TARGET_DATE_CLOSE requires a timezone-aware settled_at timestamp "
|
|
712
|
+
"at or after the 16:00 America/New_York close of target_date",
|
|
713
|
+
)
|
|
714
|
+
return (
|
|
715
|
+
"TARGET_EQ_RUN_DATE",
|
|
716
|
+
False,
|
|
717
|
+
f"target_date {target_date_s} equals the settlement run date: "
|
|
718
|
+
f"expected the prior completed close ({expected.isoformat()}), "
|
|
719
|
+
f"got price_date {price_date_s}; same-day/intraday values require an "
|
|
720
|
+
"explicit TARGET_DATE_CLOSE flag after the session closes",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# Ordinary case: settlement_run_date > target_date, target_date is a
|
|
724
|
+
# trading day -- the completed target-date close must exist and be used.
|
|
725
|
+
if price_date == target_date:
|
|
726
|
+
return ("ORDINARY", True, "")
|
|
727
|
+
return (
|
|
728
|
+
"ORDINARY",
|
|
729
|
+
False,
|
|
730
|
+
f"ordinary target {target_date_s}: expected the completed "
|
|
731
|
+
f"target-date close, got price_date {price_date_s}",
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ---------------------------------------------------------------------------
|
|
736
|
+
# Precedence resolution
|
|
737
|
+
# ---------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _prices_agree(a: float | None, b: float | None) -> bool:
|
|
741
|
+
if a is None or b is None:
|
|
742
|
+
return a is None and b is None
|
|
743
|
+
return abs(a - b) <= max(0.01, 0.0005 * max(abs(a), abs(b)))
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def build_canonical_ledger(
|
|
747
|
+
candidates: list[SettlementCandidate],
|
|
748
|
+
) -> tuple[
|
|
749
|
+
dict[CanonicalKey, SettlementCandidate],
|
|
750
|
+
list[ConflictRecord],
|
|
751
|
+
list[SettlementCandidate],
|
|
752
|
+
list[SettlementCandidate],
|
|
753
|
+
list[SettlementCandidate],
|
|
754
|
+
]:
|
|
755
|
+
"""Resolve one canonical settlement per key.
|
|
756
|
+
|
|
757
|
+
Precedence: among timing-valid, complete candidates for a key, the
|
|
758
|
+
earliest `settlement_run_date` wins (ties broken deterministically by
|
|
759
|
+
source_file name) -- this naturally prefers a candidate settled exactly
|
|
760
|
+
on the due date over any later, lower-priority re-settlement. Candidates
|
|
761
|
+
tied for earliest that materially disagree on price or direction make
|
|
762
|
+
the key an unresolved conflict instead of a silent pick; lower-priority
|
|
763
|
+
post-target observations never override a valid target-date settlement.
|
|
764
|
+
|
|
765
|
+
Returns (canonical, conflicts, rejected, audit_only, conflicted). Every
|
|
766
|
+
input candidate lands in exactly one of {canonical (as the chosen value),
|
|
767
|
+
rejected, audit_only, conflicted}:
|
|
768
|
+
- rejected: timing-invalid or incomplete rows.
|
|
769
|
+
- audit_only: valid rows that were NOT selected as canonical, either
|
|
770
|
+
because they agree with the chosen candidate (same-tier duplicate)
|
|
771
|
+
or because they are a later, lower-priority valid re-settlement of
|
|
772
|
+
an already-resolved key. Kept for lineage, never scored.
|
|
773
|
+
- conflicted: valid rows tied for earliest that materially disagree.
|
|
774
|
+
The key itself is excluded from `canonical` (see `conflicts`).
|
|
775
|
+
"""
|
|
776
|
+
by_key: dict[CanonicalKey, list[SettlementCandidate]] = defaultdict(list)
|
|
777
|
+
for c in candidates:
|
|
778
|
+
by_key[c.key()].append(c)
|
|
779
|
+
|
|
780
|
+
canonical: dict[CanonicalKey, SettlementCandidate] = {}
|
|
781
|
+
conflicts: list[ConflictRecord] = []
|
|
782
|
+
rejected: list[SettlementCandidate] = []
|
|
783
|
+
audit_only: list[SettlementCandidate] = []
|
|
784
|
+
conflicted: list[SettlementCandidate] = []
|
|
785
|
+
|
|
786
|
+
for key, rows in by_key.items():
|
|
787
|
+
valid = [c for c in rows if c.is_timing_valid and c.is_complete]
|
|
788
|
+
rejected.extend(c for c in rows if not (c.is_timing_valid and c.is_complete))
|
|
789
|
+
|
|
790
|
+
if not valid:
|
|
791
|
+
continue
|
|
792
|
+
|
|
793
|
+
ordered = sorted(valid, key=lambda c: (c.settlement_run_date, c.source_file))
|
|
794
|
+
earliest_run_date = ordered[0].settlement_run_date
|
|
795
|
+
earliest_batch = [
|
|
796
|
+
c for c in ordered if c.settlement_run_date == earliest_run_date
|
|
797
|
+
]
|
|
798
|
+
later_valid = [c for c in ordered if c.settlement_run_date != earliest_run_date]
|
|
799
|
+
|
|
800
|
+
if len(earliest_batch) > 1:
|
|
801
|
+
first = earliest_batch[0]
|
|
802
|
+
disagree = any(
|
|
803
|
+
not _prices_agree(first.price, c.price)
|
|
804
|
+
or first.direction != c.direction
|
|
805
|
+
for c in earliest_batch[1:]
|
|
806
|
+
)
|
|
807
|
+
if disagree:
|
|
808
|
+
conflicts.append(
|
|
809
|
+
ConflictRecord(
|
|
810
|
+
key=key,
|
|
811
|
+
reason="same-precedence-tier candidates disagree on "
|
|
812
|
+
"normalized price or direction",
|
|
813
|
+
candidates=earliest_batch,
|
|
814
|
+
)
|
|
815
|
+
)
|
|
816
|
+
conflicted.extend(earliest_batch)
|
|
817
|
+
audit_only.extend(later_valid)
|
|
818
|
+
continue
|
|
819
|
+
audit_only.extend(earliest_batch[1:])
|
|
820
|
+
|
|
821
|
+
canonical[key] = earliest_batch[0]
|
|
822
|
+
audit_only.extend(later_valid)
|
|
823
|
+
|
|
824
|
+
return canonical, conflicts, rejected, audit_only, conflicted
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def compute_due_inventory(
|
|
828
|
+
predictions: list[Prediction],
|
|
829
|
+
canonical: dict[CanonicalKey, SettlementCandidate],
|
|
830
|
+
conflicted_keys: set[CanonicalKey],
|
|
831
|
+
as_of: str,
|
|
832
|
+
) -> set[CanonicalKey]:
|
|
833
|
+
"""Prediction keys that are due (target_date <= as_of), have no canonical
|
|
834
|
+
settlement, and are not already flagged as an unresolved conflict --
|
|
835
|
+
derived from source prediction keys, never from the source's mutable
|
|
836
|
+
(and never-updated) `status` field. A conflicted key needs manual
|
|
837
|
+
reconciliation, not another automated settlement attempt, so it is
|
|
838
|
+
reported separately (see `conflicts` in the manifest) rather than folded
|
|
839
|
+
into due inventory where it would look like an ordinary open prediction.
|
|
840
|
+
"""
|
|
841
|
+
return {
|
|
842
|
+
p.key()
|
|
843
|
+
for p in predictions
|
|
844
|
+
if p.target_date <= as_of
|
|
845
|
+
and p.key() not in canonical
|
|
846
|
+
and p.key() not in conflicted_keys
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# ---------------------------------------------------------------------------
|
|
851
|
+
# Rolling calibration metrics (computed only from canonical keys)
|
|
852
|
+
# ---------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _ranks(values: list[float]) -> list[float]:
|
|
856
|
+
"""Average ranks (1-indexed, ties averaged), ascending."""
|
|
857
|
+
order = sorted(range(len(values)), key=lambda i: values[i])
|
|
858
|
+
ranks = [0.0] * len(values)
|
|
859
|
+
i = 0
|
|
860
|
+
while i < len(order):
|
|
861
|
+
j = i
|
|
862
|
+
while j + 1 < len(order) and values[order[j + 1]] == values[order[i]]:
|
|
863
|
+
j += 1
|
|
864
|
+
avg_rank = (i + j) / 2 + 1
|
|
865
|
+
for k in range(i, j + 1):
|
|
866
|
+
ranks[order[k]] = avg_rank
|
|
867
|
+
i = j + 1
|
|
868
|
+
return ranks
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def spearman_rank_ic(pairs: list[tuple[float, float]]) -> float | None:
|
|
872
|
+
n = len(pairs)
|
|
873
|
+
if n < 2:
|
|
874
|
+
return None
|
|
875
|
+
xs = _ranks([p[0] for p in pairs])
|
|
876
|
+
ys = _ranks([p[1] for p in pairs])
|
|
877
|
+
mean_x = sum(xs) / n
|
|
878
|
+
mean_y = sum(ys) / n
|
|
879
|
+
cov = sum((a - mean_x) * (b - mean_y) for a, b in zip(xs, ys))
|
|
880
|
+
var_x = sum((a - mean_x) ** 2 for a in xs)
|
|
881
|
+
var_y = sum((b - mean_y) ** 2 for b in ys)
|
|
882
|
+
if var_x == 0 or var_y == 0:
|
|
883
|
+
return None
|
|
884
|
+
return cov / math.sqrt(var_x * var_y)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def effective_independent_sample_size(
|
|
888
|
+
records: Iterable[SettlementCandidate],
|
|
889
|
+
horizon_days: int = FORECAST_HORIZON_DAYS,
|
|
890
|
+
) -> int:
|
|
891
|
+
"""Maximum count of non-overlapping forecast-horizon target windows.
|
|
892
|
+
|
|
893
|
+
Each distinct target date represents the end of one `horizon_days`
|
|
894
|
+
forecast window. Greedily selecting target dates in ascending order is
|
|
895
|
+
the interval-scheduling optimum because every window has equal length.
|
|
896
|
+
Dates exactly one horizon apart count as non-overlapping.
|
|
897
|
+
"""
|
|
898
|
+
target_dates = sorted(
|
|
899
|
+
{
|
|
900
|
+
dt.date.fromisoformat(c.target_date)
|
|
901
|
+
for c in records
|
|
902
|
+
if _looks_like_date(c.target_date)
|
|
903
|
+
}
|
|
904
|
+
)
|
|
905
|
+
selected = 0
|
|
906
|
+
last_target: dt.date | None = None
|
|
907
|
+
for target_date in target_dates:
|
|
908
|
+
if last_target is None or (target_date - last_target).days >= horizon_days:
|
|
909
|
+
selected += 1
|
|
910
|
+
last_target = target_date
|
|
911
|
+
return selected
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def project_effective_sample_size(
|
|
915
|
+
records: Iterable[SettlementCandidate],
|
|
916
|
+
predictions: Iterable[Prediction],
|
|
917
|
+
horizon_days: int = FORECAST_HORIZON_DAYS,
|
|
918
|
+
) -> dict[str, Any]:
|
|
919
|
+
"""When `eff_n` can next increase, given the predictions already written down.
|
|
920
|
+
|
|
921
|
+
`eff_n` counts non-overlapping target windows, so it cannot rise until a
|
|
922
|
+
prediction settles whose target date is at least one horizon after the last
|
|
923
|
+
window this ledger already selected. That date is knowable in advance from
|
|
924
|
+
the outstanding prediction inventory -- it is a fact about what has been
|
|
925
|
+
forecast, not a guess about the market.
|
|
926
|
+
|
|
927
|
+
Reports `null` dates when no outstanding prediction reaches far enough yet;
|
|
928
|
+
later runs create those records at the standard run_date + horizon cadence.
|
|
929
|
+
"""
|
|
930
|
+
target_dates = sorted(
|
|
931
|
+
{
|
|
932
|
+
dt.date.fromisoformat(c.target_date)
|
|
933
|
+
for c in records
|
|
934
|
+
if _looks_like_date(c.target_date)
|
|
935
|
+
}
|
|
936
|
+
)
|
|
937
|
+
selected: list[dt.date] = []
|
|
938
|
+
for target_date in target_dates:
|
|
939
|
+
if not selected or (target_date - selected[-1]).days >= horizon_days:
|
|
940
|
+
selected.append(target_date)
|
|
941
|
+
if not selected:
|
|
942
|
+
return {
|
|
943
|
+
"eff_n": 0,
|
|
944
|
+
"target_date_span_days": None,
|
|
945
|
+
"next_window_opens": None,
|
|
946
|
+
"eff_n_increments_on": None,
|
|
947
|
+
"pending_predictions_at_that_date": 0,
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
pending: dict[dt.date, int] = defaultdict(int)
|
|
951
|
+
for p in predictions:
|
|
952
|
+
if _looks_like_date(p.target_date):
|
|
953
|
+
pending[dt.date.fromisoformat(p.target_date)] += 1
|
|
954
|
+
|
|
955
|
+
next_opens = selected[-1] + dt.timedelta(days=horizon_days)
|
|
956
|
+
qualifying = sorted(d for d in pending if d >= next_opens)
|
|
957
|
+
increments_on = qualifying[0] if qualifying else None
|
|
958
|
+
return {
|
|
959
|
+
"eff_n": len(selected),
|
|
960
|
+
"target_date_span_days": (target_dates[-1] - target_dates[0]).days,
|
|
961
|
+
"earliest_target_date": target_dates[0].isoformat(),
|
|
962
|
+
"latest_target_date": target_dates[-1].isoformat(),
|
|
963
|
+
"selected_windows": [d.isoformat() for d in selected],
|
|
964
|
+
"next_window_opens": next_opens.isoformat(),
|
|
965
|
+
"eff_n_increments_on": increments_on.isoformat() if increments_on else None,
|
|
966
|
+
"pending_predictions_at_that_date": (
|
|
967
|
+
pending[increments_on] if increments_on else 0
|
|
968
|
+
),
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def compute_rolling_metrics(
|
|
973
|
+
canonical: dict[CanonicalKey, SettlementCandidate],
|
|
974
|
+
predictions_index: dict[CanonicalKey, Prediction],
|
|
975
|
+
) -> dict[str, Any]:
|
|
976
|
+
"""Hit rate, CI coverage, mean z, and per-vintage rank IC, per
|
|
977
|
+
rules.md § Rolling Calibration Metrics -- EQUITY_ALPHA and
|
|
978
|
+
MARKET_FORECAST reported separately, never pooled.
|
|
979
|
+
"""
|
|
980
|
+
|
|
981
|
+
def _hit_rate(records: list[SettlementCandidate]) -> float | None:
|
|
982
|
+
scored = [c for c in records if c.direction in ("HIT", "MISS")]
|
|
983
|
+
return (
|
|
984
|
+
(sum(1 for c in scored if c.direction == "HIT") / len(scored))
|
|
985
|
+
if scored
|
|
986
|
+
else None
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
def _ci_coverage(records: list[SettlementCandidate]) -> float | None:
|
|
990
|
+
scored = [c for c in records if c.ci_result is not None]
|
|
991
|
+
return (
|
|
992
|
+
(sum(1 for c in scored if c.ci_result == "IN_CI") / len(scored))
|
|
993
|
+
if scored
|
|
994
|
+
else None
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
def _mean_z(records: list[SettlementCandidate]) -> float | None:
|
|
998
|
+
zs = [c.z for c in records if c.z is not None]
|
|
999
|
+
return (sum(zs) / len(zs)) if zs else None
|
|
1000
|
+
|
|
1001
|
+
def _block(
|
|
1002
|
+
records: list[SettlementCandidate], record_type: str
|
|
1003
|
+
) -> dict[str, Any]:
|
|
1004
|
+
n = len(records)
|
|
1005
|
+
eff_n = effective_independent_sample_size(records)
|
|
1006
|
+
if n < TRACK_A_MIN_SETTLED_N:
|
|
1007
|
+
proposal_status = "INSUFFICIENT_SETTLED_N"
|
|
1008
|
+
elif eff_n < TRACK_A_MIN_EFFECTIVE_N:
|
|
1009
|
+
proposal_status = "INSUFFICIENT_EFFECTIVE_N"
|
|
1010
|
+
else:
|
|
1011
|
+
proposal_status = "ELIGIBLE"
|
|
1012
|
+
return {
|
|
1013
|
+
"n": n,
|
|
1014
|
+
"eff_n": eff_n,
|
|
1015
|
+
"status": "OK" if n >= 10 else "INSUFFICIENT_SETTLED_N",
|
|
1016
|
+
"track_a_calibration_proposal_eligible": (
|
|
1017
|
+
n >= TRACK_A_MIN_SETTLED_N and eff_n >= TRACK_A_MIN_EFFECTIVE_N
|
|
1018
|
+
),
|
|
1019
|
+
"track_a_calibration_proposal_status": proposal_status,
|
|
1020
|
+
"hit_rate": _hit_rate(records),
|
|
1021
|
+
"ci_coverage": _ci_coverage(records),
|
|
1022
|
+
"mean_z": _mean_z(records),
|
|
1023
|
+
"eff_n_projection": project_effective_sample_size(
|
|
1024
|
+
records,
|
|
1025
|
+
[p for p in predictions_index.values() if p.type == record_type],
|
|
1026
|
+
),
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
eq = [c for c in canonical.values() if c.type == "EQUITY_ALPHA"]
|
|
1030
|
+
mf = [c for c in canonical.values() if c.type == "MARKET_FORECAST"]
|
|
1031
|
+
|
|
1032
|
+
by_vintage: dict[tuple[str, str], list[SettlementCandidate]] = defaultdict(list)
|
|
1033
|
+
for c in eq:
|
|
1034
|
+
by_vintage[(c.model, c.vintage)].append(c)
|
|
1035
|
+
|
|
1036
|
+
per_vintage_ic: dict[str, dict[str, Any]] = {}
|
|
1037
|
+
weighted_sum = 0.0
|
|
1038
|
+
weighted_n = 0
|
|
1039
|
+
for (model, vintage), records in sorted(by_vintage.items()):
|
|
1040
|
+
pairs = []
|
|
1041
|
+
for c in records:
|
|
1042
|
+
pred = predictions_index.get(c.key())
|
|
1043
|
+
score = pred.adj_score if pred else None
|
|
1044
|
+
if score is not None and c.realized_alpha is not None:
|
|
1045
|
+
pairs.append((score, c.realized_alpha))
|
|
1046
|
+
ic = spearman_rank_ic(pairs)
|
|
1047
|
+
per_vintage_ic[f"{model}:{vintage}"] = {"n": len(pairs), "rank_ic": ic}
|
|
1048
|
+
if ic is not None and len(pairs) >= 2:
|
|
1049
|
+
weighted_sum += ic * len(pairs)
|
|
1050
|
+
weighted_n += len(pairs)
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
"equity_alpha": _block(eq, "EQUITY_ALPHA"),
|
|
1054
|
+
"market_forecast": _block(mf, "MARKET_FORECAST"),
|
|
1055
|
+
"rank_ic_by_vintage": per_vintage_ic,
|
|
1056
|
+
"rank_ic_weighted_mean": (weighted_sum / weighted_n) if weighted_n else None,
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
# ---------------------------------------------------------------------------
|
|
1061
|
+
# Manifest assembly
|
|
1062
|
+
# ---------------------------------------------------------------------------
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def build_manifest(
|
|
1066
|
+
packages: list[dict[str, Any]], as_of: str | None = None
|
|
1067
|
+
) -> dict[str, Any]:
|
|
1068
|
+
if as_of is None:
|
|
1069
|
+
run_dates = [p.get("run_date", "") for p in packages if p.get("run_date")]
|
|
1070
|
+
as_of = max(run_dates) if run_dates else ""
|
|
1071
|
+
|
|
1072
|
+
all_predictions = extract_predictions(packages)
|
|
1073
|
+
duplicate_books = find_duplicate_books(packages)
|
|
1074
|
+
predictions = [p for p in all_predictions if p.source_file not in duplicate_books]
|
|
1075
|
+
predictions_index: dict[CanonicalKey, Prediction] = {
|
|
1076
|
+
p.key(): p for p in predictions
|
|
1077
|
+
}
|
|
1078
|
+
# A copy's keys differ from its original's only by model name. Exclude just
|
|
1079
|
+
# those, never a key that some original book also carries.
|
|
1080
|
+
duplicate_keys: dict[str, set[CanonicalKey]] = {
|
|
1081
|
+
name: set() for name in duplicate_books
|
|
1082
|
+
}
|
|
1083
|
+
for p in all_predictions:
|
|
1084
|
+
if p.source_file in duplicate_books and p.key() not in predictions_index:
|
|
1085
|
+
duplicate_keys[p.source_file].add(p.key())
|
|
1086
|
+
excluded_keys: set[CanonicalKey] = set().union(*duplicate_keys.values())
|
|
1087
|
+
|
|
1088
|
+
lookup_by_no_vintage: dict[tuple[str, str, str, str], list[Prediction]] = (
|
|
1089
|
+
defaultdict(list)
|
|
1090
|
+
)
|
|
1091
|
+
for p in all_predictions:
|
|
1092
|
+
lookup_by_no_vintage[(p.model, p.ticker, p.type, p.target_date)].append(p)
|
|
1093
|
+
|
|
1094
|
+
candidates = extract_settlement_candidates(packages, lookup_by_no_vintage)
|
|
1095
|
+
for c in candidates:
|
|
1096
|
+
convention, valid, reason = validate_timing(
|
|
1097
|
+
c.target_date,
|
|
1098
|
+
c.settlement_run_date,
|
|
1099
|
+
c.price_date,
|
|
1100
|
+
c.declared_timing_flag,
|
|
1101
|
+
c.settled_at,
|
|
1102
|
+
)
|
|
1103
|
+
c.settlement_convention = convention
|
|
1104
|
+
c.is_timing_valid = valid
|
|
1105
|
+
c.rejection_reason = reason
|
|
1106
|
+
|
|
1107
|
+
def _from_duplicate_book(c: SettlementCandidate) -> bool:
|
|
1108
|
+
return c.source_file in duplicate_books or c.key() in excluded_keys
|
|
1109
|
+
|
|
1110
|
+
duplicate_rows = [c for c in candidates if _from_duplicate_book(c)]
|
|
1111
|
+
canonical, conflicts, rejected, audit_only, conflicted = build_canonical_ledger(
|
|
1112
|
+
[c for c in candidates if not _from_duplicate_book(c)]
|
|
1113
|
+
)
|
|
1114
|
+
conflicted_keys = {c.key for c in conflicts}
|
|
1115
|
+
due = compute_due_inventory(predictions, canonical, conflicted_keys, as_of)
|
|
1116
|
+
metrics = compute_rolling_metrics(canonical, predictions_index)
|
|
1117
|
+
|
|
1118
|
+
eq_n = sum(1 for c in canonical.values() if c.type == "EQUITY_ALPHA")
|
|
1119
|
+
mf_n = sum(1 for c in canonical.values() if c.type == "MARKET_FORECAST")
|
|
1120
|
+
|
|
1121
|
+
return {
|
|
1122
|
+
"as_of": as_of,
|
|
1123
|
+
"generated_from_packages": [p.get("_source_file", "") for p in packages],
|
|
1124
|
+
"summary": {
|
|
1125
|
+
"canonical_equity_alpha_settlements": eq_n,
|
|
1126
|
+
"canonical_market_forecast_settlements": mf_n,
|
|
1127
|
+
"conflicts": len(conflicts),
|
|
1128
|
+
"due_inventory": len(due),
|
|
1129
|
+
"total_candidate_rows": len(candidates),
|
|
1130
|
+
"rejected_rows": len(rejected),
|
|
1131
|
+
"audit_only_rows": len(audit_only),
|
|
1132
|
+
"conflicted_rows": len(conflicted),
|
|
1133
|
+
"duplicate_books": len(duplicate_books),
|
|
1134
|
+
"duplicate_book_rows": len(duplicate_rows),
|
|
1135
|
+
},
|
|
1136
|
+
"canonical_settlements": [c.to_dict() for _, c in sorted(canonical.items())],
|
|
1137
|
+
"conflicts": [c.to_dict() for c in conflicts],
|
|
1138
|
+
"rejected_candidates": [c.to_dict() for c in rejected],
|
|
1139
|
+
"audit_only_candidates": [c.to_dict() for c in audit_only],
|
|
1140
|
+
"conflicted_candidates": [c.to_dict() for c in conflicted],
|
|
1141
|
+
"duplicate_books": [
|
|
1142
|
+
{
|
|
1143
|
+
**book.to_dict(),
|
|
1144
|
+
"excluded_prediction_keys": len(duplicate_keys[name]),
|
|
1145
|
+
"excluded_candidate_rows": sum(
|
|
1146
|
+
1
|
|
1147
|
+
for c in duplicate_rows
|
|
1148
|
+
if c.source_file == name or c.key() in duplicate_keys[name]
|
|
1149
|
+
),
|
|
1150
|
+
}
|
|
1151
|
+
for name, book in sorted(duplicate_books.items())
|
|
1152
|
+
],
|
|
1153
|
+
"duplicate_book_candidates": [c.to_dict() for c in duplicate_rows],
|
|
1154
|
+
"due_inventory": [
|
|
1155
|
+
{
|
|
1156
|
+
"model": k[0],
|
|
1157
|
+
"vintage": k[1],
|
|
1158
|
+
"ticker": k[2],
|
|
1159
|
+
"type": k[3],
|
|
1160
|
+
"target_date": k[4],
|
|
1161
|
+
}
|
|
1162
|
+
for k in sorted(due)
|
|
1163
|
+
],
|
|
1164
|
+
"rolling_metrics": metrics,
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
# ---------------------------------------------------------------------------
|
|
1169
|
+
# CLI
|
|
1170
|
+
# ---------------------------------------------------------------------------
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def parse_args() -> argparse.Namespace:
|
|
1174
|
+
parser = argparse.ArgumentParser(
|
|
1175
|
+
description="Build the canonical settlement ledger from all dated "
|
|
1176
|
+
"15_predictions.json packages."
|
|
1177
|
+
)
|
|
1178
|
+
parser.add_argument(
|
|
1179
|
+
"--output-dir",
|
|
1180
|
+
type=Path,
|
|
1181
|
+
default=Path("agents/equity/output"),
|
|
1182
|
+
help="Directory containing {model}-{date}/15_predictions.json packages.",
|
|
1183
|
+
)
|
|
1184
|
+
parser.add_argument(
|
|
1185
|
+
"--as-of",
|
|
1186
|
+
type=str,
|
|
1187
|
+
default=None,
|
|
1188
|
+
help="Only consider packages with run_date <= this date (YYYY-MM-DD). "
|
|
1189
|
+
"Default: no bound (use every package found).",
|
|
1190
|
+
)
|
|
1191
|
+
parser.add_argument(
|
|
1192
|
+
"--manifest-out",
|
|
1193
|
+
type=Path,
|
|
1194
|
+
default=None,
|
|
1195
|
+
help="Write the full JSON manifest here. Defaults to stdout.",
|
|
1196
|
+
)
|
|
1197
|
+
return parser.parse_args()
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def main() -> None:
|
|
1201
|
+
args = parse_args()
|
|
1202
|
+
packages = load_packages(args.output_dir)
|
|
1203
|
+
if args.as_of:
|
|
1204
|
+
packages = [p for p in packages if p.get("run_date", "") <= args.as_of]
|
|
1205
|
+
|
|
1206
|
+
manifest = build_manifest(packages, as_of=args.as_of)
|
|
1207
|
+
text = json.dumps(manifest, indent=2, sort_keys=True)
|
|
1208
|
+
|
|
1209
|
+
if args.manifest_out:
|
|
1210
|
+
args.manifest_out.parent.mkdir(parents=True, exist_ok=True)
|
|
1211
|
+
args.manifest_out.write_text(text + "\n")
|
|
1212
|
+
print(
|
|
1213
|
+
f"Wrote canonical settlement manifest to {args.manifest_out}",
|
|
1214
|
+
file=sys.stderr,
|
|
1215
|
+
)
|
|
1216
|
+
print(json.dumps(manifest["summary"], indent=2, sort_keys=True))
|
|
1217
|
+
else:
|
|
1218
|
+
print(text)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
if __name__ == "__main__":
|
|
1222
|
+
main()
|