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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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
|
+
]
|