File size: 23,214 Bytes
fb36c27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2bab8b9
 
13b1da5
 
 
fb36c27
2bab8b9
 
 
 
8923fd6
 
 
 
6c814fb
fb36c27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85ef5de
 
 
 
 
 
 
 
6c814fb
 
85ef5de
 
 
 
 
 
 
 
 
 
6c814fb
 
 
 
 
fb36c27
 
 
 
 
6c814fb
fb36c27
 
2bab8b9
 
 
fb36c27
 
13b1da5
 
 
2bab8b9
 
 
 
 
fb36c27
6c814fb
2bab8b9
85ef5de
 
 
 
6c814fb
85ef5de
 
 
 
 
6c814fb
85ef5de
6c814fb
85ef5de
2bab8b9
 
fb36c27
 
85ef5de
fb36c27
2bab8b9
 
 
 
 
 
 
 
 
 
 
 
fb36c27
 
 
 
13b1da5
 
 
 
 
 
fb36c27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2bab8b9
 
 
 
 
 
 
 
fb36c27
 
 
85ef5de
8f481af
 
 
 
 
 
 
 
fb36c27
6c814fb
 
 
fb36c27
d288e92
6c814fb
 
 
 
 
 
 
d288e92
fb36c27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
988e3ef
 
fb36c27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
988e3ef
fb36c27
 
 
 
 
2bab8b9
 
13b1da5
 
2bab8b9
 
fb36c27
 
 
 
 
2bab8b9
 
fb36c27
 
 
 
13b1da5
 
 
fb36c27
 
2bab8b9
85ef5de
6c814fb
fb36c27
 
 
 
 
 
 
 
 
 
6c814fb
fb36c27
2bab8b9
 
fb36c27
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
"""Portfolio construction — the institutional layer between "good ideas" and "a book".

- shrunk_cov:        Ledoit-Wolf constant-correlation shrinkage over ~2y of daily returns
                     (sample correlations from one year are noise; shrinkage is standard)
- risk_contributions: marginal / percent contribution to portfolio risk per holding — the
                     diagnosis that says WHICH positions to cut, not just which are large
- greedy_select:     conditional selection — each add is chosen given the adds before it,
                     by variance reduction plus need-fit, so two redundant names never both win
- optimize_target:   SLSQP sizing under name/sector caps, a turnover budget and a tax
                     penalty; three scenarios (full, tax-light, new-cash-only)
- post_trade:        the ENTIRE risk block re-run on the recommended book, side by side
All deterministic; nothing here touches an LLM.
"""
from __future__ import annotations

import numpy as np
import pandas as pd

from . import metrics as M

TRADING_DAYS = 252
COV_WINDOW = 500            # ~2 years of daily returns
MIN_OBS = 120


# ---------------- covariance ----------------

def returns_matrix(hist: pd.DataFrame, cand_hist: pd.DataFrame | None, tickers: list[str]) -> pd.DataFrame:
    """Aligned daily returns for holdings + candidates over the last COV_WINDOW days.
    Columns with fewer than MIN_OBS observations are dropped; remaining gaps are zeros."""
    frames = [hist]
    if cand_hist is not None and not cand_hist.empty:
        frames.append(cand_hist[[c for c in cand_hist.columns if c not in hist.columns]])
    px = pd.concat(frames, axis=1)
    cols = [t for t in tickers if t in px.columns]
    if not cols:
        return pd.DataFrame()
    sub: pd.DataFrame = px.loc[:, cols].iloc[-(COV_WINDOW + 1):]
    rets = sub.pct_change(fill_method=None).iloc[1:]
    keep = [c for c in rets.columns if int(rets[c].notna().sum()) >= MIN_OBS]
    return rets[keep].fillna(0.0)


