File size: 21,076 Bytes
59461d8
 
 
 
 
 
 
 
 
 
 
 
 
271ed64
 
 
59461d8
 
 
271ed64
59461d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c1a974
59461d8
5c1a974
59461d8
 
 
 
 
 
 
 
 
 
988e3ef
 
 
 
59461d8
 
98e0107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59461d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c814fb
 
59461d8
98e0107
59461d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e814ba8
 
 
 
 
 
 
13b1da5
e814ba8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d275eab
 
 
 
 
 
 
 
 
 
e814ba8
 
 
 
 
 
 
59461d8
 
 
34158ed
 
 
 
 
06f8709
 
 
 
 
 
59461d8
34158ed
39941ec
 
 
 
59461d8
 
 
 
 
 
81285b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6efc5f4
 
81285b7
 
 
 
6c814fb
 
 
 
d275eab
 
 
6c814fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d275eab
 
 
 
 
 
 
98e0107
 
 
 
 
 
 
 
 
 
81285b7
59461d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98e0107
 
 
 
 
 
 
 
 
59461d8
 
98e0107
 
 
 
 
 
 
 
 
 
 
 
59461d8
e814ba8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59461d8
 
 
 
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
"""Conclave PDF report — typeset with reportlab (crucible's report stack, simplified).
Built directly from the result payload; tolerates missing sections (no LLM, no target).
"""
from __future__ import annotations

import io

from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import (Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle)

INK = colors.HexColor("#131a22")
BRASS = colors.HexColor("#0b7fa6")   # brand accent (print-safe deep cyan)
DIM = colors.HexColor("#5c6a7a")
GOOD = colors.HexColor("#1e7a4a")
WARN = colors.HexColor("#9a6b12")
BAD = colors.HexColor("#a33a40")
LINE = colors.HexColor("#ccd6e0")

_ACTION_COLOR = {"Buy More": GOOD, "Keep": DIM, "Trim": WARN, "Sell": BAD}
_SEV_COLOR = {"good": GOOD, "warn": WARN, "bad": BAD, "OK": GOOD, "WATCH": WARN, "ALERT": BAD}


def _sty(name, **kw):
    base = dict(fontName="Helvetica", fontSize=9.5, leading=13, textColor=INK)
    base.update(kw)
    return ParagraphStyle(name, **base)  # type: ignore[arg-type]


S_TITLE = _sty("t", fontName="Times-Bold", fontSize=24, leading=28)
S_TAG = _sty("tag", fontName="Times-Italic", fontSize=11, textColor=BRASS)
S_H2 = _sty("h2", fontName="Helvetica-Bold", fontSize=11, leading=15, textColor=BRASS,
            spaceBefore=14, spaceAfter=5)
S_BODY = _sty("b")
S_VERDICT = _sty("v", fontName="Times-Roman", fontSize=12.5, leading=17)
S_DIM = _sty("d", fontSize=8, textColor=DIM)
S_CELL = _sty("c", fontSize=8.5, leading=11)


_UNI = {"—": "-", "–": "-", "→": "->", "←": "<-", "≈": "~", "≥": ">=", "≤": "<=",
        "↑": "up", "↓": "down", "•": "-", "‘": "'", "’": "'", "“": '"', "”": '"',
        "₹": "Rs ", "…": "...", "⚠": "!", "✓": "OK", "✗": "x"}


def _txt(x) -> str:
    s = str(x if x is not None else "")
    for u, a in _UNI.items():
        s = s.replace(u, a)
    s = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    return s.encode("latin-1", "replace").decode("latin-1")   # Helvetica-safe


def _pct(x, signed=True):
    if not isinstance(x, (int, float)):
        return "-"
    return f"{x * 100:+.1f}%" if signed else f"{x * 100:.1f}%"


def _table(data, widths, header=True, aligns=None):
    t = Table(data, colWidths=widths, repeatRows=1 if header else 0)
    style = [("VALIGN", (0, 0), (-1, -1), "TOP"),
             ("LINEBELOW", (0, 0), (-1, -2), 0.4, LINE),
             ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]
    if header:
        style += [("FONT", (0, 0), (-1, 0), "Helvetica-Bold", 8),
                  ("TEXTCOLOR", (0, 0), (-1, 0), DIM),
                  ("LINEBELOW", (0, 0), (-1, 0), 0.8, BRASS)]
    for i, a in enumerate(aligns or []):
        style.append(("ALIGN", (i, 0), (i, -1), a))
    t.setStyle(TableStyle(style))
    return t


