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