File size: 20,545 Bytes
4eeb939
 
 
 
 
 
 
 
 
 
 
 
981e59f
 
 
4eeb939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
 
c7865df
4eeb939
 
988e3ef
 
4eeb939
 
 
 
 
 
 
 
d275eab
4eeb939
85ef5de
 
 
4eeb939
 
7bc086a
3876fb1
6c814fb
4eeb939
 
 
e814ba8
 
59f0f92
e814ba8
 
 
 
6efc5f4
d275eab
 
e814ba8
 
4eeb939
 
 
 
c7865df
 
4eeb939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
4eeb939
 
 
 
 
 
 
 
59461d8
 
 
4eeb939
59461d8
 
 
 
 
 
4eeb939
 
 
 
 
 
 
5c1a974
4d4dd52
988e3ef
e937493
 
 
988e3ef
 
 
 
 
 
4eeb939
 
 
 
 
 
 
 
 
 
6c814fb
4eeb939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d275eab
 
 
 
 
 
 
 
 
 
e814ba8
 
 
c733c45
 
 
 
 
d275eab
6c814fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d275eab
 
 
 
 
 
 
 
 
4eeb939
 
34158ed
 
 
06f8709
 
 
 
 
4eeb939
34158ed
39941ec
34158ed
39941ec
 
 
4eeb939
 
 
 
 
 
 
 
e814ba8
 
 
4eeb939
d275eab
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
 
 
 
4eeb939
 
 
 
 
 
e814ba8
 
 
 
 
 
 
 
 
 
 
4eeb939
 
