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,522 @@
|
|
|
1
|
+
"""Decision gates: evidence thresholds, stop criteria, and the publish gate.
|
|
2
|
+
|
|
3
|
+
These are the parts of ``rules.md`` that decide outcomes. As prose they are
|
|
4
|
+
re-interpreted by every model on every run; as functions they give the same
|
|
5
|
+
answer for the same inputs, and the model's job shrinks to explaining it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from financial_agent.harness.audit import audit_record
|
|
17
|
+
from financial_agent.harness.lifecycle import RunStatus
|
|
18
|
+
from financial_agent.harness.market_calendar import HOLIDAY, Session, session
|
|
19
|
+
from financial_agent.harness.policy import FAMILIES, Policy
|
|
20
|
+
|
|
21
|
+
# runbook.md § Artifact Table
|
|
22
|
+
ALWAYS_ARTIFACTS = (
|
|
23
|
+
"00_run_manifest.md",
|
|
24
|
+
"01_preflight.md",
|
|
25
|
+
"02_reflection.md",
|
|
26
|
+
"03_regime_and_data.md",
|
|
27
|
+
"04_universe_summary.md",
|
|
28
|
+
"05_factor_scores.md",
|
|
29
|
+
"06_top_candidates.md",
|
|
30
|
+
"07_portfolio_proposal.md",
|
|
31
|
+
"08_risk_review.md",
|
|
32
|
+
"09_final_report.md",
|
|
33
|
+
"13_evolution_log.md",
|
|
34
|
+
)
|
|
35
|
+
CHECKPOINT_ARTIFACTS = (
|
|
36
|
+
"10_midday_monitor.md",
|
|
37
|
+
"11_preclose_check.md",
|
|
38
|
+
"12_close_log.md",
|
|
39
|
+
"14_weekly_review.md",
|
|
40
|
+
"16_monthly_review.md",
|
|
41
|
+
)
|
|
42
|
+
PREDICTIONS_FILE = "15_predictions.json"
|
|
43
|
+
# rules.md § Input Classification: only these may block GO.
|
|
44
|
+
REQUIRED_INPUTS = (
|
|
45
|
+
"grounded_entry_price",
|
|
46
|
+
"price_history_60d",
|
|
47
|
+
"sigma_fallback_chain",
|
|
48
|
+
"next_earnings_date",
|
|
49
|
+
"index_union_universe",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _num(value: Any) -> float | None:
|
|
54
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
55
|
+
return None
|
|
56
|
+
return float(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _family_z(record: Mapping[str, Any], policy: Policy) -> dict[str, float | None]:
|
|
60
|
+
"""Family z-scores that may count: SHADOW families never do (rules.md
|
|
61
|
+
§ SHADOW Diagnostic Tooling), whatever value a record carries."""
|
|
62
|
+
explain = record.get("score_explainability")
|
|
63
|
+
if not isinstance(explain, Mapping):
|
|
64
|
+
return {f: None for f in FAMILIES}
|
|
65
|
+
shadow = set(policy.shadow_families)
|
|
66
|
+
return {f: None if f in shadow else _num(explain.get(f)) for f in FAMILIES}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _kelly_gate(record: Mapping[str, Any], policy: Policy) -> str | None:
|
|
70
|
+
"""A v1 record's own ``kelly_gate`` (it may use the beta-adjusted method);
|
|
71
|
+
otherwise the ``mu / sigma^2`` fallback. ``None`` when unsettleable."""
|
|
72
|
+
explain = record.get("score_explainability")
|
|
73
|
+
metrics = explain.get("metrics") if isinstance(explain, Mapping) else None
|
|
74
|
+
if isinstance(metrics, Mapping) and metrics.get("kelly_gate") in {
|
|
75
|
+
"BLOCKED",
|
|
76
|
+
"PENALTY",
|
|
77
|
+
"OK",
|
|
78
|
+
"CAP_BINDING",
|
|
79
|
+
}:
|
|
80
|
+
return str(metrics["kelly_gate"])
|
|
81
|
+
mu, sigma = _num(record.get("mu")), _num(record.get("sigma"))
|
|
82
|
+
if mu is None or sigma is None or sigma <= 0:
|
|
83
|
+
return None
|
|
84
|
+
return policy.kelly(mu, sigma).gate
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def investability(record: Mapping[str, Any], policy: Policy) -> list[str]:
|
|
88
|
+
"""Failed evidence thresholds for one EQUITY_ALPHA prediction record.
|
|
89
|
+
|
|
90
|
+
Recomputes from the record's own inputs (z-scores, mu, sigma) instead of
|
|
91
|
+
trusting stored derived fields, whose conventions drift between runs.
|
|
92
|
+
"""
|
|
93
|
+
failures: list[str] = []
|
|
94
|
+
pctl = _num(record.get("pctl", record.get("percentile")))
|
|
95
|
+
if pctl is None:
|
|
96
|
+
failures.append("percentile missing")
|
|
97
|
+
elif pctl < policy.num("evidence.min_pctl"):
|
|
98
|
+
failures.append(
|
|
99
|
+
f"percentile {pctl:.1f} < {policy.num('evidence.min_pctl'):.0f}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
explain = record.get("score_explainability")
|
|
103
|
+
family_z = _family_z(record, policy)
|
|
104
|
+
dq: float | None = None
|
|
105
|
+
if isinstance(explain, Mapping):
|
|
106
|
+
dq = _num(explain.get("data_quality_multiplier"))
|
|
107
|
+
else:
|
|
108
|
+
failures.append("score_explainability missing: families unverifiable")
|
|
109
|
+
|
|
110
|
+
shadow = set(policy.shadow_families)
|
|
111
|
+
available = [f for f in FAMILIES if family_z[f] is not None]
|
|
112
|
+
non_negative = [f for f in available if (family_z[f] or 0.0) >= 0]
|
|
113
|
+
need = int(policy.num("evidence.min_non_negative_families"))
|
|
114
|
+
if len(non_negative) < need:
|
|
115
|
+
missing = [
|
|
116
|
+
f"{f} (SHADOW)" if f in shadow else f
|
|
117
|
+
for f in FAMILIES
|
|
118
|
+
if family_z[f] is None
|
|
119
|
+
]
|
|
120
|
+
failures.append(
|
|
121
|
+
f"{len(non_negative)} of 4 families non-negative (< {need}); "
|
|
122
|
+
f"UNAVAILABLE: {', '.join(missing) or 'none'}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
weights = policy.family_weights
|
|
126
|
+
contributions = {
|
|
127
|
+
f: weights[f] * (family_z[f] or 0.0)
|
|
128
|
+
for f in available
|
|
129
|
+
if (family_z[f] or 0) > 0
|
|
130
|
+
}
|
|
131
|
+
total = sum(contributions.values())
|
|
132
|
+
if total > 0:
|
|
133
|
+
family, top = max(contributions.items(), key=lambda kv: kv[1])
|
|
134
|
+
share = top / total
|
|
135
|
+
limit = policy.num("evidence.max_family_conviction_share")
|
|
136
|
+
if share > limit:
|
|
137
|
+
failures.append(
|
|
138
|
+
f"{family} carries {share:.0%} of conviction (> {limit:.0%})"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
completeness = len(available) / len(FAMILIES)
|
|
142
|
+
if completeness < policy.num("evidence.min_data_completeness"):
|
|
143
|
+
failures.append(
|
|
144
|
+
f"family completeness {completeness:.0%} < "
|
|
145
|
+
f"{policy.num('evidence.min_data_completeness'):.0%} (proxy)"
|
|
146
|
+
)
|
|
147
|
+
if dq is not None and dq < policy.num("evidence.min_data_quality_multiplier"):
|
|
148
|
+
failures.append(f"data-quality multiplier {dq:.2f} below floor")
|
|
149
|
+
|
|
150
|
+
gate = _kelly_gate(record, policy)
|
|
151
|
+
if gate is None:
|
|
152
|
+
failures.append("mu/sigma missing: unsettleable")
|
|
153
|
+
elif gate == "BLOCKED":
|
|
154
|
+
failures.append("fractional Kelly <= 0 blocks investable status")
|
|
155
|
+
return failures
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
CONFIDENCE_ORDER = ("LOW", "MEDIUM", "HIGH")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def earnings_in_window(record: Mapping[str, Any], policy: Policy) -> bool:
|
|
162
|
+
"""``days_to_earnings`` inside ``risk.earnings_window_days`` (rules.md
|
|
163
|
+
§ Risk Controls: confidence capped ``LOW``)."""
|
|
164
|
+
days = _num(record.get("days_to_earnings"))
|
|
165
|
+
return days is not None and 0 <= days <= policy.num("risk.earnings_window_days")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def confidence_ceiling(
|
|
169
|
+
record: Mapping[str, Any], policy: Policy, *, earnings_within_window: bool = False
|
|
170
|
+
) -> tuple[str, list[str]]:
|
|
171
|
+
"""``rules.md`` § Confidence Labels: the highest label the evidence allows.
|
|
172
|
+
|
|
173
|
+
Returns the ceiling and the reasons it is not ``HIGH``. The model may
|
|
174
|
+
publish a lower label, never a higher one.
|
|
175
|
+
"""
|
|
176
|
+
reasons: list[str] = []
|
|
177
|
+
family_z = _family_z(record, policy)
|
|
178
|
+
supportive = sum(1 for z in family_z.values() if z is not None and z >= 0)
|
|
179
|
+
pctl = _num(record.get("pctl", record.get("percentile"))) or 0.0
|
|
180
|
+
explain = record.get("score_explainability")
|
|
181
|
+
dq = (
|
|
182
|
+
_num(explain.get("data_quality_multiplier"))
|
|
183
|
+
if isinstance(explain, Mapping)
|
|
184
|
+
else None
|
|
185
|
+
) or 0.0
|
|
186
|
+
|
|
187
|
+
def meets(level: str) -> bool:
|
|
188
|
+
return (
|
|
189
|
+
supportive >= policy.num(f"confidence.{level}_min_families")
|
|
190
|
+
and pctl >= policy.num(f"confidence.{level}_min_pctl")
|
|
191
|
+
and dq >= policy.num(f"confidence.{level}_min_data_quality")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
ceiling = "HIGH" if meets("high") else "MEDIUM" if meets("medium") else "LOW"
|
|
195
|
+
if ceiling != "HIGH":
|
|
196
|
+
reasons.append(
|
|
197
|
+
f"{supportive} of 4 families supportive, pctl {pctl:.1f}, DQ {dq:.2f}"
|
|
198
|
+
)
|
|
199
|
+
if _kelly_gate(record, policy) in {"BLOCKED", "PENALTY"}:
|
|
200
|
+
reasons.append("fractional Kelly below the minimum caps MEDIUM")
|
|
201
|
+
ceiling = min(ceiling, "MEDIUM", key=CONFIDENCE_ORDER.index)
|
|
202
|
+
if earnings_within_window:
|
|
203
|
+
reasons.append("earnings inside the window caps LOW")
|
|
204
|
+
ceiling = "LOW"
|
|
205
|
+
return ceiling, reasons
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class Reachability:
|
|
210
|
+
"""Whether any name could pass the evidence thresholds with the families
|
|
211
|
+
that can score, before a single record is computed (plan §3.5 step 2)."""
|
|
212
|
+
|
|
213
|
+
gating_families: tuple[str, ...]
|
|
214
|
+
blockers: tuple[str, ...]
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def reachable(self) -> bool:
|
|
218
|
+
return not self.blockers
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def go_reachability(
|
|
222
|
+
policy: Policy,
|
|
223
|
+
*,
|
|
224
|
+
available_families: Iterable[str] | None = None,
|
|
225
|
+
ranked_names: int | None = None,
|
|
226
|
+
) -> Reachability:
|
|
227
|
+
"""Structural blockers of ``GO``: thresholds no record can pass.
|
|
228
|
+
|
|
229
|
+
``available_families`` defaults to every family the policy lets score
|
|
230
|
+
(SHADOW families never do); pass the families a run actually populated to
|
|
231
|
+
check a package. Thresholds that depend on a record's values (percentile,
|
|
232
|
+
Kelly) are never structural and are not checked here.
|
|
233
|
+
"""
|
|
234
|
+
wired = set(FAMILIES if available_families is None else available_families)
|
|
235
|
+
gating = tuple(f for f in policy.gating_families if f in wired)
|
|
236
|
+
excluded = [
|
|
237
|
+
f"{f} (SHADOW)" if f in policy.shadow_families else f"{f} (UNAVAILABLE)"
|
|
238
|
+
for f in FAMILIES
|
|
239
|
+
if f not in gating
|
|
240
|
+
]
|
|
241
|
+
k, blockers = len(gating), []
|
|
242
|
+
need = int(policy.num("evidence.min_non_negative_families"))
|
|
243
|
+
if k < need:
|
|
244
|
+
blockers.append(
|
|
245
|
+
f"threshold 2: needs {need} non-negative families but only {k} can "
|
|
246
|
+
f"score ({', '.join(excluded)} excluded)"
|
|
247
|
+
)
|
|
248
|
+
limit = policy.num("evidence.max_family_conviction_share")
|
|
249
|
+
if k == 0 or 1 / k > limit + 1e-12:
|
|
250
|
+
blockers.append(
|
|
251
|
+
f"threshold 3: with {k} families one carries at least "
|
|
252
|
+
f"{1 / max(k, 1):.0%} of conviction (> {limit:.0%})"
|
|
253
|
+
)
|
|
254
|
+
elif abs(1 / k - limit) <= 1e-12:
|
|
255
|
+
blockers.append(
|
|
256
|
+
f"threshold 3: with {k} families the largest share is at least "
|
|
257
|
+
f"{limit:.0%}, so only an exact tie passes"
|
|
258
|
+
)
|
|
259
|
+
completeness = policy.num("evidence.min_data_completeness")
|
|
260
|
+
if k / len(FAMILIES) < completeness:
|
|
261
|
+
blockers.append(
|
|
262
|
+
f"threshold 4: family completeness at most {k}/{len(FAMILIES)} = "
|
|
263
|
+
f"{k / len(FAMILIES):.0%} < {completeness:.0%} "
|
|
264
|
+
"(family-availability proxy, plan §6 decision 5)"
|
|
265
|
+
)
|
|
266
|
+
minimum = int(policy.num("evidence.min_investable_names"))
|
|
267
|
+
if ranked_names is not None and ranked_names < minimum:
|
|
268
|
+
blockers.append(f"{ranked_names} ranked names < {minimum} required for GO")
|
|
269
|
+
return Reachability(gating, tuple(blockers))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class StatusDecision:
|
|
274
|
+
status: RunStatus
|
|
275
|
+
reasons: tuple[str, ...]
|
|
276
|
+
gross_cap: float | None = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def decide_status(
|
|
280
|
+
*,
|
|
281
|
+
data_mode: str,
|
|
282
|
+
required_inputs: Mapping[str, bool],
|
|
283
|
+
investable_count: int,
|
|
284
|
+
policy: Policy,
|
|
285
|
+
benchmark_available: bool = True,
|
|
286
|
+
integrity_failures: Sequence[str] = (),
|
|
287
|
+
risk_breaches: Sequence[str] = (),
|
|
288
|
+
enhancing_missing: Sequence[str] = (),
|
|
289
|
+
session: Session | None = None,
|
|
290
|
+
) -> StatusDecision:
|
|
291
|
+
"""``rules.md`` § Stop Criteria + § Input Classification as one function.
|
|
292
|
+
|
|
293
|
+
With a ``session``, a non-trading day takes the policy's calendar status
|
|
294
|
+
(integrity halts still win); a ``COMPUTED`` rule keeps the computed status
|
|
295
|
+
and says that no rule applied.
|
|
296
|
+
"""
|
|
297
|
+
if integrity_failures:
|
|
298
|
+
return StatusDecision(RunStatus.HALTED, tuple(integrity_failures))
|
|
299
|
+
if not benchmark_available and data_mode != "ILLUSTRATIVE":
|
|
300
|
+
return StatusDecision(
|
|
301
|
+
RunStatus.HALTED, ("benchmark data missing outside ILLUSTRATIVE_MODE",)
|
|
302
|
+
)
|
|
303
|
+
unknown = sorted(set(required_inputs) - set(REQUIRED_INPUTS))
|
|
304
|
+
if unknown:
|
|
305
|
+
raise ValueError(f"not Required inputs (Enhancing never blocks GO): {unknown}")
|
|
306
|
+
note: tuple[str, ...] = ()
|
|
307
|
+
if session is not None and not session.trading_day:
|
|
308
|
+
key = (
|
|
309
|
+
"calendar.holiday_status"
|
|
310
|
+
if session.kind == HOLIDAY
|
|
311
|
+
else "calendar.weekend_status"
|
|
312
|
+
)
|
|
313
|
+
rule = str(policy.get(key))
|
|
314
|
+
if rule != "COMPUTED":
|
|
315
|
+
return StatusDecision(RunStatus(rule), (f"{session.describe()} ({key})",))
|
|
316
|
+
note = (f"{session.describe()}: no status rule ({key} = COMPUTED)",)
|
|
317
|
+
decision = _data_status(
|
|
318
|
+
data_mode=data_mode,
|
|
319
|
+
required_inputs=required_inputs,
|
|
320
|
+
investable_count=investable_count,
|
|
321
|
+
policy=policy,
|
|
322
|
+
risk_breaches=risk_breaches,
|
|
323
|
+
enhancing_missing=enhancing_missing,
|
|
324
|
+
)
|
|
325
|
+
return replace(decision, reasons=decision.reasons + note) if note else decision
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _data_status(
|
|
329
|
+
*,
|
|
330
|
+
data_mode: str,
|
|
331
|
+
required_inputs: Mapping[str, bool],
|
|
332
|
+
investable_count: int,
|
|
333
|
+
policy: Policy,
|
|
334
|
+
risk_breaches: Sequence[str],
|
|
335
|
+
enhancing_missing: Sequence[str],
|
|
336
|
+
) -> StatusDecision:
|
|
337
|
+
if data_mode == "ILLUSTRATIVE":
|
|
338
|
+
return StatusDecision(
|
|
339
|
+
RunStatus.REVIEW_ONLY, ("ILLUSTRATIVE_MODE publishes REVIEW_ONLY",)
|
|
340
|
+
)
|
|
341
|
+
failed = [name for name in REQUIRED_INPUTS if not required_inputs.get(name, False)]
|
|
342
|
+
if failed or data_mode == "DELAYED_PARTIAL":
|
|
343
|
+
return StatusDecision(
|
|
344
|
+
RunStatus.REVIEW_ONLY,
|
|
345
|
+
tuple(f"Required input not grounded: {name}" for name in failed)
|
|
346
|
+
or ("DELAYED_PARTIAL data mode",),
|
|
347
|
+
)
|
|
348
|
+
minimum = int(policy.num("evidence.min_investable_names"))
|
|
349
|
+
if investable_count < minimum:
|
|
350
|
+
return StatusDecision(
|
|
351
|
+
RunStatus.NO_TRADE,
|
|
352
|
+
(f"{investable_count} names pass the investable threshold (< {minimum})",),
|
|
353
|
+
)
|
|
354
|
+
if risk_breaches:
|
|
355
|
+
return StatusDecision(RunStatus.NO_TRADE, tuple(risk_breaches))
|
|
356
|
+
cap = policy.num("risk.enhancing_missing_gross_cap") if enhancing_missing else None
|
|
357
|
+
reasons = tuple(
|
|
358
|
+
f"Enhancing input missing (cap, not blocker): {e}" for e in enhancing_missing
|
|
359
|
+
)
|
|
360
|
+
return StatusDecision(RunStatus.GO, reasons, gross_cap=cap)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def publish_gate(package_dir: Path, policy: Policy) -> list[str]:
|
|
364
|
+
"""Checklist computed from the directory, never written before the files.
|
|
365
|
+
|
|
366
|
+
(Three 2026-08 packages claimed artifacts in their manifest that were
|
|
367
|
+
never published; see ``00_run_manifest.md`` of claude-opus-5-2026-09-03.)
|
|
368
|
+
Every prediction record must also pass the error-severity audit checks.
|
|
369
|
+
"""
|
|
370
|
+
failures: list[str] = []
|
|
371
|
+
present = {p.name for p in package_dir.iterdir() if p.is_file()}
|
|
372
|
+
for name in ALWAYS_ARTIFACTS:
|
|
373
|
+
if name not in present:
|
|
374
|
+
failures.append(f"missing required artifact {name}")
|
|
375
|
+
contract = set(ALWAYS_ARTIFACTS) | set(CHECKPOINT_ARTIFACTS) | {PREDICTIONS_FILE}
|
|
376
|
+
for name in sorted(present - contract):
|
|
377
|
+
failures.append(f"file outside the artifact contract: {name}")
|
|
378
|
+
predictions_path = package_dir / PREDICTIONS_FILE
|
|
379
|
+
if not predictions_path.exists():
|
|
380
|
+
# rules.md: a run that ranks names is not publishable without it, and
|
|
381
|
+
# every run forecasts the core ETFs, so absence always fails.
|
|
382
|
+
failures.append(f"missing {PREDICTIONS_FILE} (publishing gate)")
|
|
383
|
+
return failures
|
|
384
|
+
try:
|
|
385
|
+
payload = json.loads(predictions_path.read_text(encoding="utf-8"))
|
|
386
|
+
except json.JSONDecodeError as exc:
|
|
387
|
+
return failures + [f"{PREDICTIONS_FILE} is not valid JSON: {exc}"]
|
|
388
|
+
predictions = payload.get("predictions") if isinstance(payload, dict) else None
|
|
389
|
+
if not isinstance(predictions, list):
|
|
390
|
+
return failures + [f"{PREDICTIONS_FILE} has no 'predictions' list"]
|
|
391
|
+
if "schema_version" in payload:
|
|
392
|
+
# A ledger that declares a schema is held to all of it.
|
|
393
|
+
from financial_agent.harness.schema import validate_payload
|
|
394
|
+
|
|
395
|
+
failures += [f"schema v1: {f}" for f in validate_payload(payload, policy)]
|
|
396
|
+
if any(p.get("type", "EQUITY_ALPHA") == "EQUITY_ALPHA" for p in predictions):
|
|
397
|
+
forecast = {
|
|
398
|
+
p.get("ticker") for p in predictions if p.get("type") == "MARKET_FORECAST"
|
|
399
|
+
}
|
|
400
|
+
for etf in policy.get("market_forecast.tickers"):
|
|
401
|
+
if etf not in forecast:
|
|
402
|
+
failures.append(f"ranked names but no MARKET_FORECAST record for {etf}")
|
|
403
|
+
for record in predictions:
|
|
404
|
+
for finding in audit_record(record, policy, package=package_dir.name).findings:
|
|
405
|
+
if finding.severity == "error":
|
|
406
|
+
failures.append(f"{finding.ticker}: {finding.check}: {finding.detail}")
|
|
407
|
+
return failures
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class StatusReplay:
|
|
412
|
+
investable: list[str]
|
|
413
|
+
decision: StatusDecision
|
|
414
|
+
published: str | None
|
|
415
|
+
session: Session | None = None
|
|
416
|
+
reachability: Reachability | None = None
|
|
417
|
+
blocking: dict[str, int] = field(default_factory=dict)
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def agrees(self) -> bool:
|
|
421
|
+
return self.decision.status.value == self.published
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _run_date(payload: Mapping[str, Any], package_dir: Path) -> str | None:
|
|
425
|
+
for candidate in (payload.get("run_date"), package_dir.name[-10:]):
|
|
426
|
+
try:
|
|
427
|
+
return dt.date.fromisoformat(str(candidate)).isoformat()
|
|
428
|
+
except ValueError:
|
|
429
|
+
continue
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _threshold(failure: str) -> str:
|
|
434
|
+
"""The evidence threshold an ``investability`` failure belongs to."""
|
|
435
|
+
for prefix, label in (
|
|
436
|
+
("percentile", "#1 percentile"),
|
|
437
|
+
("score_explainability", "#2 families"),
|
|
438
|
+
("mu/sigma", "Kelly or mu/sigma"),
|
|
439
|
+
("fractional Kelly", "Kelly or mu/sigma"),
|
|
440
|
+
("data-quality", "#4 data quality"),
|
|
441
|
+
("family completeness", "#4 data quality"),
|
|
442
|
+
):
|
|
443
|
+
if failure.startswith(prefix):
|
|
444
|
+
return label
|
|
445
|
+
if "families non-negative" in failure:
|
|
446
|
+
return "#2 families"
|
|
447
|
+
if "of conviction" in failure:
|
|
448
|
+
return "#3 conviction share"
|
|
449
|
+
return failure
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def replay_status(package_dir: Path, policy: Policy) -> StatusReplay | None:
|
|
453
|
+
"""Recompute the run status from the ledger; ``None`` without a ledger.
|
|
454
|
+
|
|
455
|
+
Assumes every Required input was grounded: the ledger cannot show otherwise.
|
|
456
|
+
The run date's session applies the calendar rule, and ``blocking`` counts
|
|
457
|
+
the names each evidence threshold rejects.
|
|
458
|
+
"""
|
|
459
|
+
path = package_dir / PREDICTIONS_FILE
|
|
460
|
+
if not path.exists():
|
|
461
|
+
return None
|
|
462
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
463
|
+
equities = [
|
|
464
|
+
p
|
|
465
|
+
for p in payload.get("predictions", [])
|
|
466
|
+
if p.get("type", "EQUITY_ALPHA") == "EQUITY_ALPHA"
|
|
467
|
+
]
|
|
468
|
+
passing: list[str] = []
|
|
469
|
+
blocking: dict[str, int] = {}
|
|
470
|
+
for record in equities:
|
|
471
|
+
failures = investability(record, policy)
|
|
472
|
+
if not failures:
|
|
473
|
+
passing.append(record["ticker"])
|
|
474
|
+
for label in sorted({_threshold(f) for f in failures}):
|
|
475
|
+
blocking[label] = blocking.get(label, 0) + 1
|
|
476
|
+
run_date = _run_date(payload, package_dir)
|
|
477
|
+
day = session(run_date) if run_date else None
|
|
478
|
+
decision = decide_status(
|
|
479
|
+
data_mode=str(payload.get("data_mode") or "DELAYED"),
|
|
480
|
+
required_inputs={name: True for name in REQUIRED_INPUTS},
|
|
481
|
+
investable_count=len(passing),
|
|
482
|
+
policy=policy,
|
|
483
|
+
session=day,
|
|
484
|
+
)
|
|
485
|
+
published = (
|
|
486
|
+
payload.get("run_status")
|
|
487
|
+
or payload.get("final_status")
|
|
488
|
+
or payload.get("status")
|
|
489
|
+
)
|
|
490
|
+
populated = {
|
|
491
|
+
family
|
|
492
|
+
for record in equities
|
|
493
|
+
for family, z in _family_z(record, policy).items()
|
|
494
|
+
if z is not None
|
|
495
|
+
}
|
|
496
|
+
reach = go_reachability(
|
|
497
|
+
policy, available_families=populated, ranked_names=len(equities)
|
|
498
|
+
)
|
|
499
|
+
return StatusReplay(passing, decision, published, day, reach, blocking)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def replay_history(
|
|
503
|
+
output_dir: Path,
|
|
504
|
+
policy: Policy,
|
|
505
|
+
*,
|
|
506
|
+
since: str | None = None,
|
|
507
|
+
as_of: str | None = None,
|
|
508
|
+
) -> list[tuple[str, StatusReplay]]:
|
|
509
|
+
"""``replay_status`` for every dated package with a ledger, oldest first."""
|
|
510
|
+
replays: list[tuple[str, StatusReplay]] = []
|
|
511
|
+
for package_dir in sorted(p for p in output_dir.iterdir() if p.is_dir()):
|
|
512
|
+
run_date = package_dir.name[-10:]
|
|
513
|
+
try:
|
|
514
|
+
dt.date.fromisoformat(run_date)
|
|
515
|
+
except ValueError:
|
|
516
|
+
continue
|
|
517
|
+
if (since and run_date < since) or (as_of and run_date > as_of):
|
|
518
|
+
continue
|
|
519
|
+
replay = replay_status(package_dir, policy)
|
|
520
|
+
if replay is not None:
|
|
521
|
+
replays.append((package_dir.name, replay))
|
|
522
|
+
return replays
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Stage handlers shipped with the harness.
|
|
2
|
+
|
|
3
|
+
Each handler is deterministic and read-only on prior packages. Stages that need
|
|
4
|
+
data adapters or a model are not registered, so ``run`` still halts before them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from zoneinfo import ZoneInfo
|
|
13
|
+
|
|
14
|
+
from financial_agent.harness.gates import go_reachability
|
|
15
|
+
from financial_agent.harness.helpers import load_helper
|
|
16
|
+
from financial_agent.harness.market_calendar import session
|
|
17
|
+
from financial_agent.harness.policy import load_policy
|
|
18
|
+
from financial_agent.harness.runner import RunContext, StageResult
|
|
19
|
+
|
|
20
|
+
MARKET_TZ = ZoneInfo("America/New_York")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def precheck_handler(context: RunContext) -> StageResult:
|
|
24
|
+
"""PRECHECK: the run date's session, and whether GO is reachable at all.
|
|
25
|
+
|
|
26
|
+
Plan §3.5 step 2: if no name can pass the evidence thresholds with the
|
|
27
|
+
families that can score, say so on day 1 rather than in run 13. An
|
|
28
|
+
unreachable GO does not halt; the run still publishes NO_TRADE.
|
|
29
|
+
"""
|
|
30
|
+
today = dt.datetime.now(MARKET_TZ).date()
|
|
31
|
+
if dt.date.fromisoformat(context.date) > today:
|
|
32
|
+
return StageResult(halt=f"run date {context.date} is after {today} (ET)")
|
|
33
|
+
day = session(context.date)
|
|
34
|
+
reach = go_reachability(context.policy or load_policy())
|
|
35
|
+
note = day.describe() + (
|
|
36
|
+
"; GO reachable"
|
|
37
|
+
if reach.reachable
|
|
38
|
+
else f"; GO unreachable ({len(reach.blockers)} structural blockers)"
|
|
39
|
+
)
|
|
40
|
+
return StageResult(
|
|
41
|
+
note,
|
|
42
|
+
data={
|
|
43
|
+
"session": vars(day),
|
|
44
|
+
"go_reachable": reach.reachable,
|
|
45
|
+
"go_blockers": list(reach.blockers),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def settlement_summary(output_dir: Path, run_date: str, run_id: str) -> dict[str, Any]:
|
|
51
|
+
"""Canonical settlement state from packages dated before this run."""
|
|
52
|
+
ledger = load_helper("settlement_ledger")
|
|
53
|
+
packages = [
|
|
54
|
+
p
|
|
55
|
+
for p in ledger.load_packages(output_dir)
|
|
56
|
+
if p["_source_file"] != run_id and p.get("run_date", "") <= run_date
|
|
57
|
+
]
|
|
58
|
+
manifest: dict[str, Any] = ledger.build_manifest(packages, as_of=run_date)
|
|
59
|
+
return manifest
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def reflection_handler(context: RunContext) -> StageResult:
|
|
63
|
+
"""REFLECTION: settle what is due from the immutable ledgers, report it."""
|
|
64
|
+
manifest = settlement_summary(
|
|
65
|
+
context.package_dir.parent, context.date, context.run_id
|
|
66
|
+
)
|
|
67
|
+
summary = manifest["summary"]
|
|
68
|
+
metrics = manifest["rolling_metrics"]["equity_alpha"]
|
|
69
|
+
note = (
|
|
70
|
+
f"{summary['canonical_equity_alpha_settlements']} canonical EQUITY_ALPHA "
|
|
71
|
+
f"settlements (eff_n {metrics['eff_n']}), "
|
|
72
|
+
f"{summary['canonical_market_forecast_settlements']} MARKET_FORECAST, "
|
|
73
|
+
f"{summary['due_inventory']} due, {summary['conflicts']} conflicts"
|
|
74
|
+
)
|
|
75
|
+
if summary["conflicts"]:
|
|
76
|
+
return StageResult(halt=f"settlement conflicts need reconciliation: {note}")
|
|
77
|
+
return StageResult(note)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Load the daily system's helper scripts by path, so each rule has one home.
|
|
2
|
+
|
|
3
|
+
``agents/equity/daily_investment_system/`` holds tested, standard-library
|
|
4
|
+
scripts (the canonical settlement ledger, its NYSE calendar). They are not a
|
|
5
|
+
package; builds include them as resources without duplicating their source.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import sys
|
|
12
|
+
from contextlib import ExitStack
|
|
13
|
+
from importlib.resources import as_file, files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
|
|
17
|
+
SYSTEM_DIR = (
|
|
18
|
+
Path(__file__).resolve().parents[3]
|
|
19
|
+
/ "agents"
|
|
20
|
+
/ "equity"
|
|
21
|
+
/ "daily_investment_system"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_helper(name: str) -> ModuleType:
|
|
26
|
+
"""Load once per process, preferring checkout scripts over wheel resources."""
|
|
27
|
+
if name in sys.modules:
|
|
28
|
+
return sys.modules[name]
|
|
29
|
+
path = SYSTEM_DIR / f"{name}.py"
|
|
30
|
+
with ExitStack() as stack:
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
resource = files("financial_agent.harness").joinpath(
|
|
33
|
+
"_system", f"{name}.py"
|
|
34
|
+
)
|
|
35
|
+
if not resource.is_file():
|
|
36
|
+
raise FileNotFoundError(
|
|
37
|
+
f"cannot load helper {name!r}: neither {path} nor packaged "
|
|
38
|
+
f"resource {resource} exists"
|
|
39
|
+
)
|
|
40
|
+
path = stack.enter_context(as_file(resource))
|
|
41
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
42
|
+
if spec is None or spec.loader is None:
|
|
43
|
+
raise RuntimeError(f"cannot load {name} from {path}")
|
|
44
|
+
module = importlib.util.module_from_spec(spec)
|
|
45
|
+
sys.modules[name] = module
|
|
46
|
+
spec.loader.exec_module(module)
|
|
47
|
+
return module
|