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