83de142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Result payload assembly (what the summary screen renders) + the full downloadable
markdown report (where the per-holding persona reasoning lives — it is never shown on screen).
Plain-English framing throughout (deepquant jargon→English discipline).
"""
from __future__ import annotations

from datetime import datetime, timezone

from . import metrics as M
from .personas import PERSONAS

DISCLAIMER = ("Educational analysis tool — not investment advice. AI-generated content can be "
              "wrong; verify independently and consult a licensed advisor before acting. "
              "The intrinsic-value approach is inspired by Prof. Aswath Damodaran's published "
              "valuation framework; he is not affiliated with and has not endorsed this tool.")


def _fallback_verdict(pm: dict, cards: list[dict]) -> str:
    worst = next((c for c in cards if c["status"] == "ALERT"),
                 next((c for c in cards if c["status"] == "WATCH"), None))
    base = f"{pm['n_holdings']}-stock portfolio"
    return f"{base} — biggest issue: {worst['detail']}" if worst else f"{base} — no major structural issues detected."


def _fallback_highlights(cards: list[dict]) -> list[dict]:
    sev = {"OK": "good", "WATCH": "warn", "ALERT": "bad"}
    ranked = sorted(cards, key=lambda c: {"ALERT": 0, "WATCH": 1, "OK": 2}[c["status"]])
    return [{"severity": sev[c["status"]], "title": c["check"], "detail": c["detail"],
             "lens": "portfolio"} for c in ranked[:4]]


def assemble(holdings, pm, cards, decisions, panel, strat, cio, errors, news=None,
             extra: dict | None = None) -> dict:
    news = news or {}
    cio = cio or {}
    rationale = cio.get("rationale") or {}
    hb = (extra or {}).get("health_breakdown") or {}
    score = hb.get("score") if isinstance(hb.get("score"), int) else M.health_score(cards, pm, holdings, decisions)
    action_counts: dict[str, int] = {}
    rows = []
    for h in holdings:
        t = h["symbol"]
        dec = decisions[t]
        action_counts[dec["action"]] = action_counts.get(dec["action"], 0) + 1
        dcf = h.get("dcf") or {}
        lens_points = {k: v[t] for k, v in panel.items() if t in v}
        memos = (cio or {}).get("memos") or {}
        rows.append({
            "ticker": t, "name": h["name"],
            "sector": ("Diversified fund" if str(h["data"].get("quote_type") or "").upper() in M.FUND_TYPES
                       else h["data"].get("sector")),
            "weight": round(h["weight"], 4), "entry": h["entry"], "price": h["price"],
            "pl_pct": round(h["pl_pct"], 4), "qty": h.get("qty"),
            "n_lots": h.get("n_lots") or 1, "lots": h.get("lots"), "lot_note": h.get("lot_note"),
            "entry_note": h.get("entry_note"),
            "action": dec["action"], "conviction": dec["conviction"], "watch": bool(dec.get("watch")),
            "dispersion": dec["dispersion"], "n_lenses": dec["n_lenses"],
            "rationale": str(rationale.get(t, ""))[:300] or _row_fallback(dec, dcf),
            "intrinsic": dcf.get("intrinsic"), "upside": dcf.get("upside"),
            "intrinsic_low": dcf.get("intrinsic_low"), "intrinsic_high": dcf.get("intrinsic_high"),
            "implied_growth": dcf.get("implied_growth"), "valuation_model": dcf.get("model"),
            "dcf_note": dcf.get("note"),
            "tax_note": dec.get("tax_note"), "size_note": dec.get("size_note"),
            "peer": {k: round((v + 1) / 2 * 100) for k, v in
                     ((h["data"].get("panel") or {}).get("factors") or {}).items()
                     if k in ("n_earnings_yield", "n_evebitda", "n_roe", "n_netmargin", "n_mom_12_1")},
            "peer_universe": (h["data"].get("panel") or {}).get("universe") or "US",
            "us": h["data"].get("us") or None, "asymmetry": h.get("asymmetry"),
            "memo": (memos or {}).get(t), "sizing": h.get("sizing"),
            "data_source": h["data"].get("fund_source"), "fund_asof": h["data"].get("fund_asof"),
            "price_asof": h["data"].get("price_asof"), "data_flags": h["data"].get("data_flags") or [],
            "lifecycle": dcf.get("lifecycle"), "flags": dcf.get("flags") or [],
            "strengths": [v["key_point"] for v in lens_points.values() if v.get("key_point")][:3],
            "risks": [v["key_risk"] for v in lens_points.values() if v.get("key_risk")][:3],
            "lens_scores": dec["scores"],
            "news": [{"headline": n.get("headline", ""), "sentiment": n.get("sentiment", "")}
                     for n in news.get(t, [])[:4]],
        })
    rows.sort(key=lambda r: -r["weight"])
    alternatives = []
    for a in (cio.get("alternatives") or [])[:4]:
        if isinstance(a, dict) and a.get("ticker"):
            alternatives.append({"ticker": str(a["ticker"]).upper()[:12],
                                 "reason": str(a.get("reason", ""))[:220]})
    highlights = cio.get("highlights") if isinstance(cio.get("highlights"), list) else None
    result = {
        "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
        "health_score": score,
        "verdict": str(cio.get("verdict", ""))[:300] or _fallback_verdict(pm, cards),
        "action_counts": action_counts,
        "total_pl_pct": pm.get("total_pl_pct"),
        "highlights": (highlights or _fallback_highlights(cards))[:5],
        "holdings": rows,
        "sector_allocation": pm.get("sector_allocation", {}),
        "sector_band": [M.SECTOR_CAP_LO, M.SECTOR_CAP_HI],
        "name_cap": M.MAX_POS_W,
        "risk_scorecard": cards,
        "metrics": {k: pm.get(k) for k in
                    ("effective_n", "weighted_beta", "realized_beta", "portfolio_vol",
                     "sharpe_1y", "return_1y", "spy_return_1y", "max_drawdown_1y",
                     "equal_weighted", "regime_risk_on")},
        "strategist": strat or {},
        "alternatives": alternatives,
        "input_errors": errors,
        "llm_used": bool(panel),
        "disclaimer": DISCLAIMER,
    }
    result.update(extra or {})
    result["report_md"] = _markdown(result, panel)
    return result


def _row_fallback(dec, dcf) -> str:
    if not dec["scores"]:
        up = dcf.get("upside")
        if isinstance(up, (int, float)):
            side = "less" if up > 0 else "more"
            return (f"The market is paying {abs(up) * 100:.0f}% {side} than our estimate of "
                    f"what the business is worth.")
        return "Quant view only — no AI panel available for this run."
    c = dec["conviction"]
    mood = ("the analysts see serious problems here" if c < 35 else
            "the analysts found more concerns than positives" if c < 45 else
            "the analysts' views are balanced on this one" if c <= 62 else
            "the analysts see clear positives outweighing the risks")
    return f"On balance, {mood} — open the row for the detail."


def _pct(x):
    return f"{x * 100:+.1f}%" if isinstance(x, (int, float)) else "n/a"


def _markdown(res: dict, panel: dict) -> str:
    L = ["# Vitals — Portfolio Verdict", "*Five analysts. One verdict.*",
         f"*Generated {res['generated_at']}*", "",
         f"**Portfolio health: {res['health_score']}/100** — {res['verdict']}", ""]
    deg = ((res.get("diagnostics") or {}).get("degraded") or [])
    if deg:
        L += ["*Note: " + "; ".join(deg) + ".*"]
    hb = res.get("health_breakdown") or {}
    if hb.get("pillars"):
        L += ["**Health pillars:** " + " · ".join(f"{p['label']} {p['score']:.0f} ({p['weight'] * 100:.0f}%)" for p in hb["pillars"])]
        for p in hb["pillars"]:
            L.append(f"- {p['label']}: {p['note']}")
    L += ["",
         "## Key findings"]
    for hl in res["highlights"]:
        icon = {"good": "✅", "warn": "⚠️", "bad": "🔴"}.get(hl.get("severity"), "•")
        L.append(f"- {icon} **{hl.get('title', '')}** — {hl.get('detail', '')} "
                 f"*({hl.get('lens', 'portfolio')} lens)*")
    L += ["", "## Recommendations", "",
          "| Holding | Weight | P/L | Intrinsic vs price | Action | Why |",
          "|---|---|---|---|---|---|"]
    for r in res["holdings"]:
        L.append(f"| **{r['ticker']}** {r['name']} | {r['weight'] * 100:.0f}% | {_pct(r['pl_pct'])} "
                 f"| {_pct(r['upside'])} | **{r['action']}**{' · watch' if r.get('watch') else ''} | {r['rationale']} |")
    L += ["", "## Portfolio review"]
    st = res.get("strategist") or {}
    if st.get("strategy_read"):
        L.append(f"**Strategy read:** {st['strategy_read']}")
    if st.get("style_tilt"):
        L.append(f"**Style tilt:** {st['style_tilt']}")
    if st.get("gaps"):
        L.append("**Missing exposures:** " + ", ".join(map(str, st["gaps"])))
    L.append("")
    L.append("### Risk scorecard")
    for c in res["risk_scorecard"]:
        icon = {"OK": "✅", "WATCH": "⚠️", "ALERT": "🔴"}[c["status"]]
        L.append(f"- {icon} **{c['check']}** — {c['detail']}")
    L += ["", "### Sector allocation"]
    for s, w in res["sector_allocation"].items():
        L.append(f"- {s}: {w * 100:.0f}%")
    rk = res.get("risk") or {}
    if rk:
        bn = (res.get("benchmark") or {}).get("name") or "benchmark"
        L += ["", "## Risk analytics"]
        if rk.get("stress"):
            L += [f"**Stress replay (today's weights, vs {bn}):**"]
            for s in rk["stress"]:
                L.append(f"- {s['name']} ({s['window']}): portfolio {_pct(s['portfolio'])}"
                         + (f" vs {bn} {_pct(s['benchmark'])}" if s.get("benchmark") is not None else "")
                         + (f" (coverage {s['coverage'] * 100:.0f}%)" if s.get("coverage", 1) < 1 else ""))
        if rk.get("var_1d_95") is not None:
            L.append(f"**Value at risk (95%, historical):** 1-day {rk['var_1d_95'] * 100:.1f}% · "
                     f"1-month {rk['var_1m_95'] * 100:.1f}%"
                     + (f" · expected shortfall (1-day) {rk['cvar_1d_95'] * 100:.1f}%" if rk.get("cvar_1d_95") else "")
                     + (f" · tracking error vs {bn} {rk['tracking_error'] * 100:.1f}%" if rk.get("tracking_error") else ""))
        if rk.get("currency_exposure"):
            L.append("**Currency exposure:** " + ", ".join(f"{c} {w * 100:.0f}%" for c, w in rk["currency_exposure"].items()))
        lt = rk.get("lookthrough") or {}
        if lt.get("top_names"):
            L.append(f"**Fund look-through (constituents disclosed for {lt['coverage'] * 100:.0f}% of fund weight):** "
                     + ", ".join(f"{n} {tot * 100:.1f}%" + (f" (direct {d * 100:.1f}% + via funds {v * 100:.1f}%)" if d and v else "")
                                 for n, d, v, tot in lt["top_names"][:6]))
        ft = rk.get("factor_tilts") or {}
        if ft.get("tilts"):
            L.append(f"**Style tilts (−1…+1, {ft['coverage'] * 100:.0f}% of book covered):** "
                     + ", ".join(f"{k} {v:+.2f}" for k, v in ft["tilts"].items()))
        fb = rk.get("factor_betas") or {}
        if fb.get("betas"):
            L.append(f"**Measured factor betas (vs {bn}, market beta {fb['market_beta']}):** "
                     + ", ".join(f"{k} {v:+.2f}" for k, v in fb["betas"].items()))
        br = rk.get("benchmark_relative") or {}
        if br:
            L.append(f"**Vs {bn}:** " + ", ".join(f"{k.replace('_', ' ')} {v}" for k, v in br.items()))
        if rk.get("active_sectors"):
            L.append(f"**Active sector bets vs {bn}:** " + ", ".join(
                f"{x['sector']} {x['active'] * 100:+.0f}pp ({x['book'] * 100:.0f}% vs {x['benchmark'] * 100:.0f}%)" for x in rk["active_sectors"][:6]))
        if rk.get("liquidity"):
            L.append("**Liquidity:** " + "; ".join(f"{x['ticker']} ≈ {x['days_to_liquidate']} days to exit at 20% of volume"
                                                   for x in rk["liquidity"][:5]))
    usage = ((res.get("diagnostics") or {}).get("usage") or {})
    if usage.get("by_model"):
        L += ["", "**Model usage this run:** " + "; ".join(
            f"{m} {v['calls']} calls, {(v['input'] + v['cache_read'] + v['cache_write']) / 1000:.0f}k in / {v['output'] / 1000:.1f}k out (${v['cost_usd']:.2f})"
            for m, v in usage["by_model"].items()) + f" — total ≈ ${usage.get('cost_usd', 0):.2f}"]
    plan = res.get("plan") or {}
    if plan:
        L += ["", "## Execution — the manager's desk"]
        if plan.get("cash"):
            L.append(f"**Unallocated cash:** {plan['cash'] * 100:.1f}% of the book held as dry powder for the watchlist "
                     f"(the committee's ideas could not absorb every sale at sensible sizes).")
        for x in plan.get("tax_plan") or []:
            L.append(f"- **Tax plan {x['ticker']}:** {x['note']}")
        for x in plan.get("watchlist") or []:
            L.append(f"- **Watchlist {x['ticker']}** ({x.get('name')}): {x['condition']} — {x.get('why')}")
        for x in plan.get("catalysts") or []:
            L.append(f"- **Catalyst:** {x['ticker']} {x['event']} on {x['date']} (in {x['days']} days)")
        ra = plan.get("risk_contributions_after") or {}
        rb = plan.get("risk_contributions") or {}
        if ra.get("top"):
            L.append("**Where the risk sits after the trade:** " + ", ".join(f"{t} {p * 100:.0f}%" for t, p in ra["top"][:5])
                     + (f" (today: " + ", ".join(f"{t} {p * 100:.0f}%" for t, p in rb["top"][:5]) + ")" if rb.get("top") else ""))
        s12 = plan.get("scenarios_12m") or {}
        if (s12.get("current") or {}).get("base") is not None:
            c, pl = s12["current"], s12.get("planned") or {}
            L.append(f"**Valuation risk/reward (where the book would trade if every holding converged to its bear / base / bull value, probability-weighted):** today "
                     f"bear {c['bear'] * 100:+.0f}% / base {c['base'] * 100:+.0f}% / bull {c['bull'] * 100:+.0f}%"
                     + (f"; after the plan bear {pl['bear'] * 100:+.0f}% / base {pl['base'] * 100:+.0f}% / bull {pl['bull'] * 100:+.0f}%" if pl.get("base") is not None else "")
                     + f" ({c['coverage'] * 100:.0f}% of the book covered)")
        trip = [(r["ticker"], (r.get("us") or {}).get("sell_triggers")) for r in res["holdings"]
                if r.get("action") != "Sell" and (r.get("us") or {}).get("sell_triggers")]
        if trip:
            L.append("**Tripwires (what would make us sell):** " + "; ".join(f"{t}: {tr[0]}" for t, tr in trip[:12]))
    if plan.get("harvest_pairs") or plan.get("do_nothing") or res.get("premortem"):
        L += ["", "## Discipline"]
        if plan.get("do_nothing"):
            L.append("**Do nothing.** Nothing clears the hurdle for a trade; inactivity is the recommendation until a sell trigger fires.")
        for h in plan.get("harvest_pairs") or []:
            L.append(f"- **Tax-loss harvest:** sell {h['sell']}, buy {h['buy']} ({h['buy_name']}, correlation {h['correlation']}) — {h['note']}")
        pm = res.get("premortem")
        if pm and pm.get("story"):
            L.append(f"**Pre-mortem ({pm['ticker']}):** {pm['story']}")
    if res["alternatives"]:
        L += ["", "## Suggested alternatives"]
        needs = res.get("portfolio_needs") or {}
        if needs:
            L.append("*What this portfolio needs:* " + "; ".join(v["label"] for v in needs.values() if v.get("label")))
        for g in (res.get("plan") or {}).get("gap_search") or []:
            L.append(f"- *{g['sector']}:* {g['screened']} names screened — "
                     + (f"buyable today: {', '.join(g['buyable'])}" if g.get("buyable") else "none buyable today")
                     + (f" ({g['why_not']})" if g.get("why_not") else "")
                     + (f"; on the bench: {', '.join(g['bench'])}" if g.get("bench") else ""))
        for a in res["alternatives"]:
            fit = a.get("fit") or {}
            com = a.get("committee") or {}
            L.append(f"- **{a['ticker']}** — {a['reason']}"
                     + (f" *(fixes: {'; '.join(fit['fixes'])})*" if fit.get("fixes") else "")
                     + (f" ⚠ {'; '.join(fit['cautions'])}" if fit.get("cautions") else "")
                     + (f" · committee {com['conviction']}/100 on adding" if com.get("conviction") else ""))
    if panel:
        L += ["", "## Appendix — analyst committee detail (per holding)"]
        for r in res["holdings"]:
            t = r["ticker"]
            L += ["", f"### {t} — {r['name']} · {r['action']} (conviction {r['conviction']}/100"
                      + (f", dispersion {r['dispersion']}" if r["dispersion"] else "") + ")"]
            if r.get("intrinsic"):
                L.append(f"Intrinsic value {r['intrinsic']} ({_pct(r['upside'])} vs price, "
                         f"{r.get('valuation_model') or 'DCF'}, lifecycle: {r.get('lifecycle') or '?'})"
                         + (f" · bear–bull {r['intrinsic_low']}–{r['intrinsic_high']}" if r.get("intrinsic_low") else "")
                         + (f" · price implies {r['implied_growth'] * 100:+.0f}%/yr growth" if isinstance(r.get("implied_growth"), (int, float)) else "")
                         + (f" · guards: {', '.join(r['flags'])}" if r["flags"] else ""))
            if r.get("memo"):
                m = r["memo"]
                L.append(f"Memo — thesis: {m.get('thesis')} · debate: {m.get('debate')} · would change our mind: {m.get('change_mind')}")
            if r.get("asymmetry"):
                a = r["asymmetry"]
                L.append(f"Payoff: bear {_pct(a['bear'])} / base {_pct(a['base'])} / bull {_pct(a['bull'])} → expected {_pct(a['expected_return'])}, "
                         f"upside:downside {a['up_down_ratio']}:1 ({'clears' if a['clears_hurdle'] else 'below'} the 2:1 / +8% hurdle)"
                         + (f" · half-Kelly size {r['sizing']['half_kelly'] * 100:.0f}% vs {r['sizing']['current'] * 100:.0f}% held" if r.get("sizing") else ""))
            if r.get("us"):
                from .fundamentals import us_lines
                for line in us_lines(r["us"]):
                    L.append(line)
                if r["us"].get("sell_triggers"):
                    L.append("Sell triggers: " + "; ".join(r["us"]["sell_triggers"]))
            if r.get("tax_note"):
                L.append(f"Tax: {r['tax_note']}")
            if r.get("data_flags"):
                L.append(f"Data quality: {'; '.join(r['data_flags'])}")
            for k, scores in panel.items():
                if t in scores:
                    p = PERSONAS[k]
                    v = scores[t]
                    L.append(f"- **{p['label']}** ({v['conviction']:.0f}/100): {v.get('key_point', '')}"
                             + (f" — *risk: {v['key_risk']}*" if v.get("key_risk") else ""))
    ds = res.get("data_sources") or []
    if ds:
        L += ["", "## Data sources & methodology",
              f"Benchmark: {(res.get('benchmark') or {}).get('name', 'S&P 500')} · "
              f"Base currency: {res.get('base_currency', 'USD')} · "
              f"Methodology version {res.get('methodology_version', '?')} · "
              f"Prompt version {(res.get('diagnostics') or {}).get('prompt_version', '?')} · "
              f"Models: {', '.join(f'{k} {v}' for k, v in ((res.get('diagnostics') or {}).get('models') or {}).items() if v)}",
              "", "| Holding | Fundamentals source | As of | Price as of |", "|---|---|---|---|"]
        for d in ds:
            L.append(f"| {d['ticker']} | {d.get('fundamentals') or '?'} | {d.get('fund_asof') or '—'} | {d.get('price_asof') or '—'} |")
    L += ["", "---", f"*{res['disclaimer']}*"]
    return "\n".join(L)


_CTX_LABELS = {"horizon": "Investment horizon", "risk": "Risk appetite", "objective": "Objective"}


def append_target_md(md: str, target: list[dict], context: dict) -> str:
    L = [md, "", "## Recommended target portfolio",
         "*Based on your context: " + "; ".join(
             f"{_CTX_LABELS.get(k, k)}: {v}" for k, v in context.items() if v) + ".*",
         "*Deterministic construction: committee conviction sizes positions under "
         "risk-profile name/sector caps. Educational illustration, not advice.*", "",
         "| Holding | Current | Target | Change |", "|---|---|---|---|"]
    for r in target:
        tag = " *(new)*" if r["is_new"] else ""
        L.append(f"| {r['ticker']}{tag} | {r['current_w'] * 100:.1f}% | {r['target_w'] * 100:.1f}% "
                 f"| {r['delta'] * 100:+.1f}pp |")
    return "\n".join(L)