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,393 @@
1
+ """Conformance audit of ``15_predictions.json`` ledgers against the policy.
2
+
3
+ Every prediction record carries its own inputs (entry, mu, sigma, z-scores),
4
+ so the formula fields can be recomputed and compared. The spike runs this
5
+ over every historical package to measure how often model-authored ledgers
6
+ drift from ``rules.md`` -- the evidence behind "code computes, the model
7
+ cites". The same function is the per-record check inside the publish gate.
8
+
9
+ Severities:
10
+ * ``error`` -- violates an unambiguous rule (enum, formula, required field).
11
+ * ``drift`` -- a unit or meaning the rules leave implicit, used inconsistently
12
+ across runs (e.g. percent vs decimal VaR, capped vs uncapped Kelly).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime as dt
18
+ import json
19
+ from collections import Counter, defaultdict
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Mapping
23
+
24
+ from financial_agent.harness.policy import FAMILIES, Policy
25
+
26
+ REQUIRED_FIELDS = (
27
+ "run_date",
28
+ "model",
29
+ "ticker",
30
+ "entry_price",
31
+ "price_tag",
32
+ "price_date",
33
+ "mu",
34
+ "sigma",
35
+ "sigma_source",
36
+ "ci70_lo",
37
+ "ci70_hi",
38
+ "target_date",
39
+ "benchmark",
40
+ "benchmark_price",
41
+ "adj_score",
42
+ "confidence",
43
+ "status",
44
+ "thesis",
45
+ )
46
+ ENUMS: dict[str, frozenset[str]] = {
47
+ "type": frozenset({"EQUITY_ALPHA", "MARKET_FORECAST"}),
48
+ "price_tag": frozenset(
49
+ {"LIVE", "DELAYED", "OFFICIAL_FILING", "HISTORICAL", "ILLUSTRATIVE_REF"}
50
+ ),
51
+ "sigma_source": frozenset(
52
+ {"REALIZED_VOL_30D", "IV30", "SECTOR_MEDIAN", "SECTOR_MEDIAN_ILLUS"}
53
+ ),
54
+ "confidence": frozenset({"HIGH", "MEDIUM", "LOW"}),
55
+ }
56
+ # settlement_ledger.py: first name canonical, the rest legacy spellings.
57
+ SETTLEMENT_LEGACY_KEYS = {
58
+ "settlement_price": "settle_price",
59
+ "current_price": "settle_price",
60
+ "settlement_date": "settle_price_date",
61
+ "current_price_date": "settle_price_date",
62
+ "settle_date": "settle_price_date",
63
+ "current_price_tag": "settlement_price_tag",
64
+ "settlement_timing_flag": "timing_flag",
65
+ "settled_on": "settled_at",
66
+ }
67
+ CORE_ETF_RULE_EFFECTIVE = "2026-06-11" # rules.md § Core ETF Market Forecast
68
+ REL_TOL = 2e-3 # prices and CI bounds are stored rounded
69
+ ABS_TOL = 5e-3 # z-scores and scores are stored rounded to 2-4 places
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Finding:
74
+ check: str
75
+ severity: str
76
+ package: str
77
+ ticker: str
78
+ detail: str
79
+
80
+
81
+ def _num(value: Any) -> float | None:
82
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
83
+ return None
84
+ return float(value)
85
+
86
+
87
+ def _close(actual: float, expected: float, *, rel: float = REL_TOL) -> bool:
88
+ return abs(actual - expected) <= rel * max(abs(expected), 1e-9)
89
+
90
+
91
+ _KELLY_UNITS = (
92
+ (1.0, 1.0, "DECIMAL"),
93
+ (100.0, 100.0, "PERCENT"),
94
+ (1.0, 100.0, "MIXED_UNITS"), # raw as a decimal, kelly_025 as percent
95
+ )
96
+
97
+
98
+ def kelly_convention(raw: float, stored: float, cap: float) -> str:
99
+ """Which of the conventions seen in history a stored ``kelly_025`` follows."""
100
+ for raw_scale, k_scale, unit in _KELLY_UNITS:
101
+ r, k = raw / raw_scale, stored / k_scale
102
+ if abs(k - 0.25 * r) <= 1e-3 * max(1.0, abs(r)):
103
+ return f"UNCAPPED_{unit}"
104
+ if abs(k - min(0.25 * r, cap)) <= 1e-3:
105
+ return f"CAPPED_{unit}"
106
+ return "UNRECOGNIZED"
107
+
108
+
109
+ @dataclass
110
+ class RecordAudit:
111
+ checks: list[str] = field(default_factory=list)
112
+ findings: list[Finding] = field(default_factory=list)
113
+ kelly_convention: str | None = None
114
+
115
+
116
+ def audit_record(
117
+ record: Mapping[str, Any], policy: Policy, *, package: str = ""
118
+ ) -> RecordAudit:
119
+ """Recompute one prediction record's formula fields and check its schema."""
120
+ ticker = str(record.get("ticker", "?"))
121
+ result = RecordAudit()
122
+ checks, findings = result.checks, result.findings
123
+
124
+ def check(name: str, ok: bool, detail: str, severity: str = "error") -> None:
125
+ checks.append(name)
126
+ if not ok:
127
+ findings.append(Finding(name, severity, package, ticker, detail))
128
+
129
+ missing = [f for f in REQUIRED_FIELDS if f not in record]
130
+ check("schema.required_fields", not missing, f"missing {missing}")
131
+ bad_enums = [
132
+ f"{key}={record.get(key)!r}"
133
+ for key, allowed in ENUMS.items()
134
+ if key in record and record.get(key) not in allowed
135
+ ]
136
+ check("schema.enums", not bad_enums, ", ".join(bad_enums))
137
+
138
+ kind = str(record.get("type", "EQUITY_ALPHA"))
139
+ entry, mu, sigma = (_num(record.get(k)) for k in ("entry_price", "mu", "sigma"))
140
+ if mu is not None and sigma is not None:
141
+ check(
142
+ "units.mu_sigma_decimal",
143
+ abs(mu) < 0.5 and 0 < sigma < 1,
144
+ f"mu={mu} sigma={sigma}",
145
+ "drift",
146
+ )
147
+ lo, hi = _num(record.get("ci70_lo")), _num(record.get("ci70_hi"))
148
+ if (
149
+ entry is not None
150
+ and mu is not None
151
+ and sigma is not None
152
+ and lo is not None
153
+ and hi is not None
154
+ ):
155
+ exp_lo, exp_hi = policy.ci70(entry, mu, sigma)
156
+ check(
157
+ "formula.ci70",
158
+ _close(lo, exp_lo) and _close(hi, exp_hi),
159
+ f"ci70=[{lo}, {hi}] expected [{exp_lo:.4f}, {exp_hi:.4f}]",
160
+ )
161
+ target = _num(record.get("target_price"))
162
+ if target is not None and entry is not None and mu is not None:
163
+ expected = policy.target_price(entry, mu)
164
+ check(
165
+ "formula.target_price",
166
+ _close(target, expected),
167
+ f"target={target} expected {expected:.4f}",
168
+ )
169
+ try:
170
+ offset = (
171
+ dt.date.fromisoformat(str(record.get("target_date")))
172
+ - dt.date.fromisoformat(str(record.get("run_date")))
173
+ ).days
174
+ horizons = list(policy.get("forecast.allowed_horizon_days"))
175
+ check("horizon.target_offset", offset in horizons, f"offset {offset}d")
176
+ except ValueError:
177
+ check("horizon.target_offset", False, "unparseable run_date/target_date")
178
+
179
+ if kind == "MARKET_FORECAST":
180
+ check(
181
+ "market_forecast.shape",
182
+ record.get("benchmark") == "NONE"
183
+ and record.get("benchmark_price") is None
184
+ and record.get("adj_score") is None,
185
+ "benchmark must be NONE with null benchmark_price and adj_score",
186
+ )
187
+ return result
188
+
189
+ check(
190
+ "equity.benchmark_price",
191
+ _num(record.get("benchmark_price")) is not None,
192
+ "EQUITY_ALPHA needs benchmark_price to settle alpha",
193
+ )
194
+ pctl = _num(record.get("pctl", record.get("percentile")))
195
+ if pctl is not None and mu is not None:
196
+ band = policy.mu_prior(pctl)
197
+ if band is None:
198
+ check("formula.mu_band", False, f"ranked at pctl {pctl} below every band")
199
+ else:
200
+ limit = policy.num("forecast.mu_adjustment_max")
201
+ check(
202
+ "formula.mu_band",
203
+ abs(mu - band.mu) <= limit + 1e-9,
204
+ f"mu {mu:+.3f} vs prior {band.mu:+.3f} at pctl {pctl} (max ±{limit})",
205
+ )
206
+
207
+ explain = record.get("score_explainability")
208
+ if not isinstance(explain, Mapping):
209
+ return result
210
+ composite = _num(explain.get("composite_z"))
211
+ if composite is not None:
212
+ expected = policy.composite_z({f: _num(explain.get(f)) for f in FAMILIES})
213
+ check(
214
+ "formula.composite_z",
215
+ abs(composite - expected) <= ABS_TOL,
216
+ f"composite_z {composite} expected {expected:.4f}",
217
+ )
218
+ dq, adj = _num(explain.get("data_quality_multiplier")), _num(
219
+ record.get("adj_score")
220
+ )
221
+ if dq is not None and adj is not None:
222
+ penalties = _num(explain.get("penalties")) or 0.0
223
+ expected = policy.adj_score(composite, dq, penalties)
224
+ if abs(adj) > 5:
225
+ hint = "; value looks like a 0-100 percentile rank, not a score"
226
+ elif abs(adj - (composite - penalties)) <= ABS_TOL:
227
+ hint = "; DQ multiplier not applied"
228
+ else:
229
+ hint = ""
230
+ check(
231
+ "formula.adj_score",
232
+ abs(adj - expected) <= ABS_TOL,
233
+ f"adj_score {adj} expected {expected:.4f} "
234
+ f"(= {composite} x DQ {dq} - {penalties}){hint}",
235
+ )
236
+ metrics = explain.get("metrics")
237
+ if not isinstance(metrics, Mapping) or mu is None or sigma is None:
238
+ return result
239
+ for name, fn in (("var95", policy.var95), ("cvar95", policy.cvar95)):
240
+ stored = _num(metrics.get(name))
241
+ if stored is None:
242
+ continue
243
+ percent = abs(stored) >= 1
244
+ check(f"units.{name}_decimal", not percent, f"{name}={stored}", "drift")
245
+ value = stored / 100 if percent else stored
246
+ check(
247
+ f"formula.{name}",
248
+ abs(value - fn(mu, sigma)) <= ABS_TOL,
249
+ f"{name} {value:.4f} expected {fn(mu, sigma):.4f}",
250
+ )
251
+ raw, stored_k = _num(metrics.get("kelly_raw")), _num(metrics.get("kelly_025"))
252
+ if raw is not None and stored_k is not None and sigma > 0:
253
+ convention = kelly_convention(
254
+ raw, stored_k, policy.num("risk.max_single_name_weight")
255
+ )
256
+ result.kelly_convention = convention
257
+ check(
258
+ "semantics.kelly_025",
259
+ convention != "UNRECOGNIZED",
260
+ f"kelly_025={stored_k} kelly_raw={raw}",
261
+ )
262
+ check(
263
+ "units.kelly_decimal",
264
+ convention.endswith("_DECIMAL"),
265
+ f"kelly convention {convention}",
266
+ "drift",
267
+ )
268
+ # rules.md prefers a beta-adjusted edge over tracking-error variance and
269
+ # falls back to mu/sigma^2, but no record says which it used: a value
270
+ # that is not the fallback cannot be verified from the record.
271
+ scale = 100.0 if convention.endswith("_PERCENT") else 1.0
272
+ check(
273
+ "lineage.kelly_raw_method",
274
+ _close(raw / scale, mu / sigma**2, rel=0.01),
275
+ f"kelly_raw {raw / scale:.4f} is not the mu/sigma^2 fallback "
276
+ f"{mu / sigma**2:.4f} and the method is unrecorded",
277
+ "drift",
278
+ )
279
+ return result
280
+
281
+
282
+ @dataclass
283
+ class AuditReport:
284
+ packages: int = 0
285
+ records: int = 0
286
+ checked: Counter[str] = field(default_factory=Counter)
287
+ findings: list[Finding] = field(default_factory=list)
288
+ kelly_by_model: dict[str, Counter[str]] = field(
289
+ default_factory=lambda: defaultdict(Counter)
290
+ )
291
+ settlement_rows: int = 0
292
+ settlement_keysets: set[tuple[str, ...]] = field(default_factory=set)
293
+ settlement_legacy_keys: Counter[str] = field(default_factory=Counter)
294
+ packages_without_ledger: list[str] = field(default_factory=list)
295
+
296
+ def failed(self, check: str) -> list[Finding]:
297
+ return [f for f in self.findings if f.check == check]
298
+
299
+ def to_markdown(self) -> str:
300
+ lines = [
301
+ f"Packages audited: {self.packages} "
302
+ f"({len(self.packages_without_ledger)} without 15_predictions.json); "
303
+ f"prediction records: {self.records}; settlement rows: "
304
+ f"{self.settlement_rows}.",
305
+ "",
306
+ "| check | severity | checked | failed | fail % | packages | models |",
307
+ "|---|---|---|---|---|---|---|",
308
+ ]
309
+ for name in sorted(self.checked):
310
+ fails = self.failed(name)
311
+ severity = fails[0].severity if fails else "-"
312
+ packages = {f.package for f in fails}
313
+ models = {f.package.rsplit("-", 3)[0] for f in fails}
314
+ pct = 100 * len(fails) / self.checked[name]
315
+ lines.append(
316
+ f"| {name} | {severity} | {self.checked[name]} | {len(fails)} | "
317
+ f"{pct:.1f} | {len(packages)} | {', '.join(sorted(models)) or '-'} |"
318
+ )
319
+ lines += ["", "`kelly_025` convention by model:", ""]
320
+ for model in sorted(self.kelly_by_model):
321
+ counts = ", ".join(
322
+ f"{k} {v}" for k, v in sorted(self.kelly_by_model[model].items())
323
+ )
324
+ lines.append(f"- {model}: {counts}")
325
+ lines += [
326
+ "",
327
+ f"Settlement rows use {len(self.settlement_keysets)} distinct key-sets; "
328
+ "legacy (non-canonical) field names in use:",
329
+ "",
330
+ ]
331
+ for key, count in self.settlement_legacy_keys.most_common():
332
+ lines.append(
333
+ f"- `{key}` (canonical `{SETTLEMENT_LEGACY_KEYS[key]}`): {count}"
334
+ )
335
+ return "\n".join(lines)
336
+
337
+
338
+ def audit_output_dir(
339
+ output_dir: Path, policy: Policy, *, as_of: str | None = None
340
+ ) -> AuditReport:
341
+ """Audit every ``{model}-{YYYY-MM-DD}`` package, optionally up to ``as_of``."""
342
+ report = AuditReport()
343
+ for package_dir in sorted(p for p in output_dir.iterdir() if p.is_dir()):
344
+ run_date = package_dir.name[-10:]
345
+ try:
346
+ dt.date.fromisoformat(run_date)
347
+ except ValueError:
348
+ continue
349
+ if as_of is not None and run_date > as_of:
350
+ continue
351
+ report.packages += 1
352
+ path = package_dir / "15_predictions.json"
353
+ if not path.exists():
354
+ report.packages_without_ledger.append(package_dir.name)
355
+ continue
356
+ payload = json.loads(path.read_text(encoding="utf-8"))
357
+ predictions = payload.get("predictions") or []
358
+ model = package_dir.name[: -len(run_date) - 1]
359
+ for record in predictions:
360
+ report.records += 1
361
+ result = audit_record(record, policy, package=package_dir.name)
362
+ report.checked.update(result.checks)
363
+ report.findings.extend(result.findings)
364
+ if result.kelly_convention is not None:
365
+ report.kelly_by_model[model][result.kelly_convention] += 1
366
+ ranked = any(
367
+ p.get("type", "EQUITY_ALPHA") == "EQUITY_ALPHA" for p in predictions
368
+ )
369
+ if ranked and run_date >= CORE_ETF_RULE_EFFECTIVE:
370
+ etfs = {
371
+ p.get("ticker")
372
+ for p in predictions
373
+ if p.get("type") == "MARKET_FORECAST"
374
+ }
375
+ missing = sorted(set(policy.get("market_forecast.tickers")) - etfs)
376
+ report.checked["package.core_etf_forecasts"] += 1
377
+ if missing:
378
+ report.findings.append(
379
+ Finding(
380
+ "package.core_etf_forecasts",
381
+ "error",
382
+ package_dir.name,
383
+ "-",
384
+ f"ranked names without MARKET_FORECAST for {missing}",
385
+ )
386
+ )
387
+ for row in payload.get("settlements") or []:
388
+ report.settlement_rows += 1
389
+ report.settlement_keysets.add(tuple(sorted(row)))
390
+ for key in row:
391
+ if key in SETTLEMENT_LEGACY_KEYS:
392
+ report.settlement_legacy_keys[key] += 1
393
+ return report
@@ -0,0 +1,179 @@
1
+ # Equity daily research policy: the numeric rules of
2
+ # agents/equity/daily_investment_system/rules.md (v3.3) as data.
3
+ #
4
+ # Spike status: rules.md prose stays canonical. This file mirrors it so the
5
+ # harness can compute and gate deterministically; Phase 3 of
6
+ # plan/2026-09-28-financial-harness-and-skills.md makes this file canonical and
7
+ # generates the rules.md tables from it.
8
+ #
9
+ # Governance (enforced by policy.check_mutation):
10
+ # * keys under a `protected` prefix change only with human approval;
11
+ # * keys under a `track_a` prefix change only with settled-prediction
12
+ # evidence (n and eff_n gates), otherwise the proposal is DEFERred;
13
+ # * the `evolution` table itself is protected (a change cannot relax its
14
+ # own limits).
15
+
16
+ schema_version = 1
17
+ policy_version = "3.3"
18
+ effective_date = "2026-08-03"
19
+
20
+ [score]
21
+ # rules.md § Financial Metrics and Score Attribution
22
+ formula = "Adj Score = (0.30*Fund_Z + 0.30*Tech_Z + 0.25*Sent_Z + 0.15*Macro_Z) * DQ - Penalties"
23
+ unavailable_family_contribution = 0.0
24
+ # rules.md § SHADOW Diagnostic Tooling: these families are computed as
25
+ # diagnostics only and may not enter Adj Score, the evidence-threshold count, or
26
+ # the confidence label until a reviewed promotion (protected below).
27
+ shadow_families = ["fund_z", "sent_z"]
28
+ # rules.md § Family Aggregation: a family with fewer sourceable slots is
29
+ # UNAVAILABLE. § Metric History and Grounding: a slot scores only when it is
30
+ # sourceable for at least this share of the eligible universe.
31
+ min_family_slots = 2
32
+ min_slot_coverage = 0.70
33
+
34
+ [score.family_weights]
35
+ fund_z = 0.30
36
+ tech_z = 0.30
37
+ sent_z = 0.25
38
+ macro_z = 0.15
39
+
40
+ [score.slots]
41
+ # The normative Metric Definition Table (Track B, shipped 2026-08-04; carried in
42
+ # 05_factor_scores.md since): the slots each family averages. Their transforms
43
+ # and polarity are formulas and live in scoring.py.
44
+ tech_z = ["mom20", "mom60", "ma_align", "macd", "vol_conf", "dd60"]
45
+ macro_z = ["beta", "sector_lead", "rate_sens", "vol_stability"]
46
+
47
+ [forecast]
48
+ # rules.md § Price and Target Citation Standard, § Ratio Definitions
49
+ ci70_z = 1.04
50
+ var95_z = 1.65
51
+ cvar95_z = 2.06
52
+ default_horizon_days = 28
53
+ allowed_horizon_days = [21, 28, 42]
54
+ mu_adjustment_max = 0.02
55
+
56
+ # rules.md § mu Calibration Table (adjusted-score percentile -> prior mu)
57
+ [[forecast.mu_bands]]
58
+ min_pctl = 95.0
59
+ mu = 0.06
60
+ sleeve = "INVESTABLE"
61
+
62
+ [[forecast.mu_bands]]
63
+ min_pctl = 90.0
64
+ mu = 0.05
65
+ sleeve = "INVESTABLE"
66
+
67
+ [[forecast.mu_bands]]
68
+ min_pctl = 85.0
69
+ mu = 0.04
70
+ sleeve = "INVESTABLE"
71
+
72
+ [[forecast.mu_bands]]
73
+ min_pctl = 80.0
74
+ mu = 0.03
75
+ sleeve = "INVESTABLE"
76
+
77
+ [[forecast.mu_bands]]
78
+ min_pctl = 70.0
79
+ mu = 0.02
80
+ sleeve = "MONITORING"
81
+
82
+ [[forecast.mu_bands]]
83
+ min_pctl = 60.0
84
+ mu = 0.01
85
+ sleeve = "MONITORING"
86
+
87
+ [market_forecast]
88
+ # rules.md § Core ETF Market Forecast
89
+ tickers = ["SPY", "QQQ", "SOXX"]
90
+ spy_adjustment_max = 0.01
91
+ beta_scaled_adjustment_max = 0.015
92
+ flat_call_abs_mu = 0.005
93
+
94
+ [market_forecast.spy_regime_prior]
95
+ BULL = 0.02
96
+ NEUTRAL = 0.005
97
+ HIGH_VOL = 0.0
98
+ RATE_SHOCK = -0.015
99
+ BEAR = -0.025
100
+
101
+ [evidence]
102
+ # rules.md § Evidence Thresholds, § Data Quality Multiplier, Downgrade to NO_TRADE
103
+ min_pctl = 80.0
104
+ min_non_negative_families = 3
105
+ max_family_conviction_share = 0.50
106
+ min_data_completeness = 0.85
107
+ min_data_quality_multiplier = 0.70
108
+ min_investable_names = 5
109
+ max_investable_names = 10
110
+ # Downgrade to NO_TRADE #4: event risk too concentrated.
111
+ max_earnings_names = 2
112
+
113
+ [confidence]
114
+ # rules.md § Confidence Labels: the highest label a record may carry. A model may
115
+ # publish a lower label, never a higher one. Families count when non-negative,
116
+ # as in evidence threshold 2.
117
+ high_min_families = 4
118
+ high_min_pctl = 90.0
119
+ high_min_data_quality = 0.90
120
+ medium_min_families = 3
121
+ medium_min_pctl = 80.0
122
+ medium_min_data_quality = 0.80
123
+
124
+ [kelly]
125
+ # rules.md § Risk Controls / Position Level. Values are NAV fractions; the
126
+ # cap-binding level is risk.max_single_name_weight (one source of truth).
127
+ fraction = 0.25
128
+ block_at_or_below = 0.0
129
+ penalty_below = 0.02
130
+
131
+ [risk]
132
+ # rules.md § Risk Controls / Portfolio Level (protected rules 3-7)
133
+ max_single_name_weight = 0.05
134
+ max_sector_weight = 0.30
135
+ beta_band = [0.90, 1.10]
136
+ max_avg_pairwise_corr = 0.45
137
+ max_dd95_1m = 0.08
138
+ earnings_window_days = 14
139
+ earnings_penalty = 0.10
140
+ enhancing_missing_gross_cap = 0.50
141
+ # What the beta band and the sector cap are measured on: NAV (uninvested NAV is
142
+ # cash at beta 0) or INVESTED (the book normalized by gross). NAV is the literal
143
+ # reading; at a 5% name cap and <= 10 names it makes the beta floor unreachable
144
+ # for ordinary betas. Owner decision pending (plan/2026-09-28 §6, decision 7).
145
+ exposure_basis = "NAV"
146
+
147
+ [calendar]
148
+ # runbook.md § Cadence: "U.S. market holidays still publish an ILLUSTRATIVE_MODE
149
+ # REVIEW_ONLY artifact set".
150
+ holiday_status = "REVIEW_ONLY"
151
+ # Weekends have no written rule yet (plan/2026-09-28 §6 decision 6). COMPUTED
152
+ # keeps the status the gates compute; set REVIEW_ONLY or NO_TRADE once decided.
153
+ weekend_status = "COMPUTED"
154
+
155
+ [evolution]
156
+ # rules.md § Evolution Policy
157
+ max_family_weight_step = 0.05
158
+ track_a_min_settled_n = 20
159
+ track_a_min_eff_n = 3
160
+ protected = [
161
+ "risk.max_single_name_weight",
162
+ "risk.max_sector_weight",
163
+ "risk.beta_band",
164
+ "risk.max_avg_pairwise_corr",
165
+ "risk.max_dd95_1m",
166
+ "risk.exposure_basis",
167
+ "score.shadow_families",
168
+ "calendar",
169
+ "evolution",
170
+ ]
171
+ track_a = [
172
+ "score.family_weights",
173
+ "forecast.mu_bands",
174
+ "market_forecast.spy_regime_prior",
175
+ "market_forecast.spy_adjustment_max",
176
+ "market_forecast.beta_scaled_adjustment_max",
177
+ "evidence",
178
+ "kelly",
179
+ ]
@@ -0,0 +1,55 @@
1
+ """Content hashes for run packages, to catch cloned artifacts.
2
+
3
+ Historical packages show byte-identical analysis files shared across different
4
+ models and dates (for example ``gpt-5-2026-06-20`` and ``gemini-3.5-flash-2026-06-21``
5
+ have the same ``06_top_candidates.md``). A package digest pins what was
6
+ published; ``find_clones`` reports artifacts that another package also contains.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import hashlib
13
+ from collections import defaultdict
14
+ from pathlib import Path
15
+
16
+ ARTIFACT_SUFFIXES = {".md", ".json"}
17
+
18
+
19
+ def artifact_digests(package_dir: Path) -> dict[str, str]:
20
+ """SHA-256 of every artifact file in a package, keyed by file name."""
21
+ return {
22
+ path.name: hashlib.sha256(path.read_bytes()).hexdigest()
23
+ for path in sorted(package_dir.iterdir())
24
+ if path.is_file() and path.suffix in ARTIFACT_SUFFIXES
25
+ }
26
+
27
+
28
+ def package_digest(package_dir: Path) -> str:
29
+ """One hash over all artifact names and contents; order-independent of disk."""
30
+ lines = "".join(
31
+ f"{name}:{digest}\n" for name, digest in artifact_digests(package_dir).items()
32
+ )
33
+ return hashlib.sha256(lines.encode("utf-8")).hexdigest()
34
+
35
+
36
+ def find_clones(
37
+ output_dir: Path, *, since: str | None = None
38
+ ) -> dict[tuple[str, str], list[str]]:
39
+ """Packages sharing a byte-identical artifact, keyed by ``(file, sha256)``.
40
+
41
+ Only packages named ``{model}-{YYYY-MM-DD}`` dated on or after ``since`` are
42
+ compared. Empty files are ignored: they carry no content to clone.
43
+ """
44
+ seen: dict[tuple[str, str], list[str]] = defaultdict(list)
45
+ for package_dir in sorted(p for p in output_dir.iterdir() if p.is_dir()):
46
+ try:
47
+ dt.date.fromisoformat(package_dir.name[-10:])
48
+ except ValueError:
49
+ continue
50
+ if since is not None and package_dir.name[-10:] < since:
51
+ continue
52
+ for name, digest in artifact_digests(package_dir).items():
53
+ if digest != hashlib.sha256(b"").hexdigest():
54
+ seen[(name, digest)].append(package_dir.name)
55
+ return {key: pkgs for key, pkgs in seen.items() if len(pkgs) > 1}