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