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.
Files changed (106) hide show
  1. fa_quant-1.2.1.dist-info/METADATA +236 -0
  2. fa_quant-1.2.1.dist-info/RECORD +106 -0
  3. fa_quant-1.2.1.dist-info/WHEEL +4 -0
  4. fa_quant-1.2.1.dist-info/entry_points.txt +2 -0
  5. fa_quant-1.2.1.dist-info/licenses/LICENSE +21 -0
  6. financial_agent/__init__.py +1 -0
  7. financial_agent/agents/__init__.py +0 -0
  8. financial_agent/agents/claude/__init__.py +0 -0
  9. financial_agent/agents/gpt/__init__.py +0 -0
  10. financial_agent/agents/grok/__init__.py +0 -0
  11. financial_agent/dl/__init__.py +0 -0
  12. financial_agent/dl/models/__init__.py +0 -0
  13. financial_agent/dl/models/baseline_model.py +23 -0
  14. financial_agent/dl/preprocessing/__init__.py +0 -0
  15. financial_agent/dl/preprocessing/data_windowing.py +135 -0
  16. financial_agent/harness/__init__.py +73 -0
  17. financial_agent/harness/__main__.py +430 -0
  18. financial_agent/harness/_system/factor_scoring.py +175 -0
  19. financial_agent/harness/_system/settlement_ledger.py +1222 -0
  20. financial_agent/harness/audit.py +393 -0
  21. financial_agent/harness/equity_policy.toml +179 -0
  22. financial_agent/harness/fingerprint.py +55 -0
  23. financial_agent/harness/gates.py +522 -0
  24. financial_agent/harness/handlers.py +77 -0
  25. financial_agent/harness/helpers.py +47 -0
  26. financial_agent/harness/kernels.py +696 -0
  27. financial_agent/harness/ledger.py +325 -0
  28. financial_agent/harness/lifecycle.py +127 -0
  29. financial_agent/harness/manifest.py +113 -0
  30. financial_agent/harness/market_calendar.py +50 -0
  31. financial_agent/harness/mcp.py +505 -0
  32. financial_agent/harness/policy.py +248 -0
  33. financial_agent/harness/runner.py +275 -0
  34. financial_agent/harness/schema.py +451 -0
  35. financial_agent/harness/scoring.py +363 -0
  36. financial_agent/harness/skills.py +458 -0
  37. financial_agent/quant/__init__.py +1 -0
  38. financial_agent/quant/__main__.py +506 -0
  39. financial_agent/quant/backtest/__init__.py +16 -0
  40. financial_agent/quant/backtest/config.py +74 -0
  41. financial_agent/quant/backtest/costs.py +62 -0
  42. financial_agent/quant/backtest/engine.py +248 -0
  43. financial_agent/quant/backtest/metrics.py +102 -0
  44. financial_agent/quant/backtest/result.py +51 -0
  45. financial_agent/quant/data/__init__.py +21 -0
  46. financial_agent/quant/data/bars.py +103 -0
  47. financial_agent/quant/data/cache.py +168 -0
  48. financial_agent/quant/data/providers.py +138 -0
  49. financial_agent/quant/data/quality.py +104 -0
  50. financial_agent/quant/data/synthetic.py +67 -0
  51. financial_agent/quant/execution/__init__.py +24 -0
  52. financial_agent/quant/execution/alpaca.py +293 -0
  53. financial_agent/quant/execution/broker.py +98 -0
  54. financial_agent/quant/execution/ibkr.py +634 -0
  55. financial_agent/quant/execution/journal.py +139 -0
  56. financial_agent/quant/execution/paper.py +254 -0
  57. financial_agent/quant/execution/reconcile.py +35 -0
  58. financial_agent/quant/mcp.py +210 -0
  59. financial_agent/quant/portfolio.py +56 -0
  60. financial_agent/quant/quant_policy.toml +40 -0
  61. financial_agent/quant/risk/__init__.py +21 -0
  62. financial_agent/quant/risk/killswitch.py +61 -0
  63. financial_agent/quant/risk/limits.py +109 -0
  64. financial_agent/quant/risk/orders.py +91 -0
  65. financial_agent/quant/risk/pretrade.py +157 -0
  66. financial_agent/quant/risk/state.py +86 -0
  67. financial_agent/quant/runner/__init__.py +20 -0
  68. financial_agent/quant/runner/config.py +254 -0
  69. financial_agent/quant/runner/promotion.py +277 -0
  70. financial_agent/quant/runner/report.py +231 -0
  71. financial_agent/quant/runner/session.py +705 -0
  72. financial_agent/quant/strategies/__init__.py +19 -0
  73. financial_agent/quant/strategies/base.py +44 -0
  74. financial_agent/quant/strategies/benchmarks.py +54 -0
  75. financial_agent/quant/strategies/momentum.py +80 -0
  76. financial_agent/quant/strategies/registry.py +65 -0
  77. financial_agent/quant/strategies/research.py +226 -0
  78. financial_agent/quant/strategies/trend.py +91 -0
  79. financial_agent/quant/validation/__init__.py +41 -0
  80. financial_agent/quant/validation/bootstrap.py +39 -0
  81. financial_agent/quant/validation/certificate.py +189 -0
  82. financial_agent/quant/validation/gate.py +304 -0
  83. financial_agent/quant/validation/overfitting.py +148 -0
  84. financial_agent/quant/validation/stress.py +33 -0
  85. financial_agent/quant/validation/walkforward.py +157 -0
  86. financial_agent/rl/__init__.py +13 -0
  87. financial_agent/rl/agent.py +112 -0
  88. financial_agent/rl/environment.py +177 -0
  89. financial_agent/rl/trainer.py +142 -0
  90. financial_agent/trading/__init__.py +0 -0
  91. financial_agent/trading/autotrain/__init__.py +0 -0
  92. financial_agent/trading/autotrain/training.py +92 -0
  93. financial_agent/trading/backtest.py +118 -0
  94. financial_agent/trading/config/__init__.py +0 -0
  95. financial_agent/trading/config/config.py +1587 -0
  96. financial_agent/trading/marketdata/__init__.py +0 -0
  97. financial_agent/trading/marketdata/yahoodownloader.py +99 -0
  98. financial_agent/trading/model/__init__.py +0 -0
  99. financial_agent/trading/model/models.py +466 -0
  100. financial_agent/trading/preprocessing/__init__.py +0 -0
  101. financial_agent/trading/preprocessing/data.py +32 -0
  102. financial_agent/trading/preprocessing/preprocessors.py +162 -0
  103. financial_agent/trading/utils/__init__.py +0 -0
  104. financial_agent/trading/utils/gpu_config.py +213 -0
  105. financial_agent/trading/utils/main.py +52 -0
  106. 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()