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,786 @@
|
|
|
1
|
+
"""Portfolio construction as mean-variance utility maximization.
|
|
2
|
+
|
|
3
|
+
The OR-Tools :class:`~quantred.portfolio.allocator.PortfolioAllocator` maximizes a scalar
|
|
4
|
+
score subject to constraints - it has no notion of risk, so it piles weight onto the
|
|
5
|
+
highest-scoring names regardless of how correlated they are. Active-management
|
|
6
|
+
construction instead maximizes a **risk-adjusted utility**, trading expected residual
|
|
7
|
+
return (alpha) against active risk (covariance) - and, once cost is in the objective
|
|
8
|
+
(the cost-aware solve), against the *name-specific* cost of getting there:
|
|
9
|
+
|
|
10
|
+
U(w) = αᵀw − λ_A · wᵀΣw − Σᵢ cᵢ·|Δwᵢ| − Σᵢ kᵢ·|Δwᵢ|^{3/2} (Δw = w − w₀)
|
|
11
|
+
└ value ┘ └ active risk ┘ └ linear turnover ┘ └ √-impact (conic) ┘
|
|
12
|
+
|
|
13
|
+
The output is the portfolio that maximizes the *information ratio you can actually
|
|
14
|
+
implement net of cost*, plus the Fundamental-Law diagnostics that make the cost of
|
|
15
|
+
every constraint visible. This is **research-clock**: it proposes target weights (a
|
|
16
|
+
config a human promotes), it never places an order - so it lives apart from the
|
|
17
|
+
operational position sizer.
|
|
18
|
+
|
|
19
|
+
Pure numpy - no heavyweight QP/conic dependency. The unconstrained cost-free optimum is
|
|
20
|
+
closed-form; the constrained problem (long-only, box, budget, cardinality) is a small
|
|
21
|
+
convex program solved by **proximal gradient**. The cost term is nonsmooth but convex,
|
|
22
|
+
so it enters through the proximal step: after each gradient step on the smooth part we
|
|
23
|
+
apply the exact proximal operator of ``cᵢ·|Δwᵢ| + kᵢ·|Δwᵢ|^{3/2}`` *composed with* the
|
|
24
|
+
capped-simplex projection. That proximal operator is separable per name given a single
|
|
25
|
+
budget dual, and each coordinate has a closed form (a soft-threshold *around w₀* for the
|
|
26
|
+
linear cost; a quadratic-in-√ root for the √-impact) - so the whole conic problem is
|
|
27
|
+
solved by the same 1-D budget bisection the cost-free projection already used, with no
|
|
28
|
+
external solver. When ``cᵢ = kᵢ = 0`` it reduces *exactly* to the cost-free projected
|
|
29
|
+
gradient, so the cost-blind behavior is unchanged.
|
|
30
|
+
|
|
31
|
+
``book="market_neutral"`` relaxes the long-only box to ``[−s, cap]`` and the
|
|
32
|
+
budget to ``Σw = 0``, adds a mandatory gross-leverage cap ``‖w‖₁ ≤ L`` (a second,
|
|
33
|
+
outer dual - the inner budget bisection now runs once per trial value of the leverage
|
|
34
|
+
dual, "one loop deeper" per name), and prices per-name borrow carry as a linear tilt on
|
|
35
|
+
the short side only (``Σ borrowᵢ·max(−wᵢ, 0)``) - a *second* zero-anchored kink that
|
|
36
|
+
composes with the turnover kink at ``w₀`` (see :meth:`_prox_step_short`). This is a
|
|
37
|
+
wholly separate solve path from the long-only one above: ``book="long_only"`` (the
|
|
38
|
+
default) runs the exact prior code, unchanged, so every existing result is
|
|
39
|
+
byte-for-byte identical.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from typing import Dict, List, Optional
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
from quantred.alphas.base import Alpha
|
|
48
|
+
from quantred.costs.base import CostModel
|
|
49
|
+
from quantred.risk.base import RiskMatrix
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class CostInputs:
|
|
54
|
+
"""Per-name, as-of liquidity context the cost model prices trades against.
|
|
55
|
+
|
|
56
|
+
Threaded from the cost model: a fractional ``spread`` (quoted, or a high-low-range proxy),
|
|
57
|
+
trailing ``adv_dollar`` (ADV in dollars = price · share-ADV), and trailing
|
|
58
|
+
``daily_vol`` (daily return volatility). All keyed by symbol; missing names fall
|
|
59
|
+
back to the cost model's defaults (spread) or drop the √-impact term (ADV/vol).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
spread: Dict[str, float] = field(default_factory=dict)
|
|
63
|
+
adv_dollar: Dict[str, float] = field(default_factory=dict)
|
|
64
|
+
daily_vol: Dict[str, float] = field(default_factory=dict)
|
|
65
|
+
#: Per-name annualized borrow rate override - a locate-desk quote or a
|
|
66
|
+
#: manual hard-to-borrow rate; missing names fall back to the cost model's flat
|
|
67
|
+
#: default (:meth:`~quantred.costs.base.CostModel.borrow_rate`). Only priced for
|
|
68
|
+
#: ``book="market_neutral"`` (long-only books hold no shorts to carry).
|
|
69
|
+
borrow: Dict[str, float] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class PortfolioResult:
|
|
74
|
+
"""A proposed portfolio plus the quantities the Fundamental Law cares about."""
|
|
75
|
+
|
|
76
|
+
weights: Dict[str, float]
|
|
77
|
+
feasible: bool = True
|
|
78
|
+
binding_constraint: Optional[str] = None
|
|
79
|
+
diagnostics: Dict[str, float] = field(default_factory=dict)
|
|
80
|
+
unconstrained_weights: Dict[str, float] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def long_only_cap_shortfall(n_names: int, max_weight: float, max_names: Optional[int]) -> Optional[str]:
|
|
84
|
+
"""``None`` if a fully invested long-only book can be funded under these caps,
|
|
85
|
+
otherwise the binding-constraint text.
|
|
86
|
+
|
|
87
|
+
A fully invested book needs weights summing to 1, and at most
|
|
88
|
+
``min(max_names or n_names, n_names)`` names may each carry at most ``max_weight``.
|
|
89
|
+
This is the **necessary** condition only: it cannot see a solve that fails for
|
|
90
|
+
some other reason (a singular risk matrix, a benchmark-relative constraint), so
|
|
91
|
+
``None`` means "the caps alone do not rule it out", not "a book exists".
|
|
92
|
+
|
|
93
|
+
One definition on purpose: the optimizer refuses with this text, and the verdict
|
|
94
|
+
preflight asks the same question before spending the expensive steps on a book
|
|
95
|
+
that could never be funded. Two copies would let the preflight and the solver
|
|
96
|
+
disagree about the same universe.
|
|
97
|
+
"""
|
|
98
|
+
k_cap = min(max_names or n_names, n_names)
|
|
99
|
+
if k_cap * max_weight < 1.0 - 1e-9:
|
|
100
|
+
return f"max_weight*{k_cap} < 1 (cardinality/weight cap can't fund the book)"
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MeanVarianceOptimizer:
|
|
105
|
+
"""Maximize ``αᵀw − λ·wᵀΣw − cost(w − w₀)`` over long-only, box, budgeted weights."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
max_weight: float = 0.25,
|
|
110
|
+
min_weight: float = 0.0,
|
|
111
|
+
max_names: Optional[int] = None,
|
|
112
|
+
no_trade_band: float = 0.0,
|
|
113
|
+
max_iter: int = 2000,
|
|
114
|
+
tol: float = 1e-10,
|
|
115
|
+
):
|
|
116
|
+
if not 0 < max_weight <= 1:
|
|
117
|
+
raise ValueError("max_weight must be in (0, 1]")
|
|
118
|
+
if not 0 <= min_weight <= max_weight:
|
|
119
|
+
raise ValueError("min_weight must be in [0, max_weight]")
|
|
120
|
+
self.max_weight = max_weight
|
|
121
|
+
self.min_weight = min_weight # minimum weight for a *held* name (a dust floor)
|
|
122
|
+
self.max_names = max_names
|
|
123
|
+
# Manual override retained for backward-compat / the cost-free case; when a cost
|
|
124
|
+
# model is supplied the no-trade band *emerges* from the cost (see optimize()).
|
|
125
|
+
self.no_trade_band = no_trade_band
|
|
126
|
+
self.max_iter = max_iter
|
|
127
|
+
self.tol = tol
|
|
128
|
+
|
|
129
|
+
def optimize(
|
|
130
|
+
self,
|
|
131
|
+
alphas: List[Alpha],
|
|
132
|
+
risk: RiskMatrix,
|
|
133
|
+
*,
|
|
134
|
+
target_te: Optional[float] = None,
|
|
135
|
+
risk_aversion: Optional[float] = None,
|
|
136
|
+
current_weights: Optional[Dict[str, float]] = None,
|
|
137
|
+
cost_model: Optional[CostModel] = None,
|
|
138
|
+
cost_inputs: Optional[CostInputs] = None,
|
|
139
|
+
capital: Optional[float] = None,
|
|
140
|
+
holding_period_years: float = 1.0 / 12.0,
|
|
141
|
+
benchmark_weights: Optional[Dict[str, float]] = None,
|
|
142
|
+
book: str = "long_only",
|
|
143
|
+
short_max_weight: float = 0.0,
|
|
144
|
+
gross_leverage: Optional[float] = None,
|
|
145
|
+
) -> PortfolioResult:
|
|
146
|
+
"""Construct the utility-maximizing portfolio over the alpha/risk universe.
|
|
147
|
+
|
|
148
|
+
Supply *either* ``target_te`` (the intuitive knob - "run at 4% tracking
|
|
149
|
+
error") *or* ``risk_aversion`` (``λ_A`` directly). ``current_weights`` (``w₀``)
|
|
150
|
+
is where turnover is measured from.
|
|
151
|
+
|
|
152
|
+
Pass a ``cost_model`` + ``cost_inputs`` to make the solve **cost-aware**:
|
|
153
|
+
the objective gains a name-specific linear turnover penalty and, when
|
|
154
|
+
``capital`` is given, the √-impact term. Cost coefficients are *annualized* to
|
|
155
|
+
the same units as the (annualized) alpha by dividing the one-way rate by
|
|
156
|
+
``holding_period_years`` - matching the cost model's alpha-haircut and the
|
|
157
|
+
ex-post cost drag. Without a cost model the solve is cost-blind (unchanged).
|
|
158
|
+
|
|
159
|
+
Pass ``benchmark_weights`` (``w_B``) to make the solve **benchmark-aware**:
|
|
160
|
+
risk and expected return are measured in *active* space
|
|
161
|
+
(``w_a = w − w_B``) rather than against cash, alpha is neutralized against
|
|
162
|
+
the Σ-implied benchmark beta (``αᵀw_B = 0``) so the optimizer carries no
|
|
163
|
+
implicit benchmark-timing view, and ``predicted_tracking_error`` becomes the
|
|
164
|
+
real thing instead of ``√(wᵀΣw)`` on a cash-relative book. Cost stays
|
|
165
|
+
anchored at ``w₀`` (current holdings) - risk and cost intentionally read
|
|
166
|
+
two different reference points. Without ``benchmark_weights`` (or with an
|
|
167
|
+
all-zero one) every quantity below reduces byte-for-byte to the cash-relative
|
|
168
|
+
behavior - the same "cost-blind reduces to today" pattern as the cost-aware solve.
|
|
169
|
+
|
|
170
|
+
Pass ``book="market_neutral"`` to relax the long-only box
|
|
171
|
+
``[0, cap]`` to ``[−short_max_weight, cap]`` and the budget from ``Σw = 1``
|
|
172
|
+
to ``Σw = 0``. ``gross_leverage`` (``‖w‖₁ ≤ L``) is then *mandatory* - an
|
|
173
|
+
unconstrained long/short mean-variance book on a noisy Σ is a leverage
|
|
174
|
+
machine (error maximization un-truncated by the long-only bound) - and
|
|
175
|
+
``cost_inputs.borrow`` prices a per-name annualized carry rate on the short
|
|
176
|
+
side only, composing with the turnover cost as a second, asymmetric kink at
|
|
177
|
+
``w = 0`` (see :meth:`_prox_step_short`). ``book="long_only"`` (the default)
|
|
178
|
+
runs the exact prior code path, so every existing result is unaffected.
|
|
179
|
+
|
|
180
|
+
Returns the weights, the diagnostics (``IR*``, predicted TE/IR, transfer
|
|
181
|
+
coefficient, value added, turnover, and - when cost-aware - the linear/impact
|
|
182
|
+
cost split and net expected return; when benchmark-aware, the active-beta /
|
|
183
|
+
residual-risk split; when market-neutral, the dollar-neutral residual and
|
|
184
|
+
realized gross leverage), and a feasibility verdict naming the binding
|
|
185
|
+
constraint when infeasible.
|
|
186
|
+
"""
|
|
187
|
+
if book == "market_neutral":
|
|
188
|
+
if gross_leverage is None:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"gross_leverage is mandatory for book='market_neutral' - an "
|
|
191
|
+
"unconstrained long/short book on a noisy Σ is a leverage machine "
|
|
192
|
+
"- pass an explicit ‖w‖₁ cap"
|
|
193
|
+
)
|
|
194
|
+
if short_max_weight <= 0:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
"short_max_weight must be > 0 for book='market_neutral' - "
|
|
197
|
+
"otherwise no name can be shorted and Σw=0 forces an all-cash book"
|
|
198
|
+
)
|
|
199
|
+
elif book != "long_only":
|
|
200
|
+
raise ValueError(f"book must be 'long_only' or 'market_neutral', got {book!r}")
|
|
201
|
+
|
|
202
|
+
symbols, alpha = self._align(alphas, risk)
|
|
203
|
+
if len(symbols) == 0:
|
|
204
|
+
return PortfolioResult(weights={}, feasible=False, binding_constraint="empty universe")
|
|
205
|
+
|
|
206
|
+
sigma = self._submatrix(risk, symbols)
|
|
207
|
+
n = len(symbols)
|
|
208
|
+
|
|
209
|
+
if book == "long_only":
|
|
210
|
+
# Cardinality + box can make the budget unreachable: name the binding bound.
|
|
211
|
+
shortfall = long_only_cap_shortfall(n, self.max_weight, self.max_names)
|
|
212
|
+
if shortfall is not None:
|
|
213
|
+
return PortfolioResult(weights={}, feasible=False, binding_constraint=shortfall)
|
|
214
|
+
|
|
215
|
+
sigma_inv = np.linalg.inv(sigma)
|
|
216
|
+
|
|
217
|
+
# Benchmark portfolio: β = Σw_B/(w_Bᵀ Σ w_B) is the one canonical
|
|
218
|
+
# beta - zero vector (and sigma_b2 = 0) when there is no benchmark, so
|
|
219
|
+
# every step below is a no-op and this reduces exactly to the cash-relative
|
|
220
|
+
# solve. Names in benchmark_weights but absent from `symbols` are silently
|
|
221
|
+
# excluded by `_vector` - the caller is responsible for
|
|
222
|
+
# restricting/renormalizing w_B to Σ's covered universe before calling in.
|
|
223
|
+
w_b = self._vector(benchmark_weights or {}, symbols)
|
|
224
|
+
sigma_b2 = float(w_b @ sigma @ w_b)
|
|
225
|
+
has_benchmark = sigma_b2 > 0
|
|
226
|
+
beta = (sigma @ w_b) / sigma_b2 if has_benchmark else np.zeros(n)
|
|
227
|
+
# α ← α − β·(αᵀw_B): after this, αᵀw_B = 0 exactly - no incentive to
|
|
228
|
+
# lean the book long/short of the benchmark; only constraints can now
|
|
229
|
+
# produce a nonzero active beta (surfaced below, not fought here).
|
|
230
|
+
alpha_neutral = alpha - beta * float(alpha @ w_b) if has_benchmark else alpha
|
|
231
|
+
|
|
232
|
+
ir_star = float(np.sqrt(max(alpha_neutral @ sigma_inv @ alpha_neutral, 0.0)))
|
|
233
|
+
|
|
234
|
+
lam = self._risk_aversion(ir_star, target_te, risk_aversion)
|
|
235
|
+
# The unconstrained total holding: benchmark plus the unconstrained active
|
|
236
|
+
# bet - w_B + Σ⁻¹α_neutral/2λ. Reduces to the cash-relative closed
|
|
237
|
+
# form exactly when w_B = 0.
|
|
238
|
+
unconstrained = w_b + (sigma_inv @ alpha_neutral) / (2.0 * lam)
|
|
239
|
+
|
|
240
|
+
w0 = self._vector(current_weights or {}, symbols)
|
|
241
|
+
# Per-name cost coefficients (annualized), aligned to `symbols`. Both zero when
|
|
242
|
+
# cost-blind -> the solve reduces exactly to the cost-blind projected gradient.
|
|
243
|
+
c_lin, k_imp = self._cost_coefficients(
|
|
244
|
+
cost_model, cost_inputs, capital, holding_period_years, symbols
|
|
245
|
+
)
|
|
246
|
+
cost_aware = bool(np.any(c_lin > 0) or np.any(k_imp > 0))
|
|
247
|
+
|
|
248
|
+
# Risk anchored at the benchmark, cost anchored at w₀:
|
|
249
|
+
# absorb the whole w_B risk-anchor effect into a shifted alpha so the
|
|
250
|
+
# existing box/budget/cost solver runs completely unchanged (the "two
|
|
251
|
+
# reference points, one solver" trick - see the module docstring's cost
|
|
252
|
+
# term for the analogous pattern. Shift is exactly zero when there
|
|
253
|
+
# is no benchmark.
|
|
254
|
+
alpha_shifted = alpha_neutral + 2.0 * lam * (sigma @ w_b) if has_benchmark else alpha_neutral
|
|
255
|
+
|
|
256
|
+
borrow = None
|
|
257
|
+
if book == "long_only":
|
|
258
|
+
w = self._constrained_solve(alpha_shifted, sigma, lam, n, c_lin, k_imp, w0)
|
|
259
|
+
else:
|
|
260
|
+
borrow = self._borrow_coefficients(cost_model, cost_inputs, symbols)
|
|
261
|
+
w = self._constrained_solve_market_neutral(
|
|
262
|
+
alpha_shifted, sigma, lam, n, c_lin, k_imp, borrow, w0, short_max_weight, gross_leverage
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Manual no-trade band (cost-free override): if every name moves less than the
|
|
266
|
+
# band, don't churn. When cost-aware the band instead *emerges* from c_lin (a
|
|
267
|
+
# name stays at w₀ⁱ while its marginal value is inside ±cᵢ), so this is a no-op.
|
|
268
|
+
if self.no_trade_band > 0 and np.max(np.abs(w - w0)) < self.no_trade_band:
|
|
269
|
+
w = w0.copy()
|
|
270
|
+
|
|
271
|
+
diagnostics = self._diagnostics(
|
|
272
|
+
alpha_neutral, sigma, w, w0, lam, ir_star, c_lin, k_imp, cost_aware, w_b, beta, sigma_b2
|
|
273
|
+
)
|
|
274
|
+
if book == "market_neutral":
|
|
275
|
+
# Dollar- vs beta-neutrality: Σw and
|
|
276
|
+
# (when a benchmark/β source is supplied) βᵀw_a are different residuals
|
|
277
|
+
# that diverge whenever betas are dispersed - `active_beta` above already
|
|
278
|
+
# reports the latter via the one canonical β (implied_beta), reused
|
|
279
|
+
# rather than redefined; this block adds the former plus the mandatory
|
|
280
|
+
# leverage accounting and the short-side borrow carry.
|
|
281
|
+
borrow_cost = float(np.sum(borrow * np.maximum(-w, 0.0)))
|
|
282
|
+
diagnostics.update(
|
|
283
|
+
{
|
|
284
|
+
"book": "market_neutral",
|
|
285
|
+
"dollar_neutral_residual": float(abs(np.sum(w))),
|
|
286
|
+
"gross_leverage": float(np.sum(np.abs(w))),
|
|
287
|
+
"gross_leverage_cap": float(gross_leverage),
|
|
288
|
+
"borrow_cost": borrow_cost,
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
if "expected_active_return_net" in diagnostics:
|
|
292
|
+
diagnostics["expected_active_return_net"] -= borrow_cost
|
|
293
|
+
diagnostics["expected_active_return_net_oneway"] -= borrow_cost
|
|
294
|
+
|
|
295
|
+
return PortfolioResult(
|
|
296
|
+
weights={s: float(w[i]) for i, s in enumerate(symbols) if abs(w[i]) > 1e-9},
|
|
297
|
+
feasible=True,
|
|
298
|
+
diagnostics=diagnostics,
|
|
299
|
+
unconstrained_weights={s: float(unconstrained[i]) for i, s in enumerate(symbols)},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------ #
|
|
303
|
+
# Cost coefficients
|
|
304
|
+
# ------------------------------------------------------------------ #
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _cost_coefficients(cost_model, cost_inputs, capital, holding_period_years, symbols):
|
|
307
|
+
"""Build the annualized per-name linear (``cᵢ``) and √-impact (``kᵢ``) vectors.
|
|
308
|
+
|
|
309
|
+
``cᵢ = turnover_cost_rate(spreadᵢ) / h`` (one-way commission+half-spread, per
|
|
310
|
+
year); ``kᵢ = impact_coefficient(σᵢ, ADV$ᵢ, capital) / h`` (per year), and
|
|
311
|
+
``kᵢ = 0`` when ``capital`` is absent - the "ship linear first" default. Zero
|
|
312
|
+
vectors when no cost model is supplied.
|
|
313
|
+
"""
|
|
314
|
+
n = len(symbols)
|
|
315
|
+
c_lin = np.zeros(n)
|
|
316
|
+
k_imp = np.zeros(n)
|
|
317
|
+
if cost_model is None or cost_inputs is None:
|
|
318
|
+
return c_lin, k_imp
|
|
319
|
+
h = max(holding_period_years, 1e-9)
|
|
320
|
+
have_capital = capital is not None and capital > 0
|
|
321
|
+
for i, s in enumerate(symbols):
|
|
322
|
+
spread = cost_inputs.spread.get(s)
|
|
323
|
+
c_lin[i] = cost_model.turnover_cost_rate(spread) / h
|
|
324
|
+
if have_capital:
|
|
325
|
+
adv_dollar = cost_inputs.adv_dollar.get(s, 0.0)
|
|
326
|
+
daily_vol = cost_inputs.daily_vol.get(s, 0.0)
|
|
327
|
+
k_imp[i] = cost_model.impact_coefficient(daily_vol, adv_dollar, capital) / h
|
|
328
|
+
# A non-finite input (e.g. a NaN spread proxy) must drop that name's cost term,
|
|
329
|
+
# not poison the whole solve with a NaN coefficient (which would empty the book).
|
|
330
|
+
c_lin = np.where(np.isfinite(c_lin), c_lin, 0.0)
|
|
331
|
+
k_imp = np.where(np.isfinite(k_imp), k_imp, 0.0)
|
|
332
|
+
return c_lin, k_imp
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _borrow_coefficients(cost_model, cost_inputs, symbols) -> np.ndarray:
|
|
336
|
+
"""Per-name annualized borrow rate: ``CostInputs.borrow``
|
|
337
|
+
overrides the cost model's flat default, threaded the same way
|
|
338
|
+
:meth:`_cost_coefficients` threads spread/ADV/vol. Already annualized (no
|
|
339
|
+
``/h`` - it is a per-year *holding* rate, not a one-way turnover rate); zero
|
|
340
|
+
when there is no cost model.
|
|
341
|
+
"""
|
|
342
|
+
n = len(symbols)
|
|
343
|
+
borrow = np.zeros(n)
|
|
344
|
+
if cost_model is None:
|
|
345
|
+
return borrow
|
|
346
|
+
overrides = cost_inputs.borrow if cost_inputs is not None else {}
|
|
347
|
+
for i, s in enumerate(symbols):
|
|
348
|
+
borrow[i] = cost_model.borrow_rate(overrides.get(s))
|
|
349
|
+
return np.where(np.isfinite(borrow), borrow, 0.0)
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------ #
|
|
352
|
+
# Solve
|
|
353
|
+
# ------------------------------------------------------------------ #
|
|
354
|
+
def _constrained_solve(self, alpha, sigma, lam, n, c_lin, k_imp, w0) -> np.ndarray:
|
|
355
|
+
"""Box+budget convex program, plus the two non-convex constraints by re-solving.
|
|
356
|
+
|
|
357
|
+
Cardinality (``‖w‖₀ ≤ max_names``) and the semi-continuous dust floor
|
|
358
|
+
(``w ∈ {0} ∪ [min_weight, max_weight]``) are both non-convex, so each is
|
|
359
|
+
enforced the same pragmatic way: solve the convex (now cost-aware) program, drop
|
|
360
|
+
the offending names (the smallest beyond the cardinality cap, or any stuck in
|
|
361
|
+
the ``(0, min_weight)`` hole), and re-solve on the survivors until stable. A
|
|
362
|
+
dropped held name is a full liquidation of ``w₀ⁱ``; its cost is captured in the
|
|
363
|
+
final diagnostics, which price ``w − w₀`` over the full universe.
|
|
364
|
+
"""
|
|
365
|
+
active = np.arange(n)
|
|
366
|
+
w = self._solve(alpha, sigma, lam, active, c_lin, k_imp, w0)
|
|
367
|
+
|
|
368
|
+
if self.max_names is not None and int(np.sum(w > 1e-9)) > self.max_names:
|
|
369
|
+
active = np.argsort(w)[::-1][: self.max_names]
|
|
370
|
+
w = self._solve(alpha, sigma, lam, active, c_lin, k_imp, w0)
|
|
371
|
+
|
|
372
|
+
# Dust floor: drop held names below min_weight and re-solve, while the budget
|
|
373
|
+
# stays fundable (enough remaining names at max_weight to reach 1).
|
|
374
|
+
while self.min_weight > 0:
|
|
375
|
+
held = np.where(w > 1e-9)[0]
|
|
376
|
+
keep = held[w[held] >= self.min_weight]
|
|
377
|
+
if len(keep) == len(held):
|
|
378
|
+
break # every held name clears the floor
|
|
379
|
+
if len(keep) == 0 or len(keep) * self.max_weight < 1.0 - 1e-9:
|
|
380
|
+
break # dropping further would make the budget infeasible
|
|
381
|
+
w = self._solve(alpha, sigma, lam, keep, c_lin, k_imp, w0)
|
|
382
|
+
return w
|
|
383
|
+
|
|
384
|
+
def _solve(self, alpha, sigma, lam, active, c_lin, k_imp, w0) -> np.ndarray:
|
|
385
|
+
"""Proximal-gradient solve over the active names; full-length weight vector out.
|
|
386
|
+
|
|
387
|
+
Minimizes ``f(w) + g(w)`` with smooth ``f = −αᵀw + λwᵀΣw`` and nonsmooth convex
|
|
388
|
+
``g = Σ cᵢ|wᵢ−w₀ⁱ| + Σ kᵢ|wᵢ−w₀ⁱ|^{3/2} + ι_{box∩budget}``. Each iteration is a
|
|
389
|
+
gradient step on ``f`` followed by the exact proximal operator of ``g`` (see
|
|
390
|
+
:meth:`_prox_project`). Step ``1/L`` with ``L = 2λ·λmax(Σ)`` guarantees
|
|
391
|
+
convergence; ``λmin(Σ) > 0`` makes ``f`` strongly convex, so it is linear.
|
|
392
|
+
"""
|
|
393
|
+
n = len(alpha)
|
|
394
|
+
a = alpha[active]
|
|
395
|
+
s = sigma[np.ix_(active, active)]
|
|
396
|
+
c = c_lin[active]
|
|
397
|
+
k = k_imp[active]
|
|
398
|
+
w0a = w0[active]
|
|
399
|
+
# Lipschitz constant of ∇f = -a + 2λ·s·w is 2λ·λmax(s); step = 1/L.
|
|
400
|
+
lmax = float(np.linalg.eigvalsh(s).max())
|
|
401
|
+
step = 1.0 / (2.0 * lam * lmax) if lmax > 0 else 1.0
|
|
402
|
+
thr = step * c
|
|
403
|
+
kthr = step * k
|
|
404
|
+
# Warm-start from w₀ (projected) when it holds weight, else uniform.
|
|
405
|
+
start = w0a if w0a.sum() > 1e-9 else np.ones(len(active)) / len(active)
|
|
406
|
+
w = self._prox_project(start, np.zeros_like(thr), np.zeros_like(kthr), w0a)
|
|
407
|
+
for _ in range(self.max_iter):
|
|
408
|
+
grad = -a + 2.0 * lam * (s @ w)
|
|
409
|
+
nxt = self._prox_project(w - step * grad, thr, kthr, w0a)
|
|
410
|
+
if np.max(np.abs(nxt - w)) < self.tol:
|
|
411
|
+
w = nxt
|
|
412
|
+
break
|
|
413
|
+
w = nxt
|
|
414
|
+
full = np.zeros(n)
|
|
415
|
+
full[active] = w
|
|
416
|
+
return full
|
|
417
|
+
|
|
418
|
+
def _prox_project(self, y: np.ndarray, thr: np.ndarray, kthr: np.ndarray, w0: np.ndarray) -> np.ndarray:
|
|
419
|
+
"""Exact prox of the cost + capped-simplex indicator, via a budget bisection.
|
|
420
|
+
|
|
421
|
+
Solves ``argmin_w ½‖w−y‖² + Σ thrᵢ|wᵢ−w₀ⁱ| + Σ kthrᵢ|wᵢ−w₀ⁱ|^{3/2}`` over
|
|
422
|
+
``{0 ≤ w ≤ cap, Σw = 1}``. With a single dual ``τ`` for the budget it separates
|
|
423
|
+
per coordinate: ``wᵢ(τ) = clip(w₀ⁱ + d*((yᵢ−τ)−w₀ⁱ), 0, cap)`` where ``d*`` is
|
|
424
|
+
the per-name proximal step (:meth:`_prox_step`). Each ``wᵢ(τ)`` is monotone
|
|
425
|
+
non-increasing in ``τ``, so ``Σwᵢ(τ) = 1`` is found by bisection - the same
|
|
426
|
+
1-D structure the cost-free projection used. With ``thr = kthr = 0`` the
|
|
427
|
+
coordinate map is ``clip(yᵢ − τ, 0, cap)`` and this is exactly that projection.
|
|
428
|
+
"""
|
|
429
|
+
cap = self.max_weight
|
|
430
|
+
|
|
431
|
+
def total(tau: float) -> float:
|
|
432
|
+
m = (y - tau) - w0
|
|
433
|
+
return float(np.clip(w0 + self._prox_step(m, thr, kthr), 0.0, cap).sum())
|
|
434
|
+
|
|
435
|
+
pad = float(thr.max()) if thr.size else 0.0
|
|
436
|
+
lo = float(y.min() - cap - pad)
|
|
437
|
+
hi = float(y.max() + pad)
|
|
438
|
+
# Guarantee the budget root is bracketed (total is monotone non-increasing in τ).
|
|
439
|
+
guard = 0
|
|
440
|
+
while total(lo) < 1.0 and guard < 200:
|
|
441
|
+
lo -= abs(lo) + 1.0
|
|
442
|
+
guard += 1
|
|
443
|
+
guard = 0
|
|
444
|
+
while total(hi) > 1.0 and guard < 200:
|
|
445
|
+
hi += abs(hi) + 1.0
|
|
446
|
+
guard += 1
|
|
447
|
+
for _ in range(100):
|
|
448
|
+
tau = 0.5 * (lo + hi)
|
|
449
|
+
if total(tau) > 1.0:
|
|
450
|
+
lo = tau
|
|
451
|
+
else:
|
|
452
|
+
hi = tau
|
|
453
|
+
tau = 0.5 * (lo + hi)
|
|
454
|
+
m = (y - tau) - w0
|
|
455
|
+
return np.clip(w0 + self._prox_step(m, thr, kthr), 0.0, cap)
|
|
456
|
+
|
|
457
|
+
@staticmethod
|
|
458
|
+
def _prox_step(m: np.ndarray, thr: np.ndarray, kthr: np.ndarray) -> np.ndarray:
|
|
459
|
+
"""Per-name trade ``d* = argmin_d ½(d−m)² + thr|d| + kthr|d|^{3/2}`` (unclipped).
|
|
460
|
+
|
|
461
|
+
Closed form: ``d* = 0`` when ``|m| ≤ thr`` (the emergent no-trade band - a name
|
|
462
|
+
is untraded while its move is inside the linear cost); otherwise, with residual
|
|
463
|
+
``r = |m| − thr`` and ``b = 3·kthr/2``, the magnitude solves ``u² + b·u = r``
|
|
464
|
+
(``u = √|d|``), i.e. ``u = (−b + √(b² + 4r))/2`` and ``d* = sign(m)·u²``. With
|
|
465
|
+
``kthr = 0`` this is the ordinary soft-threshold ``sign(m)·max(|m|−thr, 0)``.
|
|
466
|
+
"""
|
|
467
|
+
residual = np.maximum(np.abs(m) - thr, 0.0)
|
|
468
|
+
b = 1.5 * kthr
|
|
469
|
+
u = (-b + np.sqrt(b * b + 4.0 * residual)) / 2.0
|
|
470
|
+
return np.sign(m) * u * u
|
|
471
|
+
|
|
472
|
+
# ------------------------------------------------------------------ #
|
|
473
|
+
# Market-neutral solve - a separate path from the long-only one
|
|
474
|
+
# above; book="long_only" never touches any of this.
|
|
475
|
+
# ------------------------------------------------------------------ #
|
|
476
|
+
def _constrained_solve_market_neutral(
|
|
477
|
+
self, alpha, sigma, lam, n, c_lin, k_imp, borrow, w0, short_cap, gross_leverage
|
|
478
|
+
) -> np.ndarray:
|
|
479
|
+
"""Box ``[−short_cap, cap]`` + budget ``Σw=0`` + gross-leverage program, plus
|
|
480
|
+
cardinality/dust-floor by re-solving - the same re-solve pattern as
|
|
481
|
+
:meth:`_constrained_solve`, generalized to magnitude (``|w|``) since a large
|
|
482
|
+
short is as much a "position" as a large long.
|
|
483
|
+
"""
|
|
484
|
+
active = np.arange(n)
|
|
485
|
+
w = self._solve_leveraged(
|
|
486
|
+
alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, gross_leverage
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if self.max_names is not None and int(np.sum(np.abs(w) > 1e-9)) > self.max_names:
|
|
490
|
+
active = np.argsort(np.abs(w))[::-1][: self.max_names]
|
|
491
|
+
w = self._solve_leveraged(
|
|
492
|
+
alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, gross_leverage
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
while self.min_weight > 0:
|
|
496
|
+
held = np.where(np.abs(w) > 1e-9)[0]
|
|
497
|
+
keep = held[np.abs(w[held]) >= self.min_weight]
|
|
498
|
+
if len(keep) == len(held):
|
|
499
|
+
break # every held name clears the floor
|
|
500
|
+
if len(keep) == 0:
|
|
501
|
+
return np.zeros(n) # dust floor emptied the book - a valid (if useless) all-cash result
|
|
502
|
+
active = keep
|
|
503
|
+
w = self._solve_leveraged(
|
|
504
|
+
alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, gross_leverage
|
|
505
|
+
)
|
|
506
|
+
return w
|
|
507
|
+
|
|
508
|
+
def _solve_leveraged(
|
|
509
|
+
self, alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, gross_leverage
|
|
510
|
+
) -> np.ndarray:
|
|
511
|
+
"""The outer gross-leverage dual: solve the box/budget/
|
|
512
|
+
borrow program at ``μ=0`` first: since the leverage cap "only sometimes
|
|
513
|
+
binds", most solves stop here. Only when ‖w‖₁ already exceeds the cap does
|
|
514
|
+
the outer bisection on ``μ≥0`` run - each trial ``μ`` re-solves the *entire*
|
|
515
|
+
inner proximal-gradient program to convergence ("one loop deeper" than the
|
|
516
|
+
long-only path). Monotonicity of ‖w(μ)‖₁ (a stiffer zero-anchored threshold
|
|
517
|
+
can only shrink every name toward 0) is verified empirically by the KKT
|
|
518
|
+
certificate test, not just assumed - flagged as an open risk to revisit.
|
|
519
|
+
"""
|
|
520
|
+
w = self._solve_market_neutral(alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, 0.0)
|
|
521
|
+
gross = float(np.sum(np.abs(w)))
|
|
522
|
+
if gross <= gross_leverage + 1e-9:
|
|
523
|
+
return w
|
|
524
|
+
|
|
525
|
+
def gross_at(mu: float) -> float:
|
|
526
|
+
wm = self._solve_market_neutral(
|
|
527
|
+
alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, mu
|
|
528
|
+
)
|
|
529
|
+
return float(np.sum(np.abs(wm)))
|
|
530
|
+
|
|
531
|
+
lo_mu, hi_mu = 0.0, 1.0
|
|
532
|
+
guard = 0
|
|
533
|
+
while gross_at(hi_mu) > gross_leverage and guard < 60:
|
|
534
|
+
hi_mu *= 2.0
|
|
535
|
+
guard += 1
|
|
536
|
+
for _ in range(40):
|
|
537
|
+
mid = 0.5 * (lo_mu + hi_mu)
|
|
538
|
+
if gross_at(mid) > gross_leverage:
|
|
539
|
+
lo_mu = mid
|
|
540
|
+
else:
|
|
541
|
+
hi_mu = mid
|
|
542
|
+
return self._solve_market_neutral(
|
|
543
|
+
alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, 0.5 * (lo_mu + hi_mu)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def _solve_market_neutral(
|
|
547
|
+
self, alpha, sigma, lam, active, c_lin, k_imp, borrow, w0, short_cap, mu
|
|
548
|
+
) -> np.ndarray:
|
|
549
|
+
"""Proximal-gradient solve at a FIXED leverage dual ``μ`` - otherwise the same
|
|
550
|
+
iteration structure as :meth:`_solve`, over the market-neutral box/budget."""
|
|
551
|
+
n = len(alpha)
|
|
552
|
+
if len(active) == 0:
|
|
553
|
+
return np.zeros(n)
|
|
554
|
+
a = alpha[active]
|
|
555
|
+
s = sigma[np.ix_(active, active)]
|
|
556
|
+
c = c_lin[active]
|
|
557
|
+
k = k_imp[active]
|
|
558
|
+
b = borrow[active]
|
|
559
|
+
w0a = w0[active]
|
|
560
|
+
cap = self.max_weight
|
|
561
|
+
lower = -short_cap
|
|
562
|
+
lmax = float(np.linalg.eigvalsh(s).max())
|
|
563
|
+
step = 1.0 / (2.0 * lam * lmax) if lmax > 0 else 1.0
|
|
564
|
+
thr = step * c
|
|
565
|
+
kthr = step * k
|
|
566
|
+
bthr = step * b
|
|
567
|
+
# Warm-start via a cost-blind box+budget projection (mu=0), same spirit as _solve.
|
|
568
|
+
start = w0a if np.abs(w0a).sum() > 1e-9 else np.zeros(len(active))
|
|
569
|
+
w = self._prox_project_market_neutral(
|
|
570
|
+
start, np.zeros_like(thr), np.zeros_like(kthr), np.zeros_like(bthr), 0.0, w0a, lower, cap
|
|
571
|
+
)
|
|
572
|
+
for _ in range(self.max_iter):
|
|
573
|
+
grad = -a + 2.0 * lam * (s @ w)
|
|
574
|
+
nxt = self._prox_project_market_neutral(w - step * grad, thr, kthr, bthr, mu, w0a, lower, cap)
|
|
575
|
+
if np.max(np.abs(nxt - w)) < self.tol:
|
|
576
|
+
w = nxt
|
|
577
|
+
break
|
|
578
|
+
w = nxt
|
|
579
|
+
full = np.zeros(n)
|
|
580
|
+
full[active] = w
|
|
581
|
+
return full
|
|
582
|
+
|
|
583
|
+
def _prox_project_market_neutral(self, y, thr, kthr, borrow, mu, w0, lower, cap) -> np.ndarray:
|
|
584
|
+
"""Budget-dual (Σw=0) bisection over the market-neutral box, at a fixed
|
|
585
|
+
leverage dual ``μ`` - mirrors :meth:`_prox_project` exactly, just with
|
|
586
|
+
budget 0 instead of 1, box ``[lower, cap]`` instead of ``[0, cap]``, and the
|
|
587
|
+
short/leverage-aware per-name step (:meth:`_prox_step_short`).
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
def total(tau: float) -> float:
|
|
591
|
+
w = self._prox_step_short(y - tau, thr, kthr, borrow, mu, w0)
|
|
592
|
+
return float(np.clip(w, lower, cap).sum())
|
|
593
|
+
|
|
594
|
+
pad = float(thr.max()) if thr.size else 0.0
|
|
595
|
+
lo = float(y.min() - cap - pad)
|
|
596
|
+
hi = float(y.max() + pad)
|
|
597
|
+
guard = 0
|
|
598
|
+
while total(lo) < 0.0 and guard < 200:
|
|
599
|
+
lo -= abs(lo) + 1.0
|
|
600
|
+
guard += 1
|
|
601
|
+
guard = 0
|
|
602
|
+
while total(hi) > 0.0 and guard < 200:
|
|
603
|
+
hi += abs(hi) + 1.0
|
|
604
|
+
guard += 1
|
|
605
|
+
for _ in range(100):
|
|
606
|
+
tau = 0.5 * (lo + hi)
|
|
607
|
+
if total(tau) > 0.0:
|
|
608
|
+
lo = tau
|
|
609
|
+
else:
|
|
610
|
+
hi = tau
|
|
611
|
+
tau = 0.5 * (lo + hi)
|
|
612
|
+
w = self._prox_step_short(y - tau, thr, kthr, borrow, mu, w0)
|
|
613
|
+
return np.clip(w, lower, cap)
|
|
614
|
+
|
|
615
|
+
@classmethod
|
|
616
|
+
def _prox_step_short(cls, m, thr, kthr, borrow, mu, w0) -> np.ndarray:
|
|
617
|
+
"""Per-name proximal step for the market-neutral book: minimizes, over ``w``,
|
|
618
|
+
|
|
619
|
+
½(w−y)² + cᵢ|w−w₀ⁱ| + kᵢ|w−w₀ⁱ|^{3/2} + borrowᵢ·max(−w,0) + μ|w|
|
|
620
|
+
|
|
621
|
+
(``y = m + w₀``, ``μ`` the leverage dual). Unlike :meth:`_prox_step` this
|
|
622
|
+
has *two* kinks - the turnover kink at ``w = w₀`` and a second, zero-anchored
|
|
623
|
+
one where the short-side borrow and the (symmetric) leverage threshold turn
|
|
624
|
+
on at ``w = 0``. The function is still convex and piecewise, so the true
|
|
625
|
+
minimizer is one of a small set of closed-form candidates: the unconstrained
|
|
626
|
+
stationary point of each of the (up to 4) smooth pieces the two kinks carve
|
|
627
|
+
out, plus the two kinks themselves. Evaluating the objective at all six and
|
|
628
|
+
keeping the best is exact (no iterative search) and, unlike hand-selecting
|
|
629
|
+
which piece is "active", needs no case analysis on the sign of ``w₀`` -
|
|
630
|
+
convexity guarantees the argmin is correct regardless of which candidates
|
|
631
|
+
happen to be infeasible for their own piece.
|
|
632
|
+
|
|
633
|
+
With ``borrow = μ = 0`` the six candidates collapse to exactly
|
|
634
|
+
:meth:`_prox_step`'s two branches (plus dominated duplicates), but this
|
|
635
|
+
method is never called from the long-only path, so that path's numerics are
|
|
636
|
+
untouched.
|
|
637
|
+
"""
|
|
638
|
+
w1 = w0 + cls._branch_pos(m, thr + mu, kthr) # region w >= max(w0, 0)
|
|
639
|
+
w2 = w0 + cls._branch_neg(m, thr + borrow + mu, kthr) # region w <= min(w0, 0)
|
|
640
|
+
w3 = np.where(w0 > 0, w0 + cls._branch_neg(m, thr - mu, kthr), w0) # region 0 <= w <= w0 (w0>0 only)
|
|
641
|
+
w4 = np.where(
|
|
642
|
+
w0 < 0, w0 + cls._branch_pos(m, thr - borrow - mu, kthr), w0
|
|
643
|
+
) # region w0 <= w <= 0 (w0<0 only)
|
|
644
|
+
zeros = np.zeros_like(w0)
|
|
645
|
+
candidates = np.stack([w1, w2, w3, w4, zeros, w0])
|
|
646
|
+
|
|
647
|
+
y_eff = m + w0
|
|
648
|
+
dw = candidates - w0
|
|
649
|
+
obj = (
|
|
650
|
+
0.5 * (candidates - y_eff) ** 2
|
|
651
|
+
+ thr * np.abs(dw)
|
|
652
|
+
+ kthr * np.abs(dw) ** 1.5
|
|
653
|
+
+ borrow * np.maximum(-candidates, 0.0)
|
|
654
|
+
+ mu * np.abs(candidates)
|
|
655
|
+
)
|
|
656
|
+
best = np.argmin(obj, axis=0)
|
|
657
|
+
return np.take_along_axis(candidates, best[None, :], axis=0)[0]
|
|
658
|
+
|
|
659
|
+
@staticmethod
|
|
660
|
+
def _branch_pos(m, thr, kthr) -> np.ndarray:
|
|
661
|
+
"""Stationary point (``d = w−w₀ ≥ 0`` branch, extended to all reals) of
|
|
662
|
+
``½(d−m)² + thr·d + kthr·d^{3/2}`` - the same closed form as
|
|
663
|
+
:meth:`_prox_step`'s ``m ≥ 0`` branch, factored out so :meth:`_prox_step_short`
|
|
664
|
+
can reuse it with a shifted threshold (borrow/leverage add to ``thr`` here).
|
|
665
|
+
"""
|
|
666
|
+
b = 1.5 * kthr
|
|
667
|
+
residual = np.maximum(m - thr, 0.0)
|
|
668
|
+
u = (-b + np.sqrt(b * b + 4.0 * residual)) / 2.0
|
|
669
|
+
return u * u
|
|
670
|
+
|
|
671
|
+
@staticmethod
|
|
672
|
+
def _branch_neg(m, thr, kthr) -> np.ndarray:
|
|
673
|
+
"""Stationary point (``d = w−w₀ ≤ 0`` branch) - the mirror of
|
|
674
|
+
:meth:`_branch_pos`, matching :meth:`_prox_step`'s ``m < 0`` branch."""
|
|
675
|
+
b = 1.5 * kthr
|
|
676
|
+
residual = np.maximum(-(m + thr), 0.0)
|
|
677
|
+
u = (-b + np.sqrt(b * b + 4.0 * residual)) / 2.0
|
|
678
|
+
return -(u * u)
|
|
679
|
+
|
|
680
|
+
# ------------------------------------------------------------------ #
|
|
681
|
+
# Calibration & diagnostics
|
|
682
|
+
# ------------------------------------------------------------------ #
|
|
683
|
+
def _risk_aversion(self, ir_star, target_te, risk_aversion) -> float:
|
|
684
|
+
if risk_aversion is not None:
|
|
685
|
+
return float(risk_aversion)
|
|
686
|
+
if target_te is not None and target_te > 0 and ir_star > 0:
|
|
687
|
+
return ir_star / (2.0 * target_te) # λ_A = IR* / (2·ψ_target)
|
|
688
|
+
return 1.0 # neutral default when neither is usable
|
|
689
|
+
|
|
690
|
+
def _diagnostics(
|
|
691
|
+
self, alpha, sigma, w, w0, lam, ir_star, c_lin, k_imp, cost_aware, w_b, beta, sigma_b2
|
|
692
|
+
) -> Dict[str, float]:
|
|
693
|
+
"""``alpha`` here is already benchmark-neutralized when ``w_b`` is nonzero -
|
|
694
|
+
the caller passes ``alpha_neutral``, never the raw forecast."""
|
|
695
|
+
# Active weights w_a = w - w_B. w_b is the zero vector without a
|
|
696
|
+
# benchmark, so w_a = w and every quantity below is the plain cash-relative
|
|
697
|
+
# one - unchanged.
|
|
698
|
+
w_a = w - w_b
|
|
699
|
+
sig_wa = sigma @ w_a
|
|
700
|
+
active_variance = float(w_a @ sig_wa)
|
|
701
|
+
te = float(np.sqrt(max(active_variance, 0.0))) # ψ, the real tracking error
|
|
702
|
+
expected = float(alpha @ w_a)
|
|
703
|
+
# Transfer coefficient: corr(α, Σ-adjusted active weights) ∈ [-1, 1].
|
|
704
|
+
tc = self._corr(alpha, sig_wa)
|
|
705
|
+
dw = w - w0 # cost is anchored at w₀, not w_B - unchanged
|
|
706
|
+
diagnostics = {
|
|
707
|
+
"ir_star": ir_star,
|
|
708
|
+
"risk_aversion": float(lam),
|
|
709
|
+
"expected_active_return": expected,
|
|
710
|
+
"predicted_tracking_error": te, # realized TE at the (cost-bent) optimum
|
|
711
|
+
"predicted_ir": expected / te if te > 0 else 0.0,
|
|
712
|
+
"transfer_coefficient": tc,
|
|
713
|
+
"ir_achieved": tc * ir_star,
|
|
714
|
+
"optimal_tracking_error": ir_star / (2.0 * lam) if lam > 0 else 0.0,
|
|
715
|
+
"value_added": (ir_star**2) / (4.0 * lam) if lam > 0 else 0.0,
|
|
716
|
+
"turnover": float(np.sum(np.abs(dw))),
|
|
717
|
+
}
|
|
718
|
+
if sigma_b2 > 0:
|
|
719
|
+
# ψ² = β_a²·σ_B² + ω²: the active-beta (benchmark-timing) share of
|
|
720
|
+
# tracking error vs the residual (stock-selection) share. β_a should be
|
|
721
|
+
# ~0 by construction (alpha is neutralized); a nonzero value here comes
|
|
722
|
+
# only from a binding constraint (box/cardinality), which is exactly what
|
|
723
|
+
# this diagnostic is for surfacing.
|
|
724
|
+
active_beta = float(beta @ w_a)
|
|
725
|
+
residual_risk = float(np.sqrt(max(te**2 - active_beta**2 * sigma_b2, 0.0)))
|
|
726
|
+
diagnostics.update(
|
|
727
|
+
{
|
|
728
|
+
"has_benchmark": True,
|
|
729
|
+
"benchmark_variance": sigma_b2,
|
|
730
|
+
"active_beta": active_beta,
|
|
731
|
+
"residual_risk": residual_risk,
|
|
732
|
+
# A benchmark equal to current holdings makes ψ measure "distance
|
|
733
|
+
# from myself" - legal but meaningless.
|
|
734
|
+
"self_benchmark_warning": bool(np.sum(np.abs(w0 - w_b)) < 1e-6),
|
|
735
|
+
}
|
|
736
|
+
)
|
|
737
|
+
if cost_aware:
|
|
738
|
+
# One-way cost of *this rebalance's* turnover - what the objective charged,
|
|
739
|
+
# kept for continuity with the prior ex-post drag. Detail.
|
|
740
|
+
linear_cost = float(np.sum(c_lin * np.abs(dw)))
|
|
741
|
+
impact_cost = float(np.sum(k_imp * np.abs(dw) ** 1.5))
|
|
742
|
+
rebalance_cost = linear_cost + impact_cost
|
|
743
|
+
# Round-trip haircut on the *held book* (the capacity convention):
|
|
744
|
+
# entering AND exiting each position, amortized over the holding period. This
|
|
745
|
+
# is the conservative headline net figure - the same cost model _capacity
|
|
746
|
+
# prices, so the net return and the capacity number agree.
|
|
747
|
+
book = np.abs(w)
|
|
748
|
+
round_trip_cost = float(2.0 * (np.sum(c_lin * book) + np.sum(k_imp * book**1.5)))
|
|
749
|
+
diagnostics.update(
|
|
750
|
+
{
|
|
751
|
+
"cost_aware": True,
|
|
752
|
+
"linear_cost": linear_cost, # one-way Σ cᵢ|Δwᵢ| (rebalance turnover)
|
|
753
|
+
"impact_cost": impact_cost, # one-way Σ kᵢ|Δwᵢ|^{3/2} (rebalance √-impact)
|
|
754
|
+
"cost_drag": rebalance_cost, # one-way rebalance total (continuity)
|
|
755
|
+
"round_trip_cost": round_trip_cost, # round-trip book haircut (headline)
|
|
756
|
+
"expected_active_return_net": expected - round_trip_cost, # headline: round-trip
|
|
757
|
+
"expected_active_return_net_oneway": expected - rebalance_cost, # detail: one-way
|
|
758
|
+
"names_traded": int(np.sum(np.abs(dw) > 1e-9)),
|
|
759
|
+
}
|
|
760
|
+
)
|
|
761
|
+
return diagnostics
|
|
762
|
+
|
|
763
|
+
# ------------------------------------------------------------------ #
|
|
764
|
+
# Alignment helpers
|
|
765
|
+
# ------------------------------------------------------------------ #
|
|
766
|
+
@staticmethod
|
|
767
|
+
def _align(alphas: List[Alpha], risk: RiskMatrix):
|
|
768
|
+
"""Common symbols (in Σ order) and the aligned alpha vector."""
|
|
769
|
+
by_symbol = {a.symbol: a.alpha for a in alphas}
|
|
770
|
+
symbols = [s for s in risk.symbols if s in by_symbol]
|
|
771
|
+
return symbols, np.array([by_symbol[s] for s in symbols], dtype=float)
|
|
772
|
+
|
|
773
|
+
@staticmethod
|
|
774
|
+
def _submatrix(risk: RiskMatrix, symbols: List[str]) -> np.ndarray:
|
|
775
|
+
idx = [risk.symbols.index(s) for s in symbols]
|
|
776
|
+
return risk.sigma[np.ix_(idx, idx)]
|
|
777
|
+
|
|
778
|
+
@staticmethod
|
|
779
|
+
def _vector(weights: Dict[str, float], symbols: List[str]) -> np.ndarray:
|
|
780
|
+
return np.array([weights.get(s, 0.0) for s in symbols], dtype=float)
|
|
781
|
+
|
|
782
|
+
@staticmethod
|
|
783
|
+
def _corr(a: np.ndarray, b: np.ndarray) -> float:
|
|
784
|
+
if len(a) < 2 or np.std(a) == 0 or np.std(b) == 0:
|
|
785
|
+
return 0.0
|
|
786
|
+
return float(np.corrcoef(a, b)[0, 1])
|