def shrunk_cov(rets: pd.DataFrame) -> tuple[np.ndarray, list[str], float]:
    """Ledoit-Wolf (2003) shrinkage toward the constant-correlation target. Returns the
    ANNUALIZED covariance, the ticker order, and the shrinkage intensity used."""
    X = rets.to_numpy(dtype=float)
    T, N = X.shape
    if T < 30 or N == 0:
        return np.zeros((N, N)), list(rets.columns), 0.0
    X = X - X.mean(axis=0)
    S = X.T @ X / T
    var = np.diag(S).copy()
    sd = np.sqrt(np.maximum(var, 1e-12))
    corr = S / np.outer(sd, sd)
    if N > 1:
        rbar = (corr.sum() - N) / (N * (N - 1))
    else:
        rbar = 0.0
    F = rbar * np.outer(sd, sd)
    np.fill_diagonal(F, var)
    # pi: sum of asymptotic variances of the sample covariance entries
    Y = X ** 2
    pi_mat = (Y.T @ Y) / T - S ** 2
    pi_hat = pi_mat.sum()
    # rho: covariance between shrinkage target and sample estimator
    term = ((X ** 3).T @ X) / T - var[:, None] * S
    theta = term  # theta_{ii,ij}
    rho_hat = np.diag(pi_mat).sum()
    if N > 1:
        r_over = rbar / 2.0
        ratio = np.sqrt(np.outer(1.0 / var, var))   # sqrt(var_j / var_i)
        rho_mat = r_over * (ratio * theta + ratio.T * theta.T)
        rho_hat += rho_mat.sum() - np.trace(rho_mat)
    gamma = float(((S - F) ** 2).sum())
    kappa = (pi_hat - rho_hat) / gamma if gamma > 0 else 0.0
    delta = float(np.clip(kappa / T, 0.0, 1.0))
    sigma = delta * F + (1 - delta) * S
    return sigma * TRADING_DAYS, list(rets.columns), round(delta, 3)


# ---------------- risk decomposition ----------------

def risk_contributions(weights: dict[str, float], cov: np.ndarray, tickers: list[str]) -> dict:
    """{'portfolio_vol', 'contrib': {t: {weight, mctr, pct_risk}}, 'top': [[t, pct], ...]}"""
    idx = {t: i for i, t in enumerate(tickers)}
    w = np.array([weights.get(t, 0.0) for t in tickers], dtype=float)
    if w.sum() <= 0 or cov.size == 0:
        return {}
    w = w / w.sum()
    var = float(w @ cov @ w)
    if var <= 0:
        return {}
    vol = np.sqrt(var)
    mctr = (cov @ w) / vol
    contrib = w * mctr
    out = {t: {"weight": round(float(w[idx[t]]), 4), "mctr": round(float(mctr[idx[t]]), 4),
               "pct_risk": round(float(contrib[idx[t]] / vol), 4)}
           for t in tickers if w[idx[t]] > 1e-9}          # holdings only, not the candidate pool
    top = sorted(((t, v["pct_risk"]) for t, v in out.items()), key=lambda kv: -kv[1])
    return {"portfolio_vol": round(float(vol), 4), "contrib": out,
            "top": [[t, p] for t, p in top[:5]]}


# ---------------- conditional (greedy) selection ----------------

def greedy_select(cur_w: dict[str, float], candidates: list[dict], cov: np.ndarray,
                  tickers: list[str], max_adds: int = 4, w_add: float | None = None) -> list[dict]:
    """Pick adds one at a time, each conditional on the previous picks: score = 0.6 × rank of
    variance reduction (given the book AS IT WOULD BE) + 0.4 × rank of need-fit. Two names
    that diversify the book but not each other cannot both win."""
    idx = {t: i for i, t in enumerate(tickers)}
    held = [t for t in cur_w if t in idx]
    if not held or cov.size == 0:
        return []
    n = len(cur_w)
    w_add = w_add or min(0.08, 1.0 / (n + 1))
    w = np.zeros(len(tickers))
    for t in held:
        w[idx[t]] = cur_w[t]
    w = w / w.sum()
    pool = [c for c in candidates if c["ticker"] in idx and c["ticker"] not in cur_w]
    picks: list[dict] = []
    for _ in range(max_adds):
        if not pool:
            break
        var0 = float(w @ cov @ w)
        rows = []
        for c in pool:
            w1 = w * (1 - w_add)
            w1[idx[c["ticker"]]] += w_add
            var1 = float(w1 @ cov @ w1)
            rows.append((c, (var0 - var1) / var0 if var0 > 0 else 0.0))
        vr = np.array([r[1] for r in rows])
        fs = np.array([float((r[0].get("fit") or {}).get("score", 0.0)) for r in rows])
        rank = lambda a: (np.argsort(np.argsort(a)) + 1) / len(a) if len(a) > 1 else np.ones(len(a))
        combo = 0.6 * rank(vr) + 0.4 * rank(fs)
        best = int(np.argmax(combo))
        c, red = rows[best]
        picks.append({"ticker": c["ticker"], "order": len(picks) + 1,
                      "variance_reduction": round(float(red), 4),
                      "vol_before": round(float(np.sqrt(var0)), 4),
                      "vol_after": round(float(np.sqrt(var0 * (1 - red))), 4)})
        w = w * (1 - w_add)
        w[idx[c["ticker"]]] += w_add
        pool = [p for p in pool if p["ticker"] != c["ticker"]]
    return picks


