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