quantred 2.4.0__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 (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,335 @@
1
+ """Multi-period trading: aim in front of the target.
2
+
3
+ The plain single-period solve (:class:`~quantred.portfolio.optimizer.MeanVarianceOptimizer`)
4
+ is honestly cost-aware, but it is still **myopic**: every rebalance it pays real
5
+ cost to reach *this period's* optimum, ignoring that (1) alphas decay, so some of
6
+ what it trades into is gone by the time it fully arrives, and (2) today's trade
7
+ sets tomorrow's starting point, so a chain of myopic solves over-trades relative
8
+ to the true dynamic optimum whenever alphas are persistent.
9
+
10
+ With quadratic trading costs and exponentially-decaying signals, the dynamic
11
+ program collapses to two rules:
12
+
13
+ - **The aim portfolio** - the Markowitz solve on alphas *discounted* by
14
+ ``κ/(κ+φ)`` per signal (``φ`` its decay rate, ``κ`` the trading rate below) -
15
+ "aim in front of the target": construct the portfolio you'll still want once you
16
+ get there, not the one you want today.
17
+ - **Partial adjustment** - trade a fraction ``κ ∈ (0,1]`` of the gap each period,
18
+ ``w_t = w_{t-1} + κ·(aim_t - w_{t-1})``, ``κ`` increasing in risk (urgency) and
19
+ decreasing in cost.
20
+
21
+ This module derives ``κ`` from the book's own risk-aversion/variance and cost
22
+ curvature (a closed form checked against limiting cases below), applies the decay
23
+ discount, and composes the partial-adjustment step with the optimizer's own
24
+ no-trade band via its *existing* proximal-projection machinery - it wraps
25
+ :class:`~quantred.portfolio.optimizer.MeanVarianceOptimizer`, never modifies it. A cost
26
+ curvature that can't be estimated (no capital, or a trade too small to pin the
27
+ √-impact term) is not an error - the policy degrades gracefully to exactly the
28
+ plain myopic, cost-aware solve (see :func:`derive_kappa`).
29
+
30
+ **κ derivation.** Single-asset discrete-time LQ control with quadratic risk
31
+ ``λσ²w²`` and quadratic trading cost ``(c₂/2)Δw²`` (our real cost is linear +
32
+ 3/2-power; ``c₂`` is its local curvature at the book's typical trade size, see
33
+ :func:`quantred.costs.parametric.cost_curvature` - an approximation validated by the
34
+ net-of-cost A/B, not asserted exact). The Bellman value function
35
+ ``V(w,f) = -Aw² + Bwf - Cf²`` for a signal decaying as ``f_{t+1} = δf_t`` gives, by
36
+ matching quadratic coefficients: ``L := λσ² + A`` solves
37
+ ``L² - λσ²·L - λσ²c₂/2 = 0``, so with ``s = λσ²``:
38
+
39
+ L = (s + √(s² + 2·s·c₂)) / 2 κ = 2L/(2L + c₂)
40
+
41
+ which simplifies to the closed form in :func:`derive_kappa` - with **no dependence
42
+ on φ** in κ itself (φ only enters the discount, below): the trading *speed*
43
+ depends on risk/cost alone; *what you aim at* depends on decay. Limiting cases,
44
+ both exact and tested (``test_kappa_limits``): ``c₂ → 0 ⇒ κ → 1`` (costless: no
45
+ curvature to solve against - see the fallback note below); ``c₂ → ∞ ⇒ κ → 0``;
46
+ monotone (``∂κ/∂c₂ < 0`` for all ``c₂ ≥ 0``) ⇒ κ increasing in ``s = λ_A·σ²``,
47
+ decreasing in cost.
48
+
49
+ Solving the *other* coefficient-matching equation (the ``B``/``wf`` coefficient)
50
+ exactly - using the algebraic identity ``2Lθ = c₂·a`` that falls out of the same
51
+ fixed point - gives the *aim* coefficient (the weight on ``f`` in the optimal
52
+ policy) **exactly**: ``μ/a = a / [2λσ²·(1 − δ(1−a))]`` where ``δ = e^(−φ)`` is the
53
+ per-rebalance persistence - i.e. the discount on alpha is ``κ / (1 − δ(1−κ))``
54
+ (:func:`discount_factor`), not merely its small-φ limit ``κ/(κ+φ)`` (which is
55
+ what a first pass at this derivation gives, and is commonly quoted this way, but
56
+ whose error *grows* with φ - a real bug in an earlier draft of this module,
57
+ caught by ``test_synthetic_gp_world...`` disagreeing with a direct value-
58
+ iteration solve of the Bellman recursion at large φ). Since the exact form costs
59
+ nothing extra to evaluate, :func:`discount_factor` uses it directly.
60
+
61
+ **Why "costless ⇒ exact myopic" is a fallback, not a limit.** Plugging ``c₂ = 0``
62
+ into the closed form gives ``κ = 1`` exactly, and at ``κ=1`` the exact discount
63
+ formula above *does* correctly give 1 (``δ(1−1) = 0``) for any φ - so the
64
+ costless case is consistent either way here. The fallback is still needed for a
65
+ different reason: :func:`derive_kappa` has no way to fit ``c₂`` at all without an
66
+ impact term (no capital, or a linear-only cost model) - "undefined" is not the
67
+ same number as "zero", and treating it as zero would silently invent a κ=1 from
68
+ a curvature that was never actually measured. So the policy's job is to
69
+ recognize "no curvature to fit" and hand
70
+ back the exact myopic solve, undiscounted, rather than apply a discount formula
71
+ whose derivation assumed a nonzero cost. :func:`derive_kappa` returns ``None``
72
+ whenever the cost curvature is undefined (no impact term, or too small a typical
73
+ trade to pin it), and :func:`build_aim_portfolio` treats ``None`` as "run the
74
+ plain solve exactly, band-only" - this is what makes the costless limiting case
75
+ an *exact*, tested reduction (``test_costless_reduces_to_exact_myopic``) instead of an asymptote.
76
+ """
77
+
78
+ import math
79
+ from dataclasses import replace
80
+ from typing import Dict, List, Optional
81
+
82
+ import numpy as np
83
+
84
+ from quantred.alphas.base import Alpha
85
+ from quantred.portfolio.optimizer import MeanVarianceOptimizer, PortfolioResult
86
+ from quantred.risk.base import RiskMatrix
87
+
88
+ #: Floor on the typical per-name trade size fed to the curvature fit -
89
+ #: avoids a near-zero denominator manufacturing an absurd κ when the myopic
90
+ #: reference solve traded almost nothing (e.g. it was already near its no-trade band).
91
+ MIN_TYPICAL_TRADE = 1e-4
92
+
93
+
94
+ # --------------------------------------------------------------------------- #
95
+ # Pure math: decay units, the discount, and the trading rate κ
96
+ # --------------------------------------------------------------------------- #
97
+ def phi_from_half_life(half_life_periods: float, min_half_life: float = 1.0) -> float:
98
+ """Per-rebalance decay rate ``φ = ln2 / HL``, floored at ``min_half_life``
99
+ (can't measure decay faster than the rebalance frequency itself - a floor of
100
+ 1 rebalance is the fastest a half-life can mean anything).
101
+ A permanent signal (``HL = inf``) has ``φ = 0`` - no discount at any κ.
102
+ """
103
+ if not math.isfinite(half_life_periods):
104
+ return 0.0
105
+ return math.log(2) / max(half_life_periods, min_half_life)
106
+
107
+
108
+ def half_life_in_rebalance_units(half_life_periods: float, periods_per_rebalance: float) -> float:
109
+ """Convert a half-life measured in raw bars into rebalance units - silently
110
+ mixing bars and rebalances would misprice every discount by the cadence
111
+ factor. ``periods_per_rebalance`` is the bar count between rebalances (e.g.
112
+ ~21 for a monthly cadence on daily bars). A non-finite half-life (permanent
113
+ signal) or a non-positive cadence passes through unchanged.
114
+ """
115
+ if not math.isfinite(half_life_periods) or periods_per_rebalance <= 0:
116
+ return half_life_periods
117
+ return half_life_periods / periods_per_rebalance
118
+
119
+
120
+ def discount_factor(kappa: float, phi: float) -> float:
121
+ """The aim-alpha decay discount, the **exact** closed form:
122
+
123
+ discount = κ / (1 − δ·(1−κ)), δ = e^(−φ)
124
+
125
+ (derived from the same Riccati fixed point as :func:`derive_kappa` - see the
126
+ module docstring - by solving the *other* coefficient-matching equation,
127
+ ``B(1−δθ) = θ·β``, exactly, with no small-φ linearization). This is
128
+ numerically verified against a direct value-iteration solve of the Bellman
129
+ recursion (see ``test_discount_factor_matches_value_iteration``): the
130
+ frequently-quoted ``κ/(κ+φ)`` form is only this formula's small-φ limit
131
+ (``δ ≈ 1−φ`` ⇒ ``1−δ(1−κ) ≈ φ+κ``) and its error *grows* with φ - exact is no
132
+ more expensive to compute, so there is no reason to use the approximation.
133
+
134
+ ``φ = 0`` (a permanent signal) ⇒ ``δ = 1`` ⇒ discount ``= 1`` at any κ,
135
+ exactly - no discount ever applies to a signal that doesn't decay. ``κ → 0``
136
+ (trading nearly frozen) ⇒ discount ``→ κ/(1−δ)`` for any φ > 0, which
137
+ coincides with the familiar ``κ/φ`` in the same small-φ limit.
138
+ """
139
+ if phi <= 0:
140
+ return 1.0
141
+ if kappa <= 0:
142
+ return 0.0
143
+ delta = math.exp(-phi)
144
+ denom = 1.0 - delta * (1.0 - kappa)
145
+ return kappa / denom if denom > 0 else 1.0
146
+
147
+
148
+ def derive_kappa(risk_aversion: float, variance: float, cost_curvature: Optional[float]) -> Optional[float]:
149
+ """Gârleanu-Pedersen's trading rate, closed form (derived in this module's
150
+ docstring): ``s = λ_A·σ²``, ``κ = (s + √(s²+2sc₂)) / (s + c₂ + √(s²+2sc₂))``.
151
+
152
+ Monotone increasing in ``s`` (risk/urgency), decreasing in ``c₂`` (cost
153
+ curvature) - ``test_kappa_monotonicity`` grids this. ``κ → 1`` as ``c₂ → 0``;
154
+ ``κ → 0`` as ``c₂ → ∞`` (``test_kappa_limits``).
155
+
156
+ Returns ``None`` - the "cost curvature ill-defined" signal - when ``c₂`` is
157
+ missing/non-positive (no impact term, or a trade too small to pin it) or when
158
+ ``risk_aversion·variance`` itself is non-positive. The caller
159
+ (:func:`build_aim_portfolio`) treats ``None`` as "fall back to the plain
160
+ myopic solve exactly, band-only" (a documented fallback), not an error.
161
+ """
162
+ if cost_curvature is None or not (cost_curvature > 0):
163
+ return None
164
+ s = risk_aversion * variance
165
+ if not (s > 0):
166
+ return None
167
+ root = math.sqrt(s * s + 2.0 * s * cost_curvature)
168
+ return (s + root) / (s + cost_curvature + root)
169
+
170
+
171
+ def trading_half_life(kappa: float) -> float:
172
+ """Implied trading half-life, in rebalances: ``ln2/κ`` - a reported
173
+ diagnostic. Infinite when κ is non-positive (frozen book)."""
174
+ return math.log(2) / kappa if kappa > 0 else float("inf")
175
+
176
+
177
+ # --------------------------------------------------------------------------- #
178
+ # The policy: aim construction + partial adjustment, wrapping the optimizer
179
+ # --------------------------------------------------------------------------- #
180
+ def build_aim_portfolio(
181
+ optimizer: MeanVarianceOptimizer,
182
+ alphas: List[Alpha],
183
+ risk: RiskMatrix,
184
+ *,
185
+ phi: float = 0.0,
186
+ trade_rate: Optional[float] = None,
187
+ target_te: Optional[float] = None,
188
+ risk_aversion: Optional[float] = None,
189
+ current_weights: Optional[Dict[str, float]] = None,
190
+ cost_model=None,
191
+ cost_inputs=None,
192
+ capital: Optional[float] = None,
193
+ holding_period_years: float = 1.0 / 12.0,
194
+ ) -> PortfolioResult:
195
+ """The aim-in-front-of-the-target policy: derive κ, discount the alphas by
196
+ ``κ/(κ+φ)``, solve the cost-free aim (the plain optimizer with the cost term
197
+ zeroed), take the κ-fraction step from ``current_weights``, and pass the
198
+ result through the optimizer's own proximal band projection (the *same*
199
+ box/budget/no-trade-band machinery a real cost-aware solve would apply -
200
+ reused via :meth:`MeanVarianceOptimizer._prox_project`, never re-derived) so
201
+ the band and κ compose rather than stack independently.
202
+
203
+ ``phi`` is the (already per-rebalance, already CI-adjusted) decay rate for
204
+ this alpha vector - a single scalar, since v1 is a single combined signal per
205
+ call (scalar κ/φ is the v1 lean; per-signal only matters upstream of
206
+ multi-signal combination, where :func:`discount_factor` is the same one-line
207
+ vector scale applied per signal before ``combine_scores`` - no change needed
208
+ here).
209
+
210
+ ``trade_rate`` overrides the derived κ (the CLI's ``--trade-rate``). Without a
211
+ usable cost curvature (no capital, or the myopic reference solve traded too
212
+ little to pin one) and no override, this **falls back to exactly the plain
213
+ cost-aware solve** - the costless/undefined-curvature reduction is exact,
214
+ not asymptotic (see the module docstring). Position constraints (box,
215
+ cardinality, min-weight) are enforced on the *aim* solve itself (it is a full
216
+ solve), so the final κ-adjusted-and-banded trade then respects the box via
217
+ the proximal projection, though a rebalance whose ``current_weights`` and
218
+ aim disagree sharply on *which* names to hold can, in principle, transiently
219
+ exceed ``max_names`` until the next full re-solve (a documented v1
220
+ limitation, not exercised when ``max_names`` is unset).
221
+
222
+ Book scope: **long-only only** in v1 (the final banding step reuses the
223
+ long-only proximal projection). Benchmark-relative books are also out of
224
+ v1's scope - the aim/myopic split here works in cash-relative space only.
225
+ """
226
+ myopic = optimizer.optimize(
227
+ alphas,
228
+ risk,
229
+ target_te=target_te,
230
+ risk_aversion=risk_aversion,
231
+ current_weights=current_weights,
232
+ cost_model=cost_model,
233
+ cost_inputs=cost_inputs,
234
+ capital=capital,
235
+ holding_period_years=holding_period_years,
236
+ )
237
+ if not myopic.feasible:
238
+ return myopic
239
+
240
+ symbols, alpha_vec = MeanVarianceOptimizer._align(alphas, risk)
241
+ if len(symbols) == 0:
242
+ return myopic
243
+
244
+ sigma = MeanVarianceOptimizer._submatrix(risk, symbols)
245
+ lam = float(myopic.diagnostics["risk_aversion"])
246
+ c_lin, k_imp = MeanVarianceOptimizer._cost_coefficients(
247
+ cost_model, cost_inputs, capital, holding_period_years, symbols
248
+ )
249
+
250
+ names_traded = myopic.diagnostics.get("names_traded")
251
+ turnover = float(myopic.diagnostics.get("turnover", 0.0))
252
+ typical_trade = (turnover / names_traded) if names_traded else MIN_TYPICAL_TRADE
253
+ typical_trade = max(typical_trade, MIN_TYPICAL_TRADE)
254
+
255
+ mean_k = float(np.mean(k_imp)) if k_imp.size else 0.0
256
+ typical_variance = float(np.mean(np.diag(sigma)))
257
+ from quantred.costs.parametric import cost_curvature
258
+
259
+ c2 = cost_curvature(mean_k, typical_trade)
260
+ kappa_derived = derive_kappa(lam, typical_variance, c2)
261
+ kappa = trade_rate if trade_rate is not None else kappa_derived
262
+
263
+ if kappa is None or not (kappa > 0):
264
+ result = PortfolioResult(
265
+ weights=dict(myopic.weights),
266
+ feasible=True,
267
+ binding_constraint=myopic.binding_constraint,
268
+ diagnostics={
269
+ **myopic.diagnostics,
270
+ "policy": "myopic_fallback",
271
+ "aim_degraded": True,
272
+ "fallback_reason": "cost curvature undefined (no capital/impact term, or "
273
+ "trade too small to pin c2) and no --trade-rate override; degraded to "
274
+ "the plain cost-aware solve",
275
+ "cost_curvature": c2,
276
+ "kappa_derived": kappa_derived,
277
+ "typical_trade_size": typical_trade,
278
+ },
279
+ unconstrained_weights=dict(myopic.unconstrained_weights),
280
+ )
281
+ return result
282
+
283
+ discount = discount_factor(kappa, phi)
284
+ discounted_alphas = [replace(a, alpha=a.alpha * discount) for a in alphas]
285
+
286
+ aim = optimizer.optimize(
287
+ discounted_alphas,
288
+ risk,
289
+ risk_aversion=lam,
290
+ current_weights=current_weights,
291
+ cost_model=None,
292
+ cost_inputs=None,
293
+ capital=None,
294
+ )
295
+ if not aim.feasible:
296
+ return aim
297
+
298
+ w_prev = MeanVarianceOptimizer._vector(current_weights or {}, symbols)
299
+ w_aim = MeanVarianceOptimizer._vector(aim.weights, symbols)
300
+ raw_target = w_prev + kappa * (w_aim - w_prev)
301
+
302
+ lmax = float(np.linalg.eigvalsh(sigma).max())
303
+ step = 1.0 / (2.0 * lam * lmax) if lmax > 0 else 1.0
304
+ thr = step * c_lin
305
+ kthr = step * k_imp
306
+ w_final = optimizer._prox_project(raw_target, thr, kthr, w_prev)
307
+
308
+ sigma_inv = np.linalg.inv(sigma)
309
+ ir_star = float(np.sqrt(max(alpha_vec @ sigma_inv @ alpha_vec, 0.0)))
310
+ zeros = np.zeros(len(symbols))
311
+ cost_aware = bool(np.any(c_lin > 0) or np.any(k_imp > 0))
312
+ diagnostics = optimizer._diagnostics(
313
+ alpha_vec, sigma, w_final, w_prev, lam, ir_star, c_lin, k_imp, cost_aware, zeros, zeros, 0.0
314
+ )
315
+ diagnostics.update(
316
+ {
317
+ "policy": "aim",
318
+ "aim_degraded": False,
319
+ "kappa": kappa,
320
+ "kappa_derived": kappa_derived,
321
+ "kappa_overridden": trade_rate is not None,
322
+ "trading_half_life_rebalances": trading_half_life(kappa),
323
+ "cost_curvature": c2,
324
+ "typical_trade_size": typical_trade,
325
+ "phi_per_rebalance": phi,
326
+ "signal_discount": discount,
327
+ }
328
+ )
329
+
330
+ return PortfolioResult(
331
+ weights={s: float(w_final[i]) for i, s in enumerate(symbols) if abs(w_final[i]) > 1e-9},
332
+ feasible=True,
333
+ diagnostics=diagnostics,
334
+ unconstrained_weights=dict(aim.weights),
335
+ )
@@ -0,0 +1,208 @@
1
+ """Black–Litterman posterior: blend our views with Σ's implied propagation.
2
+
3
+ The reverse-optimized consensus (``quantred/portfolio/benchmark.py``) backs out the
4
+ expected returns for which the benchmark is itself optimal; our alphas are
5
+ *deviations from* that consensus. Left alone, the optimizer reads a name with no
6
+ signal as a *hard* zero-view ("this name earns exactly consensus") rather than as
7
+ ignorance, and treats every view's confidence as baked into its magnitude alone.
8
+ Black–Litterman fixes both: a single precision-weighted blend of the (zero, in
9
+ residual space) prior and our views, where prior precision comes from ``(τΣ)⁻¹``
10
+ and view precision from measured IC. Names we never scored still get a real,
11
+ nonzero posterior when they're correlated with a name we *did* score ("never zero
12
+ the correlated forecasts") — the propagation is the entire point.
13
+
14
+ Pure numpy, no I/O. Two layers:
15
+
16
+ - :func:`black_litterman` — the update itself, given ``Ω`` directly (a diagonal
17
+ view-covariance dict). This is the primitive the limit tests (Ω→0, Ω→∞) drive.
18
+ - :func:`black_litterman_from_ic` — derives ``Ω`` from IC via the calibration
19
+ identity (:func:`view_variance`) and calls the primitive. This is what
20
+ ``construct_portfolio`` calls.
21
+
22
+ Everything here is in **residual/active space**: the prior mean is exactly 0, not
23
+ because our alphas happen to be benchmark-neutral, but because the
24
+ reverse-optimization consensus is *defined* as the return explained by beta — by
25
+ construction there is zero consensus residual return left over. ``q`` (the views)
26
+ must be the *unshrunk* refined alphas (the refinement's own level-shrink stage
27
+ left off upstream) — Ω below already carries the full IC-uncertainty haircut, so
28
+ shrinking twice would halve it twice.
29
+ """
30
+
31
+ from dataclasses import dataclass, field
32
+ from typing import Dict, List, Optional
33
+
34
+ import numpy as np
35
+
36
+ from quantred.alphas.refine import level_shrink_factor
37
+ from quantred.risk.base import RiskMatrix
38
+
39
+
40
+ @dataclass
41
+ class BLPosterior:
42
+ """The BL posterior over Σ's full universe, plus the audit trail (τ-sensitivity, sourcing)."""
43
+
44
+ mu_post: Dict[str, float] # symbol -> posterior residual return, EVERY Σ symbol
45
+ source: Dict[str, str] # symbol -> "view" | "propagated" | "prior"
46
+ views: Dict[str, float] = field(default_factory=dict) # symbol -> q, covered names only
47
+ omega: Dict[str, float] = field(default_factory=dict) # symbol -> view variance, covered only
48
+ tau: float = 0.0
49
+ tau_sensitivity: Dict[str, Dict[str, float]] = field(default_factory=dict)
50
+ #: Set only by :func:`black_litterman_from_ic` (the low-level update takes Ω
51
+ #: directly and has no notion of a single IC/T_eff behind it).
52
+ ic: Optional[float] = None
53
+ t_eff: Optional[float] = None
54
+
55
+
56
+ def view_variance(residual_vol: float, ic: float, t_eff: float) -> float:
57
+ """The calibration-forced view-error variance ``Ω_n = ω_n² / (T_eff²·IC²)``.
58
+
59
+ Not the naive ``ω²·(1−IC²)`` — that formula alone prices only "the signal
60
+ isn't perfectly correlated with truth," and ignores that IC itself is
61
+ *estimated* over a finite ``T_eff``. The calibration identity (a single
62
+ covered-name view, uninformative prior, pinned ``τ = 1/T_eff``, must reproduce
63
+ the refinement's own level-shrunk alpha exactly — the ``g/(g+1)`` weight
64
+ :func:`~quantred.alphas.refine.level_shrink_factor` already computes, ``g =
65
+ T_eff·IC²``) forces this closed form: the BL blend weight on a single view is
66
+ ``τω²/(τω²+Ω)``; setting that equal to ``g/(g+1)`` with ``τ = 1/T_eff`` and
67
+ solving for ``Ω`` gives exactly ``ω²/(T_eff²·IC²)``.
68
+
69
+ Returns ``inf`` when ``ic`` is zero/non-finite or ``t_eff`` is non-positive (an
70
+ uninformative view carries zero precision, i.e. infinite variance) — callers
71
+ that build a diagonal Ω matrix must drop such views rather than pass ``inf``
72
+ through a linear solve (see :func:`black_litterman_from_ic`).
73
+ """
74
+ if not np.isfinite(ic) or ic == 0 or t_eff <= 0:
75
+ return float("inf")
76
+ return float((residual_vol**2) / (t_eff**2 * ic**2))
77
+
78
+
79
+ def expected_single_view_weight(ic: float, t_eff: float) -> float:
80
+ """The blend weight a lone covered-name view must land on (the calibration identity).
81
+
82
+ Reuses :func:`~quantred.alphas.refine.level_shrink_factor` directly (not a second,
83
+ independently-derived formula) so the calibration test is a tautology by
84
+ construction if :func:`view_variance` is right, and a real check if it isn't.
85
+ """
86
+ return level_shrink_factor(ic, t_eff)
87
+
88
+
89
+ def calibration_gap(posterior: "BLPosterior") -> Optional[float]:
90
+ """For a single-view posterior, ``actual_weight − expected_weight``.
91
+
92
+ ``actual_weight = μ_post,n / q_n`` for the one covered name; ``expected_weight``
93
+ is :func:`expected_single_view_weight`. Near 0 is the pass condition — anything
94
+ else means Ω is mis-derived relative to the refinement's level shrink (double- or
95
+ under-shrunk). Returns ``None`` when the posterior isn't single-view (the K=N
96
+ matrix-form generalization of this same identity is a future extension) or
97
+ when ``ic``/``t_eff`` weren't recorded (a raw :func:`black_litterman` call).
98
+ """
99
+ if len(posterior.views) != 1 or posterior.ic is None or posterior.t_eff is None:
100
+ return None
101
+ ((sym, q),) = posterior.views.items()
102
+ if q == 0:
103
+ return None
104
+ actual = posterior.mu_post[sym] / q
105
+ expected = expected_single_view_weight(posterior.ic, posterior.t_eff)
106
+ return float(actual - expected)
107
+
108
+
109
+ def black_litterman(
110
+ views: Dict[str, float],
111
+ risk: RiskMatrix,
112
+ omega: Dict[str, float],
113
+ tau: float,
114
+ ) -> BLPosterior:
115
+ """The BL update in residual/active space:
116
+ ``μ_post = τΣPᵀ(PτΣPᵀ+Ω)⁻¹q``.
117
+
118
+ Zero-prior-mean, one ``(K×K)`` solve (``K`` = number of covered names — cheap at
119
+ this scale). ``views`` (``q``) and ``omega`` (``Ω``, diagonal, one entry per
120
+ view — exactly one view per name, already decorrelated/combined upstream by
121
+ the multi-signal combination) are keyed by symbol; any symbol in ``views``
122
+ absent from ``risk.symbols`` is ignored. Also computes the τ-sensitivity
123
+ (posterior recomputed at ``τ/2`` and ``2τ``).
124
+ """
125
+ symbols = list(risk.symbols)
126
+ idx = {s: i for i, s in enumerate(symbols)}
127
+ sigma = risk.sigma
128
+
129
+ covered = [s for s in views if s in idx and s in omega]
130
+ cov_idx = [idx[s] for s in covered]
131
+ q = np.array([views[s] for s in covered], dtype=float)
132
+ omega_diag = np.array([omega[s] for s in covered], dtype=float)
133
+
134
+ tau_eff = max(float(tau), 0.0)
135
+ mu_post_vec = _update(sigma, cov_idx, q, omega_diag, tau_eff)
136
+
137
+ tol = 1e-12
138
+ source: Dict[str, str] = {}
139
+ for s in symbols:
140
+ if s in covered:
141
+ source[s] = "view"
142
+ elif abs(mu_post_vec[idx[s]]) > tol:
143
+ source[s] = "propagated"
144
+ else:
145
+ source[s] = "prior"
146
+
147
+ sensitivity: Dict[str, Dict[str, float]] = {}
148
+ for label, t in (("tau_half", tau_eff / 2.0), ("tau_double", tau_eff * 2.0)):
149
+ vec = _update(sigma, cov_idx, q, omega_diag, t)
150
+ sensitivity[label] = {s: float(vec[idx[s]]) for s in symbols}
151
+
152
+ return BLPosterior(
153
+ mu_post={s: float(mu_post_vec[idx[s]]) for s in symbols},
154
+ source=source,
155
+ views={s: float(views[s]) for s in covered},
156
+ omega={s: float(omega[s]) for s in covered},
157
+ tau=tau_eff,
158
+ tau_sensitivity=sensitivity,
159
+ )
160
+
161
+
162
+ def black_litterman_from_ic(
163
+ views: Dict[str, float],
164
+ risk: RiskMatrix,
165
+ ic: float,
166
+ t_eff: float,
167
+ tau: Optional[float] = None,
168
+ ) -> BLPosterior:
169
+ """:func:`black_litterman` with Ω derived from IC and τ pinned to
170
+ ``1/T_eff`` — what ``construct_portfolio`` calls.
171
+
172
+ A view whose derived Ω is non-finite (``ic`` zero/non-finite, or ``t_eff`` ≤ 0 —
173
+ an uninformative signal or unmeasurable sample) is dropped from the covered set
174
+ entirely rather than passed through as ``inf`` (which would poison the linear
175
+ solve); its name still gets a posterior, either 0 (prior) or a nonzero
176
+ propagated value from OTHER views, exactly as if it had never had a signal.
177
+ """
178
+ symbols = list(risk.symbols)
179
+ idx = {s: i for i, s in enumerate(symbols)}
180
+ filtered_views: Dict[str, float] = {}
181
+ omega: Dict[str, float] = {}
182
+ for s, q in views.items():
183
+ if s not in idx or q != q: # not covered by Σ, or NaN
184
+ continue
185
+ vol = float(np.sqrt(max(risk.sigma[idx[s], idx[s]], 0.0)))
186
+ om = view_variance(vol, ic, t_eff)
187
+ if np.isfinite(om):
188
+ filtered_views[s] = q
189
+ omega[s] = om
190
+
191
+ tau_eff = float(tau) if tau is not None else (1.0 / t_eff if t_eff > 0 else 0.0)
192
+ posterior = black_litterman(filtered_views, risk, omega, tau_eff)
193
+ posterior.ic = float(ic)
194
+ posterior.t_eff = float(t_eff)
195
+ return posterior
196
+
197
+
198
+ def _update(
199
+ sigma: np.ndarray, cov_idx: List[int], q: np.ndarray, omega_diag: np.ndarray, tau: float
200
+ ) -> np.ndarray:
201
+ """``μ_post = τΣPᵀ(PτΣPᵀ+Ω)⁻¹q`` for one ``τ`` — the shared inner solve."""
202
+ n = sigma.shape[0]
203
+ if not cov_idx:
204
+ return np.zeros(n)
205
+ tau_sigma_pt = tau * sigma[:, cov_idx] # (n x K): τΣPᵀ
206
+ m = tau_sigma_pt[cov_idx, :] + np.diag(omega_diag) # (K x K): PτΣPᵀ + Ω
207
+ x = np.linalg.solve(m, q)
208
+ return tau_sigma_pt @ x
@@ -0,0 +1,30 @@
1
+ """Autonomous research agent (opt-in behind the ``ai`` extra).
2
+
3
+ A bounded, offline research loop: propose a hypothesis -> express it as a config
4
+ (or, behind a flag, as new strategy code) -> validate it out-of-sample with
5
+ walk-forward -> keep/discard -> repeat until budget or dryness -> score the
6
+ survivors once on the sacred holdout -> hand a human a shortlist.
7
+
8
+ The agent reuses the *same* :mod:`quantred.services` core as the MCP server, so there
9
+ is one code path. It never touches live trading, never toggles ``PAPER_TRADE``,
10
+ and is scored exclusively on out-of-sample output (see :mod:`quantred.research.agent`
11
+ for the non-negotiable guardrails). The proposer is provider-agnostic - Claude,
12
+ OpenAI, or a local Ollama model (see :mod:`quantred.research.llm`).
13
+ """
14
+
15
+ from quantred.research.agent import Candidate, ResearchAgent, ResearchConfig, ResearchResult
16
+ from quantred.research.llm import build_llm_client
17
+ from quantred.research.proposer import FixedProposer, LLMProposer, Proposal, Proposer, build_proposer
18
+
19
+ __all__ = [
20
+ "ResearchAgent",
21
+ "ResearchConfig",
22
+ "ResearchResult",
23
+ "Candidate",
24
+ "Proposer",
25
+ "Proposal",
26
+ "FixedProposer",
27
+ "LLMProposer",
28
+ "build_proposer",
29
+ "build_llm_client",
30
+ ]