# ---------------- constrained sizing ----------------

def optimize_target(cur_w: dict[str, float], mu: dict[str, float], cov: np.ndarray, tickers: list[str],
                    bounds: dict[str, tuple[float, float]], sector_of: dict[str, str],
                    sector_cap: float, turnover_max: float, tax_cost: dict[str, float],
                    tax_weight: float = 0.5, risk_aversion: float = 3.0,
                    stress_rets: dict[str, dict[str, float]] | None = None,
                    loss_tol: float | None = None,
                    betas: dict[str, float] | None = None,
                    beta_floor: float | None = None) -> dict[str, float] | None:
    """min  λ·w'Σw − μ'w + τ·Σ tax_cost_i·(w_cur_i − w_i)+   s.t.  Σw=1, bounds, sector caps,
    turnover Σ|w−w_cur|/2 ≤ T, and — when the investor stated a loss tolerance — every crash
    replay Σ w_i·r_i(window) ≥ −tolerance (stress_rets: {window: {ticker: return}}). If the
    tolerance cannot be met inside the bounds the constraint is dropped and `tolerance_met`
    is set False for the caller. Returns weights or None if the solver fails."""
    # per-call state read by scenarios(): reset FIRST — a stale list once made a 25-name book's
    # narrative say "VRTX and UNH are trimmed in stages" when neither was in that book
    optimize_target.relaxed = []        # noqa
    optimize_target.tolerance_met = None  # noqa
    optimize_target.cash = 0.0          # noqa — unallocated proceeds (dry powder), read by scenarios
    try:
        from scipy.optimize import minimize
    except Exception:
        return None
    U = [t for t in tickers if t in bounds]
    if not U:
        return None
    idx = {t: i for i, t in enumerate(tickers)}
    C = cov[np.ix_([idx[t] for t in U], [idx[t] for t in U])]
    w0 = np.array([cur_w.get(t, 0.0) for t in U])
    m = np.array([mu.get(t, 0.0) for t in U])
    tc = np.array([tax_cost.get(t, 0.0) for t in U])
    lo = np.array([bounds[t][0] for t in U])
    hi = np.array([bounds[t][1] for t in U])
    # feasibility: if lower bounds already exceed 1 (or upper bounds cannot reach 1) relax
    if lo.sum() > 1.0:
        lo = lo / lo.sum() * 0.98
    if hi.sum() < 1.0:
        # feasibility must not silently lift the caps on names the verdicts froze (a gated
        # NVDA once grew from 6.8% to 9.2% this way): only names allowed to grow absorb the
        # shortfall; the uniform stretch is the last resort
        growable = hi > w0 + 1e-9
        short = 1.02 - hi.sum()
        if growable.any():
            room = hi[growable] - w0[growable]
            lifted = hi[growable] + short * (room / room.sum() if room.sum() > 0 else 1.0 / growable.sum())
            lift_cap = 0.10 if len(U) <= 12 else 0.06      # in a broad book an add is never a 10% bet (five 6% adds once ate 31%)
            hi[growable] = np.minimum(lifted, np.maximum(hi[growable], lift_cap))
        if hi.sum() < 1.0:
            # still short: the sales are bigger than the book can absorb at starter sizes —
            # trim the TRIMS less (a staged reduction), never un-sell a Sell, and keep at least
            # half of each intended cut so the trade stays meaningful
            shrinking = (hi < w0 - 1e-9) & (hi > 1e-9)
            if shrinking.any():
                room = 0.5 * (w0[shrinking] - hi[shrinking])
                add = np.minimum((1.02 - hi.sum()) * room / room.sum(), room)
                hi[shrinking] = hi[shrinking] + add
                optimize_target.relaxed = [U[i] for i in np.where(shrinking)[0] if add[list(np.where(shrinking)[0]).index(i)] > 1e-4]
        if hi.sum() < 1.0:
            # proceeds exceed what the committee's ideas can absorb: the professional answer is
            # cash held for the watchlist, not a forced top-up of lukewarm Keeps
            optimize_target.cash = float(min(0.25, 1.0 - hi.sum()))
    total = 1.0 - optimize_target.cash

    def obj(w):
        return (risk_aversion * float(w @ C @ w) - float(m @ w)
                + tax_weight * float(tc @ np.maximum(w0 - w, 0.0)))

    cons = [{"type": "eq", "fun": lambda w: float(w.sum() - total)},
            {"type": "ineq", "fun": lambda w: turnover_max - 0.5 * float(np.abs(w - w0).sum())}]
    for s in set(sector_of.get(t, "?") for t in U):
        if s in ("Diversified fund", "?"):      # funds are internally diversified — the per-name
            continue                            # fund cap governs them, not a "sector" cap that
        members = [i for i, t in enumerate(U) if sector_of.get(t, "?") == s]   # once starved BIL
        cap = max(sector_cap, float(lo[members].sum()) + 1e-6)   # never infeasible vs the floors
        cons.append({"type": "ineq", "fun": (lambda w, mem=members, c=cap: c - float(w[mem].sum()))})
    if betas and beta_floor is not None:     # keep the book's character: no silent conversion
        bv = np.array([float(betas.get(t, 1.0)) for t in U])   # of a growth book into utilities
        cons.append({"type": "ineq", "fun": (lambda w, b=bv, f=beta_floor: float(w @ b) - f)})
    stress_cons = []
    if stress_rets and loss_tol is not None:
        for win, rets in stress_rets.items():
            r = np.array([float(rets.get(t, 0.0)) for t in U])
            stress_cons.append({"type": "ineq", "fun": (lambda w, rv=r, tol=loss_tol: tol + float(w @ rv))})
    start = np.clip(w0, lo, hi)
    start = start / start.sum() * total if start.sum() > 0 else np.full(len(U), total / len(U))

    def _fill_to_one(w):
        """Σw must be 1 without breaking a bound: the residual goes into names with room
        (a plain rescale once lifted 10% starters to 12%)."""
        for _ in range(4):
            resid = total - float(w.sum())
            if abs(resid) < 1e-6:
                break
            room = (hi - w) if resid > 0 else (w - lo)
            room = np.maximum(room, 0.0)
            if room.sum() <= 1e-12:
                return w / w.sum() * total
            w = np.clip(w + resid * room / room.sum(), lo, hi)
        return w / w.sum() * total if abs(total - float(w.sum())) > 1e-4 else w

    def _solve(extra):
        res = minimize(obj, start, method="SLSQP", bounds=list(zip(lo, hi)), constraints=cons + extra,
                       options={"maxiter": 300, "ftol": 1e-9})
        w = np.clip(res.x, lo, hi)
        w = _fill_to_one(w)
        if not np.all(np.isfinite(w)):
            return None, False
        ok = bool(res.success) and all(c["fun"](w) >= -1e-4 for c in extra)
        return {t: float(w[i]) for i, t in enumerate(U)}, ok
    try:
        if stress_cons:
            w, ok = _solve(stress_cons)
            if w and ok:
                optimize_target.tolerance_met = True     # noqa — read by scenarios()
                return w
        optimize_target.tolerance_met = False if stress_cons else None
        w, _ = _solve([])
        return w
    except Exception:
        return None