def build_pdf(res: dict) -> bytes:
    buf = io.BytesIO()
    doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=16 * mm, rightMargin=16 * mm,
                            topMargin=16 * mm, bottomMargin=16 * mm, title="Vitals — Portfolio Verdict")
    E = []
    E.append(Paragraph("Vitals", S_TITLE))
    E.append(Paragraph("Five analysts. One verdict.", S_TAG))
    meta = f"Generated {_txt(res.get('generated_at'))}"
    ctx = res.get("context") or {}
    if ctx:
        meta += " · Context: " + ", ".join(f"{k}: {v}" for k, v in ctx.items())
    E.append(Paragraph(_txt(meta), S_DIM))
    E.append(Spacer(1, 8))

    E.append(Paragraph(f"Portfolio health: <b>{res.get('health_score', '?')}/100</b>",
                       _sty("hs", fontName="Helvetica-Bold", fontSize=13)))
    hb = res.get("health_breakdown") or {}
    if hb.get("pillars"):
        E.append(Paragraph(_txt(" · ".join(f"{p['label']} {p['score']:.0f}" for p in hb["pillars"])), S_DIM))
        E.append(Spacer(1, 3))
    E.append(Spacer(1, 3))
    E.append(Paragraph(_txt(res.get("verdict", "")), S_VERDICT))
    ac = res.get("action_counts") or {}
    strip = "  ·  ".join(f"{ac[a]} {a}" for a in ("Buy More", "Keep", "Trim", "Sell") if ac.get(a))
    if res.get("total_pl_pct") is not None:
        strip += f"  ·  Portfolio P/L {_pct(res['total_pl_pct'])}"
    E.append(Spacer(1, 4))
    E.append(Paragraph(strip, _sty("strip", fontName="Helvetica-Bold", fontSize=9.5,
                                   textColor=DIM)))
    ol0 = (res.get("plan") or {}).get("outlook") or {}
    cur0, pl0 = ol0.get("current") or {}, ol0.get("planned") or {}
    if cur0 and pl0:   # the payoff line belongs on page 1, not buried mid-report
        E.append(Spacer(1, 3))
        E.append(Paragraph(
            f"<b>If you follow the plan:</b> est. health {cur0.get('est_health')} -> "
            f"<b>{pl0.get('est_health')}</b>"
            + (f", model 12-mo edge {_pct(cur0.get('expected_excess_12m'))} -> "
               f"<b>{_pct(pl0.get('expected_excess_12m'))}</b>"
               if cur0.get("expected_excess_12m") is not None
               and pl0.get("expected_excess_12m") is not None else "")
            + (f", beta {cur0.get('beta')} -> {pl0.get('beta')}" if pl0.get("beta") else ""),
            S_BODY))

    E.append(Paragraph("KEY FINDINGS", S_H2))
    for h in res.get("highlights", []):
        c = _SEV_COLOR.get(str(h.get("severity")), DIM)
        E.append(Paragraph(
            f'<font color="#{c.hexval()[2:]}">&#9632;</font>&nbsp; <b>{_txt(h.get("title"))}</b> — '
            f'{_txt(h.get("detail"))} <font size="7" color="#{DIM.hexval()[2:]}">({_txt(h.get("lens", "portfolio"))} lens)</font>',
            S_BODY))
        E.append(Spacer(1, 3))

    E.append(Paragraph("RECOMMENDATIONS", S_H2))
    rows: list[list] = [["Holding", "Weight", "P/L", "vs intrinsic", "Action", "Why"]]
    for r in res.get("holdings", []):
        c = _ACTION_COLOR.get(r.get("action"), DIM)
        rows.append([
            Paragraph(f"<b>{_txt(r['ticker'])}</b><br/><font size='7' color='#{DIM.hexval()[2:]}'>{_txt(r.get('name'))}</font>", S_CELL),
            _pct(r.get("weight"), signed=False), _pct(r.get("pl_pct")), _pct(r.get("upside")),
            Paragraph(f"<b><font color='#{c.hexval()[2:]}'>{_txt(r.get('action'))}</font></b>"
                      + ("<br/><font size='6'>watch</font>" if r.get("watch") else ""), S_CELL),
            Paragraph(_txt(r.get("rationale")), S_CELL)])
    E.append(_table(rows, [26 * mm, 14 * mm, 18 * mm, 18 * mm, 16 * mm, 84 * mm],
                    aligns=["LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "LEFT"]))

    E.append(Paragraph("PORTFOLIO REVIEW", S_H2))
    st = res.get("strategist") or {}
    if st.get("strategy_read"):
        E.append(Paragraph(f"<b>Strategy read:</b> {_txt(st['strategy_read'])}", S_BODY))
    if st.get("style_tilt"):
        E.append(Paragraph(f"<b>Style tilt:</b> {_txt(st['style_tilt'])}", S_BODY))
    if st.get("gaps"):
        E.append(Paragraph("<b>Missing exposures:</b> " + _txt(", ".join(map(str, st["gaps"]))), S_BODY))
    E.append(Spacer(1, 5))
    for c0 in res.get("risk_scorecard", []):
        c = _SEV_COLOR.get(str(c0.get("status")), DIM)
        E.append(Paragraph(
            f"<font color='#{c.hexval()[2:]}'><b>[{_txt(c0.get('status'))}]</b></font> "
            f"<b>{_txt(c0.get('check'))}</b> — {_txt(c0.get('detail'))}", S_BODY))
        E.append(Spacer(1, 2))
    sa = res.get("sector_allocation") or {}
    if sa:
        E.append(Spacer(1, 4))
        E.append(Paragraph("<b>Sector mix:</b> " + _txt(" · ".join(
            f"{s} {w * 100:.0f}%" for s, w in sa.items())), S_BODY))

    rk = res.get("risk") or {}
    if rk:
        bn = (res.get("benchmark") or {}).get("name") or "benchmark"
        E.append(Paragraph("RISK ANALYTICS", S_H2))
        if rk.get("stress"):
            rows_s: list[list] = [["Stress replay (today's weights)", "Window", "Portfolio", bn]]
            for s in rk["stress"]:
                rows_s.append([_txt(s["name"]), _txt(str(s["window"]).replace("→", "to").replace("->", "to")), _pct(s["portfolio"]),
                               _pct(s["benchmark"]) if s.get("benchmark") is not None else "—"])
            E.append(_table(rows_s, [55 * mm, 55 * mm, 25 * mm, 25 * mm],
                            aligns=["LEFT", "LEFT", "RIGHT", "RIGHT"]))
            E.append(Spacer(1, 4))
        bits = []
        if rk.get("var_1d_95") is not None:
            bits.append(f"<b>VaR (95%):</b> 1-day {rk['var_1d_95'] * 100:.1f}%, 1-month {rk['var_1m_95'] * 100:.1f}%"
                        + (f", expected shortfall {rk['cvar_1d_95'] * 100:.1f}%" if rk.get("cvar_1d_95") else ""))
        if rk.get("tracking_error"):
            bits.append(f"<b>Tracking error vs {_txt(bn)}:</b> {rk['tracking_error'] * 100:.1f}%")
        if rk.get("currency_exposure"):
            bits.append("<b>Currency:</b> " + _txt(", ".join(f"{c} {w * 100:.0f}%" for c, w in rk["currency_exposure"].items())))
        lt = rk.get("lookthrough") or {}
        if lt.get("top_names"):
            bits.append("<b>Fund look-through (top effective names):</b> " + _txt(", ".join(
                f"{n} {tot * 100:.1f}%" for n, _, _, tot in lt["top_names"][:6])))
        ft = rk.get("factor_tilts") or {}
        if ft.get("tilts"):
            bits.append("<b>Style tilts:</b> " + _txt(", ".join(f"{k} {v:+.2f}" for k, v in ft["tilts"].items())))
        fb = rk.get("factor_betas") or {}
        if fb.get("betas"):
            bits.append(f"<b>Measured factor betas (market beta {fb['market_beta']}):</b> "
                        + _txt(", ".join(f"{k} {v:+.2f}" for k, v in fb["betas"].items())))
        br = rk.get("benchmark_relative") or {}
        if br:
            bits.append(f"<b>Vs {_txt(bn)}:</b> " + _txt(", ".join(f"{k.replace('_', ' ')} {v}" for k, v in br.items())))
        if rk.get("active_sectors"):
            bits.append("<b>Active sector bets:</b> " + _txt(", ".join(
                f"{x['sector']} {x['active'] * 100:+.0f}pp" for x in rk["active_sectors"][:6])))
        if rk.get("liquidity"):
            bits.append("<b>Liquidity:</b> " + _txt("; ".join(
                f"{x['ticker']} ~{x['days_to_liquidate']} days to exit" for x in rk["liquidity"][:4])))
        for b in bits:
            E.append(Paragraph(b, S_BODY))
            E.append(Spacer(1, 2))

    alts = res.get("alternatives") or []
    if alts:
        E.append(Paragraph("WORTH A LOOK — GAP FILLERS", S_H2))
        needs = res.get("portfolio_needs") or {}
        if needs:
            E.append(Paragraph("<b>What this portfolio needs:</b> " + _txt(
                "; ".join(v["label"] for v in needs.values() if v.get("label"))), S_BODY))
            E.append(Spacer(1, 3))
        for g in (res.get("plan") or {}).get("gap_search") or []:
            line = (f"<b>{_txt(g['sector'])}:</b> {g['screened']} names screened — "
                    + (f"buyable today: {_txt(', '.join(g['buyable']))}" if g.get("buyable") else "none buyable today")
                    + (f" ({_txt(g['why_not'])})" if g.get("why_not") else "")
                    + (f"; on the bench with entry conditions: {_txt(', '.join(g['bench']))}" if g.get("bench") else ""))
            E.append(Paragraph(line, S_DIM)); E.append(Spacer(1, 2))
        for a in alts:
            fit = a.get("fit") or {}
            com = a.get("committee") or {}
            sig = " · ".join((fit.get("fixes") or []) + [f"CAUTION: {x}" for x in (fit.get("cautions") or [])]
                             + ([f"committee {com['conviction']}/100 on adding"] if com.get("conviction") else [])
                             + (a.get("signals") or []))
            E.append(Paragraph(
                f"<b>{_txt(a.get('ticker'))}</b> — {_txt(a.get('reason'))}"
                + (f" <font size='7' color='#{DIM.hexval()[2:]}'>[{_txt(sig)}]</font>" if sig else ""),
                S_BODY))
            E.append(Spacer(1, 3))

    plan = res.get("plan") or {}
    if plan:
        E.append(Paragraph("REVIVAL PLAN" if plan.get("mode") == "revival"
                           else "ENHANCEMENT PLAN", S_H2))
        E.append(Paragraph(_txt(plan.get("pitch")), S_VERDICT))
        E.append(Spacer(1, 5))
        for i, m in enumerate(plan.get("moves") or [], 1):
            E.append(Paragraph(f"<b>{i}. {_txt(m.get('title'))}</b> — {_txt(m.get('detail'))}", S_BODY))
            E.append(Spacer(1, 3))
        orders = plan.get("orders") or []
        if orders:
            E.append(Spacer(1, 5))
            rows2: list[list] = [["", "Holding", "Size", "Approx."]]
            for o in orders:
                amt = (f"${o['amount_usd']:,.0f}" + (f" · ~{o['shares']} sh" if o.get("shares") else "")
                       ) if o.get("amount_usd") else "-"
                if o.get("cost_note"):
                    amt += f" (+{o['cost_note']})"
                rows2.append([o["side"], o["ticker"] + (" (new)" if o.get("is_new") else ""),
                              f"{abs(o['delta_pct']) * 100:.1f}% of book", amt])
            E.append(_table(rows2, [18 * mm, 42 * mm, 40 * mm, 45 * mm],
                            aligns=["LEFT", "LEFT", "RIGHT", "RIGHT"]))
        if plan.get("cash"):
            E.append(Spacer(1, 3))
            E.append(Paragraph(f"<b>Unallocated cash:</b> {plan['cash'] * 100:.1f}% of the book kept as dry powder for the "
                               f"watchlist — the committee's ideas could not absorb every sale at sensible sizes.", S_BODY))
        if plan.get("do_nothing"):
            E.append(Spacer(1, 4))
            E.append(Paragraph("<b>Do nothing.</b> Nothing clears the hurdle for a trade; inactivity is the recommendation until a sell trigger fires.", S_BODY))
        desk = []
        for x in plan.get("tax_plan") or []:
            desk.append(f"<b>Tax plan {_txt(x['ticker'])}:</b> {_txt(x['note'])}")
        ra, rb = plan.get("risk_contributions_after") or {}, plan.get("risk_contributions") or {}
        if ra.get("top"):
            desk.append("<b>Where the risk sits after the trade:</b> " + _txt(", ".join(f"{t} {p * 100:.0f}%" for t, p in ra["top"][:5]))
                        + (" <font size='7'>(today: " + _txt(", ".join(f"{t} {p * 100:.0f}%" for t, p in rb["top"][:5])) + ")</font>" 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 {}
            desk.append(f"<b>Valuation risk/reward</b> (where the book would trade if every holding converged to its bear / base / bull value, probability-weighted, {c['coverage'] * 100:.0f}% of the book): "
                        f"today 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 ""))
        if desk:
            E.append(Spacer(1, 5))
            E.append(Paragraph("<b>Execution — the manager's desk</b>", S_BODY))
            for b in desk:
                E.append(Paragraph(b, S_BODY)); E.append(Spacer(1, 2))
        wl = plan.get("watchlist") or []
        if wl:
            E.append(Spacer(1, 4))
            E.append(Paragraph("<b>Watchlist — the bench, with entry conditions</b>", S_BODY))
            rows_w: list[list] = [["Ticker", "Entry condition", "Why it is on the bench"]]
            for x in wl:
                rows_w.append([_txt(x["ticker"]), Paragraph(_txt(x["condition"]), S_CELL), Paragraph(_txt(x.get("why")), S_CELL)])
            E.append(_table(rows_w, [22 * mm, 75 * mm, 75 * mm]))
        cats = plan.get("catalysts") or []
        if cats:
            E.append(Spacer(1, 4))
            E.append(Paragraph("<b>Catalysts — next 90 days:</b> " + _txt(", ".join(
                f"{x['ticker']} {x['event']} {x['date']}" for x in cats)), S_BODY))
        trip = [(r["ticker"], (r.get("us") or {}).get("sell_triggers")) for r in res.get("holdings", [])
                if r.get("action") != "Sell" and (r.get("us") or {}).get("sell_triggers")]
        if trip:
            E.append(Spacer(1, 4))
            E.append(Paragraph("<b>Tripwires — what would make us sell</b>", S_BODY))
            rows_t: list[list] = [["Holding", "Tripwire"]]
            for t, tr in trip[:14]:
                rows_t.append([_txt(t), Paragraph(_txt(tr[0]), S_CELL)])
            E.append(_table(rows_t, [22 * mm, 150 * mm]))
        for hp in plan.get("harvest_pairs") or []:
            E.append(Spacer(1, 3))
            E.append(Paragraph(f"<b>Tax-loss harvest:</b> sell {_txt(hp['sell'])}, buy {_txt(hp['buy'])} ({_txt(hp['buy_name'])}, correlation {hp['correlation']}) - {_txt(hp['note'])}", S_BODY))
        pmx = res.get("premortem")
        if pmx and pmx.get("story"):
            E.append(Spacer(1, 4))
            E.append(Paragraph(f"<b>Pre-mortem ({_txt(pmx['ticker'])}):</b> {_txt(pmx['story'])}", S_BODY))
        tri = plan.get("triage") or []
        if tri:
            E.append(Spacer(1, 6))
            E.append(Paragraph("<b>Losing-position triage</b>", S_BODY))
            E.append(Spacer(1, 2))
            rows3: list[list] = [["Holding", "Call", "Why"]]
            for t in tri:
                rows3.append([_txt(t.get("ticker")), _txt(t.get("disposition")),
                              Paragraph(_txt(t.get("note")), S_CELL)])
            E.append(_table(rows3, [22 * mm, 28 * mm, 122 * mm]))

    target = res.get("target_portfolio") or []
    if target:
        E.append(Paragraph("RECOMMENDED TARGET PORTFOLIO", S_H2))
        E.append(Paragraph("Committee conviction sizes positions under risk-profile caps. "
                           "Educational illustration, not advice.", S_DIM))
        E.append(Spacer(1, 4))
        rows = [["Holding", "Sector", "Current", "Target", "Change"]]
        for r in target:
            name = r["ticker"] + ("  (new)" if r.get("is_new") else "")
            rows.append([_txt(name), _txt(r.get("sector")), _pct(r["current_w"], signed=False),
                         _pct(r["target_w"], signed=False), f"{r['delta'] * 100:+.1f}pp"])
        E.append(_table(rows, [38 * mm, 55 * mm, 22 * mm, 22 * mm, 22 * mm],
                        aligns=["LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT"]))

    if res.get("llm_used"):
        # one line per holding — the old prose dump ran ~7 pages on a 47-name book and
        # nobody reads it; the full reasoning stays on screen in the expandable rows
        E.append(Paragraph("APPENDIX — COMMITTEE SCORES AT A GLANCE", S_H2))
        E.append(Paragraph("Conviction 0-100 per lens (50 = neutral). Full reasoning and the "
                           "headlines weighed are in the app under each holding's row.", S_DIM))
        E.append(Spacer(1, 4))
        lens_order = ("value", "growth", "macro", "technical", "risk")
        rows4: list[list] = [["Holding", "Action", "Val", "Gro", "Mac", "Tec", "Rsk",
                              "vs intr.", "Committee's top concern"]]
        for r in res.get("holdings", []):
            scores = r.get("lens_scores") or {}
            cells = [f"{scores[k]:.0f}" if k in scores else "-" for k in lens_order]
            concern = (r.get("risks") or [""])[0]
            c = _ACTION_COLOR.get(r.get("action"), DIM)
            rows4.append([
                _txt(r["ticker"]),
                Paragraph(f"<font color='#{c.hexval()[2:]}'><b>{_txt(r.get('action'))}</b></font>", S_CELL),
                *cells, _pct(r.get("upside")),
                Paragraph(_txt(concern)[:140], S_CELL)])
        E.append(_table(rows4, [18 * mm, 17 * mm, 9 * mm, 9 * mm, 9 * mm, 9 * mm, 9 * mm,
                                15 * mm, 77 * mm],
                        aligns=["LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "RIGHT", "RIGHT",
                                "RIGHT", "LEFT"]))

    ds = res.get("data_sources") or []
    if ds:
        diag = res.get("diagnostics") or {}
        E.append(Paragraph("DATA SOURCES & METHODOLOGY", S_H2))
        E.append(Paragraph(_txt(
            f"Benchmark {(res.get('benchmark') or {}).get('name', 'S&P 500')} · base currency "
            f"{res.get('base_currency', 'USD')} · methodology {res.get('methodology_version', '?')} · "
            f"prompt {diag.get('prompt_version', '?')} · models "
            + ", ".join(f"{k} {v}" for k, v in (diag.get("models") or {}).items() if v)), S_DIM))
        E.append(Spacer(1, 3))
        rows_d: list[list] = [["Holding", "Fundamentals", "As of", "Price as of"]]
        for d in ds:
            rows_d.append([_txt(d["ticker"]), _txt(d.get("fundamentals") or "?"),
                           _txt(d.get("fund_asof") or "—"), _txt(d.get("price_asof") or "—")])
        E.append(_table(rows_d, [30 * mm, 50 * mm, 35 * mm, 45 * mm],
                        aligns=["LEFT", "LEFT", "LEFT", "LEFT"]))

    E.append(Spacer(1, 10))
    E.append(Paragraph(_txt(res.get("disclaimer", "")), S_DIM))
    doc.build(E)
    return buf.getvalue()