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,696 @@
1
+ """Kernels: the per-run engine as tested functions (plan Phase 2).
2
+
3
+ Every run used to re-implement risk analytics, the forecast, and Kelly from
4
+ ``rules.md`` prose. That is how Sortino came out equal to Sharpe for weeks and
5
+ a drawdown sign flipped on a rebuild. Here they are pure functions of their
6
+ inputs, defined once:
7
+
8
+ * ``price_risk`` -- beta, realized and downside vol, tracking error, and 60d
9
+ drawdown from adjusted closes, per the normative Metric Definition Table
10
+ (``claude-opus-5-2026-09-03/05_factor_scores.md``) and ``rules.md``
11
+ § Computed Risk Analytics;
12
+ * ``forecast_ratios`` -- Sharpe, Sortino, Information Ratio, Treynor, Calmar
13
+ (``rules.md`` § Ratio Definitions);
14
+ * ``equity_record`` / ``market_forecast_records`` -- complete schema-v1 ledger
15
+ records built from data plus the model's judgment inputs;
16
+ * ``portfolio_feasibility`` -- the portfolio-level risk controls (protected
17
+ rules 3-7) and the event-risk stop, from proposed weights and closes
18
+ (``rules.md`` § Computed Risk Analytics, § Risk Controls / Portfolio Level).
19
+
20
+ The model supplies judgment (percentile inputs, a bounded mu adjustment with a
21
+ reason, confidence, thesis); the kernel computes every number and refuses a
22
+ judgment outside the policy's bounds instead of publishing it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import datetime as dt
29
+ import math
30
+ import statistics
31
+ from dataclasses import dataclass, field
32
+ from itertools import combinations
33
+ from typing import Any, Mapping, Sequence
34
+
35
+ from financial_agent.harness.gates import (
36
+ CONFIDENCE_ORDER,
37
+ confidence_ceiling,
38
+ earnings_in_window,
39
+ investability,
40
+ )
41
+ from financial_agent.harness.policy import FAMILIES, Policy
42
+ from financial_agent.harness.schema import (
43
+ CONFIDENCE,
44
+ DATA_MODES,
45
+ FORMULA,
46
+ PRICE_TAGS,
47
+ SCHEMA_VERSION,
48
+ SIGMA_SOURCES,
49
+ )
50
+
51
+ RISK_INTERVALS = 60 # daily return intervals (61 closes): § Computed Risk Analytics
52
+ VOL_INTERVALS = 30 # REALIZED_VOL_30D: § Sigma Fallback Chain
53
+ MONTH = math.sqrt(21) # daily -> one-month scaling
54
+ PRICE_DP = 4
55
+ DECIMAL_DP = 6
56
+
57
+
58
+ class KernelError(ValueError):
59
+ """An input the policy does not allow; the record is not built."""
60
+
61
+
62
+ def daily_returns(closes: Sequence[float]) -> list[float]:
63
+ return [b / a - 1 for a, b in zip(closes, closes[1:])]
64
+
65
+
66
+ def _beta(returns: Sequence[float], benchmark: Sequence[float]) -> float:
67
+ """``cov(r, r_b, ddof=0) / var(r_b)``."""
68
+ mean_r, mean_b = statistics.fmean(returns), statistics.fmean(benchmark)
69
+ var_b = statistics.fmean([(b - mean_b) ** 2 for b in benchmark])
70
+ if var_b == 0:
71
+ raise KernelError("benchmark returns have zero variance: beta undefined")
72
+ cov = statistics.fmean(
73
+ [(r - mean_r) * (b - mean_b) for r, b in zip(returns, benchmark)]
74
+ )
75
+ return cov / var_b
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class PriceRisk:
80
+ """Price-history analytics for one name, all decimals, one-month scale."""
81
+
82
+ beta_60d_vs_spy: float
83
+ realized_vol_30d: float
84
+ prior_vol_30d: float # the 30 intervals before the latest 30
85
+ vol_60d: float
86
+ downside_vol_30d: float | None # None with fewer than two down days
87
+ tracking_error_1m: float
88
+ max_drawdown_60d: float # signed, <= 0
89
+ beta_60d_vs_tlt: float | None = None
90
+
91
+
92
+ def _window(name: str, closes: Sequence[float]) -> list[float]:
93
+ need = RISK_INTERVALS + 1
94
+ if len(closes) < need:
95
+ raise KernelError(f"{name}: {len(closes)} closes, need {need}")
96
+ window = [float(c) for c in closes[-need:]]
97
+ if any(not math.isfinite(c) or c <= 0 for c in window):
98
+ raise KernelError(f"{name}: closes must be positive and finite")
99
+ return window
100
+
101
+
102
+ def price_risk(
103
+ closes: Sequence[float],
104
+ spy_closes: Sequence[float],
105
+ *,
106
+ tlt_closes: Sequence[float] | None = None,
107
+ ) -> PriceRisk:
108
+ """Risk analytics from date-aligned adjusted closes (oldest first).
109
+
110
+ Uses the latest 61 closes (60 return intervals); population stdevs
111
+ (``ddof=0``) scaled by ``sqrt(21)``. Downside vol is the stdev of the
112
+ negative daily returns among the latest 30.
113
+ """
114
+ window = _window("closes", closes)
115
+ returns = daily_returns(window)
116
+ spy = daily_returns(_window("spy_closes", spy_closes))
117
+ beta = _beta(returns, spy)
118
+ latest = returns[-VOL_INTERVALS:]
119
+ down = [r for r in latest if r < 0]
120
+ peak, drawdown = window[0], 0.0
121
+ for close in window:
122
+ peak = max(peak, close)
123
+ drawdown = min(drawdown, close / peak - 1)
124
+ tlt_beta = None
125
+ if tlt_closes is not None:
126
+ tlt_beta = _beta(returns, daily_returns(_window("tlt_closes", tlt_closes)))
127
+ return PriceRisk(
128
+ beta_60d_vs_spy=beta,
129
+ realized_vol_30d=statistics.pstdev(latest) * MONTH,
130
+ prior_vol_30d=statistics.pstdev(returns[:-VOL_INTERVALS]) * MONTH,
131
+ vol_60d=statistics.pstdev(returns) * MONTH,
132
+ downside_vol_30d=statistics.pstdev(down) * MONTH if len(down) >= 2 else None,
133
+ tracking_error_1m=statistics.pstdev(
134
+ [r - beta * s for r, s in zip(returns, spy)]
135
+ )
136
+ * MONTH,
137
+ max_drawdown_60d=drawdown,
138
+ beta_60d_vs_tlt=tlt_beta,
139
+ )
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class Ratios:
144
+ sharpe: float
145
+ sortino: float | None
146
+ information_ratio: float | None
147
+ treynor: float | None
148
+ calmar: float | None
149
+ basis: str # EXCESS_RETURN with a sourced rf_1m, else RAW_DIAGNOSTIC
150
+
151
+
152
+ def _div(numerator: float, denominator: float | None) -> float | None:
153
+ if denominator is None or denominator == 0:
154
+ return None
155
+ return numerator / denominator
156
+
157
+
158
+ def forecast_ratios(
159
+ mu: float,
160
+ sigma: float,
161
+ *,
162
+ rf_1m: float | None = None,
163
+ downside_vol: float | None = None,
164
+ beta: float | None = None,
165
+ tracking_error: float | None = None,
166
+ max_drawdown: float | None = None,
167
+ spy_mu: float | None = None,
168
+ ) -> Ratios:
169
+ """``rules.md`` § Ratio Definitions; an unavailable input gives ``None``.
170
+
171
+ Without a sourced one-month risk-free rate the ratios use raw ``mu`` and are
172
+ labelled ``RAW_DIAGNOSTIC`` (§ Metric History and Grounding).
173
+ """
174
+ excess = mu - (rf_1m or 0.0)
175
+ residual = None if beta is None or spy_mu is None else mu - beta * spy_mu
176
+ return Ratios(
177
+ sharpe=excess / sigma,
178
+ sortino=_div(excess, downside_vol),
179
+ information_ratio=(
180
+ None if residual is None else _div(residual, tracking_error)
181
+ ),
182
+ treynor=_div(excess, beta),
183
+ calmar=_div(excess, None if max_drawdown is None else abs(max_drawdown)),
184
+ basis="RAW_DIAGNOSTIC" if rf_1m is None else "EXCESS_RETURN",
185
+ )
186
+
187
+
188
+ def _round(value: float | None, digits: int = DECIMAL_DP) -> float | None:
189
+ return None if value is None else round(value, digits)
190
+
191
+
192
+ def _require(name: str, value: Any, allowed: Sequence[str]) -> None:
193
+ if value not in allowed:
194
+ raise KernelError(f"{name}={value!r} not in {list(allowed)}")
195
+
196
+
197
+ def _target_date(run_date: str, horizon: int | None, policy: Policy) -> str:
198
+ days = horizon or int(policy.num("forecast.default_horizon_days"))
199
+ allowed = list(policy.get("forecast.allowed_horizon_days"))
200
+ if days not in allowed:
201
+ raise KernelError(f"horizon {days}d not in {allowed}")
202
+ return (dt.date.fromisoformat(run_date) + dt.timedelta(days=days)).isoformat()
203
+
204
+
205
+ def _adjust(prior: float, adjustment: float, reason: str, limit: float) -> float:
206
+ if abs(adjustment) > limit + 1e-12:
207
+ raise KernelError(f"mu adjustment {adjustment:+.4f} exceeds ±{limit}")
208
+ if adjustment and not reason.strip():
209
+ raise KernelError("a mu adjustment needs a stated, ledger-backed reason")
210
+ return prior + adjustment
211
+
212
+
213
+ def _price_fields(
214
+ entry: float, mu: float, sigma: float, policy: Policy
215
+ ) -> dict[str, Any]:
216
+ if not entry > 0 or not 0 < sigma < 1:
217
+ raise KernelError(f"entry_price {entry} / sigma {sigma} out of range")
218
+ lo, hi = policy.ci70(entry, mu, sigma)
219
+ return {
220
+ "target_price": round(policy.target_price(entry, mu), PRICE_DP),
221
+ "ci70_lo": round(lo, PRICE_DP),
222
+ "ci70_hi": round(hi, PRICE_DP),
223
+ }
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class EquityInputs:
228
+ """One ranked name: grounded data plus the model's judgment calls."""
229
+
230
+ ticker: str
231
+ entry_price: float # raw close from a ledger row
232
+ price_tag: str
233
+ price_date: str
234
+ benchmark_price: float # SPY close at price_date
235
+ sigma: float # decimal, one month
236
+ sigma_source: str
237
+ pctl: float # adjusted-score percentile in the eligible universe
238
+ family_z: Mapping[str, float | None]
239
+ data_quality_multiplier: float
240
+ confidence: str
241
+ thesis: str
242
+ penalties: float = 0.0
243
+ mu_adjustment: float = 0.0
244
+ mu_adjustment_reason: str = ""
245
+ horizon_days: int | None = None
246
+ kelly_raw: float | None = None # beta-adjusted edge / TE variance, if computed
247
+ earnings_within_window: bool = False
248
+ rf_1m: float | None = None
249
+ spy_mu: float | None = None
250
+ risk: PriceRisk | None = None
251
+ ledger_rows: tuple[str, ...] = ()
252
+ extra: Mapping[str, Any] = field(default_factory=dict)
253
+
254
+ @classmethod
255
+ def from_mapping(cls, data: Mapping[str, Any]) -> "EquityInputs":
256
+ values = dict(data)
257
+ known = {f.name for f in dataclasses.fields(cls)}
258
+ unknown = sorted(set(values) - known)
259
+ if unknown:
260
+ raise KernelError(f"unknown equity input(s) {unknown}; use 'extra'")
261
+ if isinstance(values.get("risk"), Mapping):
262
+ values["risk"] = PriceRisk(**values["risk"])
263
+ values["ledger_rows"] = tuple(values.get("ledger_rows", ()))
264
+ try:
265
+ return cls(**values)
266
+ except TypeError as exc:
267
+ raise KernelError(str(exc)) from None
268
+
269
+
270
+ def equity_record(
271
+ run_date: str, model: str, inputs: EquityInputs, policy: Policy
272
+ ) -> dict[str, Any]:
273
+ """A schema-v1 ``EQUITY_ALPHA`` record with every number computed."""
274
+ _require("price_tag", inputs.price_tag, PRICE_TAGS)
275
+ _require("sigma_source", inputs.sigma_source, SIGMA_SOURCES)
276
+ _require("confidence", inputs.confidence, CONFIDENCE)
277
+ unknown = sorted(set(inputs.family_z) - set(FAMILIES))
278
+ if unknown:
279
+ raise KernelError(f"unknown families {unknown}")
280
+ for family in policy.shadow_families:
281
+ if inputs.family_z.get(family) is not None:
282
+ raise KernelError(
283
+ f"{family} is SHADOW: it may not enter Adj Score until promoted "
284
+ "(rules.md § SHADOW Diagnostic Tooling)"
285
+ )
286
+ band = policy.mu_prior(inputs.pctl)
287
+ if band is None:
288
+ raise KernelError(f"pctl {inputs.pctl} is below every mu band: do not rank")
289
+ mu = _adjust(
290
+ band.mu,
291
+ inputs.mu_adjustment,
292
+ inputs.mu_adjustment_reason,
293
+ policy.num("forecast.mu_adjustment_max"),
294
+ )
295
+ family_z = {f: inputs.family_z.get(f) for f in FAMILIES}
296
+ composite = policy.composite_z(family_z)
297
+ dq = inputs.data_quality_multiplier
298
+ raw = inputs.kelly_raw if inputs.kelly_raw is not None else mu / inputs.sigma**2
299
+ sizing = policy.kelly_sizing(raw)
300
+ metrics: dict[str, Any] = {
301
+ "kelly_raw": _round(raw),
302
+ "kelly_method": (
303
+ "MU_OVER_SIGMA2" if inputs.kelly_raw is None else "BETA_ADJ_TE"
304
+ ),
305
+ "kelly_fractional": _round(sizing.fractional),
306
+ "position_weight": _round(sizing.weight),
307
+ "kelly_gate": sizing.gate,
308
+ "var95": _round(policy.var95(mu, inputs.sigma)),
309
+ "cvar95": _round(policy.cvar95(mu, inputs.sigma)),
310
+ }
311
+ risk = inputs.risk
312
+ if risk is not None:
313
+ metrics.update(
314
+ {
315
+ name: _round(getattr(risk, name))
316
+ for name in (
317
+ "beta_60d_vs_spy",
318
+ "realized_vol_30d",
319
+ "downside_vol_30d",
320
+ "tracking_error_1m",
321
+ "max_drawdown_60d",
322
+ )
323
+ }
324
+ )
325
+ ratios = forecast_ratios(
326
+ mu,
327
+ inputs.sigma,
328
+ rf_1m=inputs.rf_1m,
329
+ downside_vol=risk.downside_vol_30d if risk else None,
330
+ beta=risk.beta_60d_vs_spy if risk else None,
331
+ tracking_error=risk.tracking_error_1m if risk else None,
332
+ max_drawdown=risk.max_drawdown_60d if risk else None,
333
+ spy_mu=inputs.spy_mu,
334
+ )
335
+ metrics.update(
336
+ {
337
+ "sharpe": _round(ratios.sharpe),
338
+ "sortino": _round(ratios.sortino),
339
+ "information_ratio": _round(ratios.information_ratio),
340
+ "treynor": _round(ratios.treynor),
341
+ "calmar": _round(ratios.calmar),
342
+ "ratio_basis": ratios.basis,
343
+ }
344
+ )
345
+ record: dict[str, Any] = {
346
+ "run_date": run_date,
347
+ "model": model,
348
+ "ticker": inputs.ticker,
349
+ "type": "EQUITY_ALPHA",
350
+ "entry_price": inputs.entry_price,
351
+ "price_tag": inputs.price_tag,
352
+ "price_date": inputs.price_date,
353
+ "mu": _round(mu),
354
+ "mu_prior": band.mu,
355
+ "mu_adjustment": inputs.mu_adjustment,
356
+ "sigma": _round(inputs.sigma),
357
+ "sigma_source": inputs.sigma_source,
358
+ **_price_fields(inputs.entry_price, mu, inputs.sigma, policy),
359
+ "target_date": _target_date(run_date, inputs.horizon_days, policy),
360
+ "benchmark": "SPY",
361
+ "benchmark_price": inputs.benchmark_price,
362
+ "pctl": inputs.pctl,
363
+ "adj_score": _round(policy.adj_score(composite, dq, inputs.penalties)),
364
+ "sleeve": "MONITORING",
365
+ "confidence": inputs.confidence,
366
+ "status": "OPEN",
367
+ "thesis": inputs.thesis,
368
+ "score_explainability": {
369
+ **family_z,
370
+ "composite_z": _round(composite),
371
+ "data_quality_multiplier": dq,
372
+ "penalties": inputs.penalties,
373
+ "formula": FORMULA,
374
+ "metrics": metrics,
375
+ "ledger_rows": list(inputs.ledger_rows),
376
+ },
377
+ }
378
+ if inputs.mu_adjustment:
379
+ record["mu_adjustment_reason"] = inputs.mu_adjustment_reason
380
+ clash = sorted(set(inputs.extra) & set(record))
381
+ if clash:
382
+ raise KernelError(f"extra fields may not override computed fields {clash}")
383
+ record.update(inputs.extra)
384
+
385
+ ceiling, reasons = confidence_ceiling(
386
+ record,
387
+ policy,
388
+ earnings_within_window=inputs.earnings_within_window
389
+ or earnings_in_window(record, policy),
390
+ )
391
+ if CONFIDENCE_ORDER.index(inputs.confidence) > CONFIDENCE_ORDER.index(ceiling):
392
+ raise KernelError(
393
+ f"confidence {inputs.confidence} exceeds the ceiling {ceiling}: "
394
+ + "; ".join(reasons)
395
+ )
396
+ if band.sleeve == "INVESTABLE" and not investability(record, policy):
397
+ record["sleeve"] = "INVESTABLE"
398
+ return record
399
+
400
+
401
+ @dataclass(frozen=True)
402
+ class MarketInputs:
403
+ """One core ETF: grounded data plus the model's judgment calls."""
404
+
405
+ ticker: str
406
+ entry_price: float
407
+ price_tag: str
408
+ price_date: str
409
+ sigma: float
410
+ sigma_source: str
411
+ thesis: str
412
+ beta_vs_spy: float | None = None # required for every ETF but SPY
413
+ mu_adjustment: float = 0.0
414
+ mu_adjustment_reason: str = ""
415
+ confidence: str = "MEDIUM"
416
+ horizon_days: int | None = None
417
+ ledger_rows: tuple[str, ...] = ()
418
+
419
+ @classmethod
420
+ def from_mapping(cls, data: Mapping[str, Any]) -> "MarketInputs":
421
+ values = dict(data)
422
+ unknown = sorted(set(values) - {f.name for f in dataclasses.fields(cls)})
423
+ if unknown:
424
+ raise KernelError(f"unknown market input(s) {unknown}")
425
+ values["ledger_rows"] = tuple(values.get("ledger_rows", ()))
426
+ try:
427
+ return cls(**values)
428
+ except TypeError as exc:
429
+ raise KernelError(str(exc)) from None
430
+
431
+
432
+ def market_forecast_records(
433
+ run_date: str,
434
+ model: str,
435
+ regime: str,
436
+ inputs: Sequence[MarketInputs],
437
+ policy: Policy,
438
+ ) -> list[dict[str, Any]]:
439
+ """``rules.md`` § Core ETF Market Forecast: SPY from the regime prior, the
440
+ others as ``beta x SPY mu``, each within its adjustment band."""
441
+ tickers = list(policy.get("market_forecast.tickers"))
442
+ given = [i.ticker for i in inputs]
443
+ if sorted(given) != sorted(tickers):
444
+ raise KernelError(f"core ETF inputs {given} must be exactly {tickers}")
445
+ priors = policy.get("market_forecast.spy_regime_prior")
446
+ if regime not in priors:
447
+ raise KernelError(f"regime {regime!r} not in {list(priors)}")
448
+ ordered = sorted(inputs, key=lambda i: tickers.index(i.ticker))
449
+ records: list[dict[str, Any]] = []
450
+ spy_mu = 0.0
451
+ for item in ordered:
452
+ _require("price_tag", item.price_tag, PRICE_TAGS)
453
+ _require("sigma_source", item.sigma_source, SIGMA_SOURCES)
454
+ _require("confidence", item.confidence, CONFIDENCE)
455
+ if item.ticker == "SPY":
456
+ prior = policy.spy_mu_prior(regime)
457
+ limit = policy.num("market_forecast.spy_adjustment_max")
458
+ derivation = f"regime prior for {regime}"
459
+ else:
460
+ if item.beta_vs_spy is None:
461
+ raise KernelError(f"{item.ticker} needs beta_vs_spy (60d, DERIVED)")
462
+ prior = item.beta_vs_spy * spy_mu
463
+ limit = policy.num("market_forecast.beta_scaled_adjustment_max")
464
+ derivation = (
465
+ f"beta {item.beta_vs_spy:.4f} x SPY mu {spy_mu:+.4%} = {prior:+.4%}"
466
+ )
467
+ mu = _adjust(prior, item.mu_adjustment, item.mu_adjustment_reason, limit)
468
+ if item.ticker == "SPY":
469
+ spy_mu = mu
470
+ derivation += (
471
+ f"; adjustment {item.mu_adjustment * 100:+.2f}pp: "
472
+ f"{item.mu_adjustment_reason}"
473
+ if item.mu_adjustment
474
+ else "; no adjustment applied"
475
+ )
476
+ record: dict[str, Any] = {
477
+ "run_date": run_date,
478
+ "model": model,
479
+ "ticker": item.ticker,
480
+ "type": "MARKET_FORECAST",
481
+ "entry_price": item.entry_price,
482
+ "price_tag": item.price_tag,
483
+ "price_date": item.price_date,
484
+ "mu": _round(mu),
485
+ "mu_prior": _round(prior),
486
+ "mu_adjustment": item.mu_adjustment,
487
+ "mu_derivation": derivation,
488
+ "sigma": _round(item.sigma),
489
+ "sigma_source": item.sigma_source,
490
+ **_price_fields(item.entry_price, mu, item.sigma, policy),
491
+ "target_date": _target_date(run_date, item.horizon_days, policy),
492
+ "benchmark": "NONE",
493
+ "benchmark_price": None,
494
+ "adj_score": None,
495
+ "beta_vs_spy": item.beta_vs_spy,
496
+ "confidence": item.confidence,
497
+ "status": "OPEN",
498
+ "thesis": item.thesis,
499
+ "score_explainability": None,
500
+ "ledger_rows": list(item.ledger_rows),
501
+ }
502
+ if item.mu_adjustment:
503
+ record["mu_adjustment_reason"] = item.mu_adjustment_reason
504
+ records.append(record)
505
+ return records
506
+
507
+
508
+ def predictions_payload(
509
+ *,
510
+ run_date: str,
511
+ model: str,
512
+ run_status: str,
513
+ data_mode: str,
514
+ regime: str,
515
+ predictions: Sequence[Mapping[str, Any]],
516
+ settlements: Sequence[Mapping[str, Any]] = (),
517
+ **extra: Any,
518
+ ) -> dict[str, Any]:
519
+ """A schema-v1 ``15_predictions.json`` payload around built records."""
520
+ _require("data_mode", data_mode, DATA_MODES)
521
+ return {
522
+ "schema_version": SCHEMA_VERSION,
523
+ "run_date": run_date,
524
+ "model": model,
525
+ "run_status": run_status,
526
+ "data_mode": data_mode,
527
+ "regime": regime,
528
+ **extra,
529
+ "predictions": [dict(p) for p in predictions],
530
+ "settlements": [dict(s) for s in settlements],
531
+ }
532
+
533
+
534
+ @dataclass(frozen=True)
535
+ class Holding:
536
+ """One proposed position: a NAV weight plus its own adjusted closes."""
537
+
538
+ ticker: str
539
+ weight: float # fraction of NAV; the rest of NAV is cash (beta 0, vol 0)
540
+ sector: str # GICS sector
541
+ closes: Sequence[float] # date-aligned with the SPY closes, oldest first
542
+ earnings_within_window: bool = False
543
+
544
+ @classmethod
545
+ def from_mapping(cls, data: Mapping[str, Any]) -> "Holding":
546
+ unknown = sorted(set(data) - {f.name for f in dataclasses.fields(cls)})
547
+ if unknown:
548
+ raise KernelError(f"unknown holding field(s) {unknown}")
549
+ try:
550
+ return cls(**data)
551
+ except TypeError as exc:
552
+ raise KernelError(str(exc)) from None
553
+
554
+
555
+ @dataclass(frozen=True)
556
+ class PortfolioRisk:
557
+ """Portfolio analytics and the protected limits they breach.
558
+
559
+ ``breaches`` feeds ``decide_status(risk_breaches=...)`` directly, so the
560
+ status no longer depends on a model transcribing its own risk review.
561
+ Beta and sector weight come on both bases; ``basis`` says which one the
562
+ beta band and sector cap were checked on (``risk.exposure_basis``).
563
+ """
564
+
565
+ names: int
566
+ gross: float
567
+ basis: str # NAV | INVESTED
568
+ max_name_weight: float
569
+ sector_weights: dict[str, float] # fraction of NAV
570
+ sector_shares: dict[str, float] # fraction of gross
571
+ beta_vs_spy: float # NAV: sum(w * beta), cash at beta 0
572
+ invested_beta: float # beta_vs_spy / gross
573
+ sigma_1m: float # NAV: sqrt(w' S w), one-month scale
574
+ dd95_1m: float # parametric: var95_z * sigma_1m, normality assumed
575
+ avg_pairwise_corr: float | None # None with fewer than two names
576
+ earnings_names: tuple[str, ...]
577
+ breaches: tuple[str, ...]
578
+
579
+
580
+ def _corr(a: Sequence[float], b: Sequence[float]) -> float:
581
+ sa, sb = statistics.pstdev(a), statistics.pstdev(b)
582
+ mean_a, mean_b = statistics.fmean(a), statistics.fmean(b)
583
+ cov = statistics.fmean([(x - mean_a) * (y - mean_b) for x, y in zip(a, b)])
584
+ return cov / (sa * sb)
585
+
586
+
587
+ def portfolio_feasibility(
588
+ holdings: Sequence[Holding], spy_closes: Sequence[float], policy: Policy
589
+ ) -> PortfolioRisk:
590
+ """``rules.md`` § Computed Risk Analytics 1-4 and § Risk Controls /
591
+ Portfolio Level, checked against the policy's protected limits.
592
+
593
+ Weights are NAV fractions, so uninvested NAV is cash: it adds nothing to
594
+ beta or sigma. The drawdown cap is a loss of NAV and is always checked on
595
+ NAV. The beta band and sector cap are checked on ``risk.exposure_basis``:
596
+ on ``NAV`` a book capped at 5% a name and 10 names (50% gross) can reach
597
+ the 0.90 beta floor only if its average beta is 1.8 or more, which is why
598
+ past runs disagree on the basis (plan/2026-09-28 §6, decision 7).
599
+
600
+ Every series uses the latest 61 closes (60 intervals) and population
601
+ moments, as ``price_risk`` does. Factor crowding is not checked here: it
602
+ needs per-name family contributions, not prices.
603
+ """
604
+ basis = str(policy.get("risk.exposure_basis"))
605
+ if basis not in ("NAV", "INVESTED"):
606
+ raise KernelError(f"risk.exposure_basis={basis!r} not in ['NAV', 'INVESTED']")
607
+ if not holdings:
608
+ raise KernelError("no holdings: a portfolio needs at least one name")
609
+ tickers = [h.ticker for h in holdings]
610
+ if len(set(tickers)) != len(tickers):
611
+ raise KernelError(f"duplicate tickers in {tickers}")
612
+ for h in holdings:
613
+ if not math.isfinite(h.weight) or h.weight <= 0:
614
+ raise KernelError(f"{h.ticker}: weight {h.weight} must be positive")
615
+ if not h.sector.strip():
616
+ raise KernelError(f"{h.ticker}: a GICS sector is required")
617
+ gross = math.fsum(h.weight for h in holdings)
618
+ if gross > 1 + 1e-9:
619
+ raise KernelError(f"gross {gross:.4f} exceeds NAV: long-only, no leverage")
620
+ spy = daily_returns(_window("spy_closes", spy_closes))
621
+ returns = [daily_returns(_window(h.ticker, h.closes)) for h in holdings]
622
+ for h, r in zip(holdings, returns):
623
+ if statistics.pstdev(r) == 0:
624
+ raise KernelError(
625
+ f"{h.ticker}: zero return variance, correlation undefined"
626
+ )
627
+ weights = [h.weight for h in holdings]
628
+ betas = [_beta(r, spy) for r in returns]
629
+ beta = math.fsum(w * b for w, b in zip(weights, betas))
630
+ # Portfolio daily return series: the quadratic form w' S w without the matrix.
631
+ book = [
632
+ math.fsum(w * r[t] for w, r in zip(weights, returns)) for t in range(len(spy))
633
+ ]
634
+ sigma = statistics.pstdev(book) * MONTH
635
+ dd95 = policy.num("forecast.var95_z") * sigma
636
+ pairs = [_corr(a, b) for a, b in combinations(returns, 2)]
637
+ avg_corr = statistics.fmean(pairs) if pairs else None
638
+ sectors: dict[str, float] = {}
639
+ for h in holdings:
640
+ sectors[h.sector] = sectors.get(h.sector, 0.0) + h.weight
641
+ earnings = tuple(h.ticker for h in holdings if h.earnings_within_window)
642
+
643
+ breaches: list[str] = []
644
+ cap = policy.num("risk.max_single_name_weight")
645
+ for h in holdings:
646
+ if h.weight > cap + 1e-9:
647
+ breaches.append(
648
+ f"{h.ticker} weight {h.weight:.2%} > {cap:.0%} single-name cap"
649
+ )
650
+ shares = {sector: weight / gross for sector, weight in sectors.items()}
651
+ invested = beta / gross
652
+ sector_cap = policy.num("risk.max_sector_weight")
653
+ checked = sectors if basis == "NAV" else shares
654
+ for sector, weight in sorted(checked.items()):
655
+ if weight > sector_cap + 1e-9:
656
+ breaches.append(
657
+ f"{sector} {weight:.2%} of {basis} > {sector_cap:.0%} sector cap"
658
+ )
659
+ lo, hi = (float(x) for x in policy.get("risk.beta_band"))
660
+ checked_beta = beta if basis == "NAV" else invested
661
+ if not lo <= checked_beta <= hi:
662
+ note = (
663
+ f" (invested-book beta {invested:.2f} at gross {gross:.0%})"
664
+ if basis == "NAV" and gross < 1 - 1e-9
665
+ else ""
666
+ )
667
+ breaches.append(
668
+ f"{basis} beta {checked_beta:.2f} outside {lo:.2f}-{hi:.2f}{note}"
669
+ )
670
+ corr_cap = policy.num("risk.max_avg_pairwise_corr")
671
+ if avg_corr is not None and avg_corr >= corr_cap:
672
+ breaches.append(f"avg pairwise corr {avg_corr:.2f} >= {corr_cap:.2f}")
673
+ dd_cap = policy.num("risk.max_dd95_1m")
674
+ if dd95 > dd_cap + 1e-12:
675
+ breaches.append(f"dd95 1m {dd95:.2%} > {dd_cap:.0%}")
676
+ max_earnings = int(policy.num("evidence.max_earnings_names"))
677
+ if len(earnings) > max_earnings:
678
+ breaches.append(
679
+ f"{len(earnings)} names with earnings inside the window "
680
+ f"(> {max_earnings}): {', '.join(earnings)}"
681
+ )
682
+ return PortfolioRisk(
683
+ names=len(holdings),
684
+ gross=gross,
685
+ basis=basis,
686
+ max_name_weight=max(weights),
687
+ sector_weights=dict(sorted(sectors.items())),
688
+ sector_shares=dict(sorted(shares.items())),
689
+ beta_vs_spy=beta,
690
+ invested_beta=invested,
691
+ sigma_1m=sigma,
692
+ dd95_1m=dd95,
693
+ avg_pairwise_corr=avg_corr,
694
+ earnings_names=earnings,
695
+ breaches=tuple(breaches),
696
+ )