def _grow(w: float, cap: float) -> float:
    """An existing position may at most double, or gain 3 points of the book, whichever is
    larger — a 0.8% satellite is never turned into a 14% core by the solver."""
    return min(cap, max(w * 2.0, w + 0.03))


def build_bounds(holdings: list[dict], decisions: dict, picks: list[str], caps: dict[str, float],
                 mode: str = "full", new_cash: float = 0.0) -> dict[str, tuple[float, float]]:
    """Action-consistent bounds: Sell → 0; Trim → at most current (and below cap); Keep →
    not gutted (≥50% of current) and not grown past its cap; Buy More → at least current,
    up to cap; new picks → 0…cap. 'new_cash' mode forbids sells: existing weights can only
    shrink by dilution from the new money."""
    b: dict[str, tuple[float, float]] = {}
    for h in holdings:
        t, w = h["symbol"], float(h["weight"])
        cap = caps.get(t, M.MAX_POS_W)
        act = decisions.get(t, {}).get("action", "Keep")
        if mode == "new_cash":
            floor = w / (1 + new_cash)
            b[t] = (floor, max(floor, min(cap, w)) if act != "Buy More" else max(floor, cap))
            continue
        if decisions.get(t, {}).get("ballast"):          # protected bond/cash cushion: never sold down
            b[t] = (min(w, cap), max(w, cap))
            continue
        if mode == "de_risk":                             # honouring a loss tolerance may mean cutting
            b[t] = (0.0, max(min(w, cap), 0.0) if act in ("Sell", "Trim") else cap)   # Keeps well below half
            if act == "Sell":
                b[t] = (0.0, 0.0)
            continue
        if act == "Sell":
            b[t] = (0.0, 0.0)
        elif act == "Trim":
            band = min(0.02, max(0.005, 0.5 / max(len(holdings), 1)))   # same band the order plan uses
            note = str(decisions.get(t, {}).get("size_note") or "")
            if "size itself is the risk" in note and w > cap:
                # a size trim on a backed winner brings it back TOWARD the cap (META 24% → 12-15%),
                # never down to 40% of the position as a damage trim would
                b[t] = (min(w, 0.8 * cap), min(w, cap))
            else:
                hi_t = min(0.8 * w, max(w - band, 0.5 * w), cap)      # a Trim is a real trade, not a 0.1% shave
                b[t] = (min(0.4 * w, hi_t), hi_t)                      # …and never a full exit (that is a Sell verdict)
        elif act == "Buy More":
            min_size = min(0.02, max(0.01, 0.5 / max(len(holdings), 1)))
            lo_b = max(min(w, cap), min(min_size, cap))            # a Buy More is funded at least to a real position
            b[t] = (lo_b, max(lo_b, min(max(w, cap), _grow(w, cap))))
        else:
            d = decisions.get(t, {})
            min_size = min(0.02, max(0.01, 0.5 / max(len(holdings), 1)))
            conv = float(d.get("conviction") or 50)
            lo_k = (0.25 if d.get("watch") else 0.8 if d.get("protect") else 0.5) * w   # watch names are sources of funds
            if d.get("no_add") or conv < 58 or w >= min_size:
                hi_k = min(w, cap)          # fresh capital goes to Buy More verdicts and new ideas, never to a plain Keep
            else:
                hi_k = min(cap, min_size)   # a backed Keep below the trading band is rounded up to a real position
            b[t] = (min(lo_k, cap), max(hi_k, 0.0) if w > cap else hi_k)
    for t in picks:
        b[t] = (0.0, caps.get(t, M.MAX_POS_W))
    return b


def tax_cost_per_weight(holdings: list[dict]) -> dict[str, float]:
    """Tax cost per unit of weight SOLD ≈ gain fraction of proceeds × rate (long-term 20%,
    short-term/unknown 30%). Losses are free (a small negative → harvesting is rewarded)."""
    out = {}
    for h in holdings:
        px, entry = float(h["price"]), float(h["entry"])
        gain_frac = (px - entry) / px if px > 0 else 0.0
        rate = 0.30
        b = h.get("bought")
        if b:
            try:
                from datetime import date
                y, mo, d = (int(x) for x in str(b)[:10].split("-"))
                if (date.today() - date(y, mo, d)).days >= 365:
                    rate = 0.20
            except Exception:
                pass
        out[h["symbol"]] = round(max(gain_frac, -0.2) * rate, 4)
    return out


# ---------------- post-trade risk (the whole block, on the recommended book) ----------------

def post_trade(weights: dict[str, float], holdings: list[dict], cand_data: dict[str, dict],
               hist: pd.DataFrame, cand_hist: pd.DataFrame | None, bench: str,
               fund_lt: dict[str, dict] | None = None, decisions: dict | None = None,
               context: dict | None = None) -> dict:
    """Re-run metrics/stress/VaR/look-through on the target book. Candidates become pseudo
    holdings using their live data."""
    by = {h["symbol"]: h for h in holdings}
    pseudo = []
    for t, w in weights.items():
        if w <= 1e-6:
            continue
        if t in by:
            h = dict(by[t], weight=w)
        else:
            d = cand_data.get(t) or {"ticker": t}
            h = {"symbol": t, "weight": w, "data": d, "entry": d.get("price") or 1.0,
                 "price": d.get("price") or 1.0, "pl_pct": 0.0, "market_value": None,
                 "cost_usd": None, "equal_weighted": False, "dcf": {}}
        pseudo.append(h)
    if not pseudo:
        return {}
    frames = [hist]
    if cand_hist is not None and not cand_hist.empty:
        extra = [c for c in cand_hist.columns if c not in hist.columns]
        if extra:
            frames.append(cand_hist[extra])
    full = pd.concat(frames, axis=1)
    pm = M.portfolio_metrics(pseudo, full, bench)
    cards = M.risk_scorecard(pm)
    stress = M.stress_tests(pseudo, full, bench)
    lt = M.lookthrough(pseudo, fund_lt or {}) if fund_lt else {}
    return {"vol": pm.get("portfolio_vol"), "effective_n": pm.get("effective_n"),
            "beta": pm.get("realized_beta") or pm.get("weighted_beta"),
            "var_1d_95": pm.get("var_1d_95"), "var_1m_95": pm.get("var_1m_95"),
            "top_sector": max(pm["sector_allocation"].items(), key=lambda kv: kv[1]) if pm.get("sector_allocation") else None,
            "sector_allocation": pm.get("sector_allocation"),
            "corr_clusters": len(pm.get("corr_clusters") or []),
            "stress": stress, "lookthrough_top": (lt.get("top_names") or [])[:3],
            "est_health": M.health_score(cards, pm, pseudo, decisions, context, {"stress": stress}),
            "cards": [(c["check"], c["status"]) for c in cards]}


def scenarios(holdings: list[dict], decisions: dict, picks: list[str], mu: dict[str, float],
              cov: np.ndarray, tickers: list[str], caps: dict[str, float], sector_of: dict[str, str],
              sector_cap: float, book_usd: float | None,
              stress_rets: dict[str, dict[str, float]] | None = None,
              loss_tol: float | None = None, risk_aversion: float = 3.0,
              betas: dict[str, float] | None = None, beta_floor: float | None = None) -> dict[str, dict]:
    """Three ways to get there. Each: weights, turnover, est. tax cost, expected edge, and —
    when a loss tolerance was stated — whether every crash replay stays inside it."""
    cur = {h["symbol"]: float(h["weight"]) for h in holdings}
    tc = tax_cost_per_weight(holdings)
    specs = {"full": {"turnover": 0.40, "tax_w": 0.5, "mode": "full"},
             "tax_light": {"turnover": 0.15, "tax_w": 2.5, "mode": "full"},
             "new_cash": {"turnover": 0.12, "tax_w": 0.0, "mode": "new_cash"}}
    if loss_tol is not None:   # what it actually takes to honour the stated tolerance
        specs["de_risk"] = {"turnover": 0.80, "tax_w": 0.5, "mode": "de_risk"}
    out: dict[str, dict] = {}
    for name, sp in specs.items():
        bounds = build_bounds(holdings, decisions, picks, caps, sp["mode"], new_cash=0.10)
        w = optimize_target(cur, mu, cov, tickers, bounds, sector_of, sector_cap,
                            sp["turnover"], tc, tax_weight=sp["tax_w"], risk_aversion=risk_aversion,
                            stress_rets=stress_rets, loss_tol=loss_tol,
                            betas=betas, beta_floor=(None if name == "de_risk" else beta_floor))
        if not w:
            continue
        tol_met = getattr(optimize_target, "tolerance_met", None)
        staged = list(getattr(optimize_target, "relaxed", []) or [])
        cash = round(float(getattr(optimize_target, "cash", 0.0) or 0.0), 4)
        turnover = 0.5 * sum(abs(w.get(t, 0.0) - cur.get(t, 0.0)) for t in set(w) | set(cur))
        sold = {t: max(cur.get(t, 0.0) - w.get(t, 0.0), 0.0) for t in cur}
        tax_frac = sum(tc.get(t, 0.0) * s for t, s in sold.items())
        edge_before = sum(cur[t] * mu.get(t, 0.0) for t in cur)
        edge_after = sum(w[t] * mu.get(t, 0.0) for t in w)
        out[name] = {"weights": {t: round(v, 4) for t, v in w.items() if v > 1e-4},
                     "turnover": round(turnover, 4),
                     "est_tax_usd": round(tax_frac * book_usd) if book_usd else None,
                     "est_tax_pct_of_book": round(tax_frac, 4),
                     "edge_before": round(edge_before, 4), "edge_after": round(edge_after, 4),
                     "tolerance_met": tol_met, "staged_trims": staged, "cash": cash,
                     "label": {"full": "Full rebalance", "tax_light": "Tax-light rebalance",
                               "new_cash": "New cash only (≈10% added, no sells)",
                               "de_risk": "Meet your loss tolerance (bigger trade)"}[name]}
    return out