slab-cli 0.1.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.
slab_cli/display.py ADDED
@@ -0,0 +1,1187 @@
1
+ """Rich display helpers for CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Group
6
+ from rich.text import Text
7
+
8
+ from slab_schemas.cards import CardOut, CardSearchResult, SetOut, SetSearchResult
9
+ from slab_schemas.collection import BreakSearchResult, CollectionResult, LotSearchResult
10
+ from slab_schemas.community import (
11
+ CollectedCard,
12
+ CollectedPlayer,
13
+ CommunityBoard,
14
+ CommunityCard,
15
+ HotPlayer,
16
+ RarestOwnedCard,
17
+ TickerItem,
18
+ ValuableCard,
19
+ )
20
+ from slab_schemas.custom_sets import CustomSetDetail, CustomSetOut, CustomSetSearchResult
21
+ from slab_schemas.dashboard import CatalogStats, DashboardStats, HighlightCard, LabeledCount
22
+ from slab_schemas.enums import Grade
23
+ from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTopCards
24
+ from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
25
+ from slab_schemas.timeseries import CardPriceHistory
26
+
27
+ from .theme import (
28
+ bar,
29
+ console,
30
+ heading,
31
+ kv_grid,
32
+ money,
33
+ pct_coverage,
34
+ section,
35
+ signed_money,
36
+ slab_panel,
37
+ slab_table,
38
+ sparkline,
39
+ titled,
40
+ )
41
+
42
+
43
+ def format_grade_delta(delta: float | None) -> str | None:
44
+ """Human phrasing for a self-vs-pro grade gap (e.g. '+1 (optimistic)'). None if not comparable."""
45
+ if delta is None:
46
+ return None
47
+ if delta == 0:
48
+ return "spot on"
49
+ n = int(delta) if float(delta).is_integer() else delta
50
+ if delta < 0:
51
+ return f"{n} (optimistic)"
52
+ return f"+{n} (pessimistic)"
53
+
54
+
55
+ def _grade_name(g) -> str:
56
+ """Human label for a Grade rung — 'excellent', 'near mint mint' — from the enum or its wire value."""
57
+ name = getattr(g, "name", None)
58
+ if name is None:
59
+ try:
60
+ name = Grade(g).name
61
+ except ValueError:
62
+ return str(g)
63
+ return name.replace("_", " ")
64
+
65
+
66
+ def _format_collection_grade(cp) -> str:
67
+ """Compose the Grade column: pro grade and/or self-grade, with a compact delta when both exist."""
68
+ pro = f"{cp.grading_company} {cp.grade}".strip() if cp.grading_company else None
69
+ sg = f"{_grade_name(cp.self_grade)} (self)" if cp.self_grade else None
70
+ if pro and sg:
71
+ suffix = ""
72
+ if cp.grade_delta is not None:
73
+ n = int(cp.grade_delta) if float(cp.grade_delta).is_integer() else cp.grade_delta
74
+ sign = f"+{n}" if cp.grade_delta > 0 else str(n)
75
+ suffix = f" (Δ{sign})"
76
+ return f"{pro} · {sg}{suffix}"
77
+ return pro or sg or "Raw"
78
+
79
+
80
+ def format_card_summary(card: CardOut) -> str:
81
+ """One-line summary of a card for inline display. Shows the subset (the line) and the parallel
82
+ finish only when there is one — so a no-finish INSERT reads as its insert name, not 'Base'."""
83
+ subjects = ", ".join(s.name for s in card.subjects) or "—"
84
+ slot = (card.subset or "?") + (f" · {card.finish}" if card.finish else "")
85
+ pr = f" /{card.print_run}" if card.print_run else ""
86
+ attrs = f" [{', '.join(a.name for a in card.attributes)}]" if card.attributes else ""
87
+ return f"{card.card_number} {subjects} — {card.brand or ''} {card.set_name or ''} — {slot}{pr}{attrs}"
88
+
89
+
90
+ def _more(shown: int, total: int, hint: str) -> None:
91
+ """Standard 'N of M shown' footer — one place so every list truncates the same way."""
92
+ if total > shown:
93
+ console.print(f" [slab.dim]{shown} of {total} shown — {hint}[/]")
94
+
95
+
96
+ def print_cards(result: CardSearchResult) -> None:
97
+ if not result.items:
98
+ console.print("[slab.dim]No cards found.[/]")
99
+ return
100
+
101
+ table = slab_table([
102
+ ("Number", {"style": "slab.value"}),
103
+ ("Subject(s)", {}),
104
+ ("Set", {}),
105
+ ("Subset", {}),
106
+ ("Finish", {"style": "slab.accent"}),
107
+ ("Attrs", {"style": "slab.dim"}),
108
+ ("Run", {"justify": "right", "style": "slab.dim"}),
109
+ ("UUID", {"style": "slab.muted"}),
110
+ ])
111
+
112
+ for c in result.items:
113
+ subjects = ", ".join(s.name for s in c.subjects)
114
+ attrs = ", ".join(a.name for a in c.attributes)
115
+ finish = c.finish or "[slab.dim]Base[/]"
116
+ pr = f"/{c.print_run}" if c.print_run else ""
117
+ table.add_row(
118
+ c.card_number, subjects, c.set_name or "", c.subset or "",
119
+ finish, attrs, pr, c.uuid[:8],
120
+ )
121
+
122
+ console.print(heading("Cards", f"{result.total} total"))
123
+ console.print(table)
124
+ _more(len(result.items), result.total, "narrow your search, or `--limit N` to see more.")
125
+
126
+
127
+ def print_sets(result: SetSearchResult) -> None:
128
+ if not result.items:
129
+ console.print("[slab.dim]No sets found.[/]")
130
+ return
131
+
132
+ table = slab_table([
133
+ ("Name", {"style": "slab.value"}),
134
+ ("Brand", {}),
135
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
136
+ ("Priced", {"justify": "right"}),
137
+ ("Sales/90d", {"justify": "right", "style": "slab.dim"}),
138
+ ("Box FMV", {"justify": "right"}),
139
+ ])
140
+
141
+ for s in result.items:
142
+ # Priced = printings with a sale in the past 90 days; the % against the full card
143
+ # count says how much of the set the market actually prices (color = health band).
144
+ priced = "[slab.dim]—[/]"
145
+ if s.priced_count:
146
+ pct = f" ({pct_coverage(100 * s.priced_count / s.card_count)})" if s.card_count else ""
147
+ priced = f"{s.priced_count:,}{pct}"
148
+ box = f"[slab.foil]{money(s.box_price)}[/]" if s.box_price is not None else "[slab.dim]—[/]"
149
+ table.add_row(
150
+ s.name or "", s.brand or "",
151
+ f"{s.card_count or 0:,}",
152
+ priced,
153
+ f"{s.sales_90d:,}" if s.sales_90d else "[slab.dim]—[/]",
154
+ box,
155
+ )
156
+
157
+ console.print(heading("Sets", f"{result.total} total"))
158
+ console.print(table)
159
+ _more(len(result.items), result.total, "narrow with a query (e.g. `slab set search platinum`).")
160
+
161
+
162
+ def print_collection(result: CollectionResult) -> None:
163
+ if not result.items:
164
+ console.print("[dim]No cards in your collection matching that search.[/dim]")
165
+ if result.total == 0:
166
+ console.print(" [dim]Use `slab collection add` to start cataloging cards.[/dim]")
167
+ return
168
+
169
+ table = slab_table([
170
+ ("Card #", {"style": "slab.value"}),
171
+ ("Subject(s)", {}),
172
+ ("Set", {}),
173
+ ("Finish", {"style": "slab.accent"}),
174
+ ("Grade", {"style": "slab.dim"}),
175
+ ("Cost Basis", {"justify": "right"}),
176
+ ("FMV", {"justify": "right"}),
177
+ ("Gain/Loss", {"justify": "right"}),
178
+ ])
179
+
180
+ for cp in result.items:
181
+ card = cp.card
182
+ subjects = ", ".join(s.name for s in card.subjects) if card and card.subjects else "—"
183
+ set_name = (card.set_name or "") if card else ""
184
+ finish = ((card.finish if card else None) or "[slab.dim]Base[/]")
185
+ card_num = card.card_number if card else "—"
186
+ grade = _format_collection_grade(cp)
187
+ basis = money(cp.cost_basis) if cp.cost_basis is not None else "[slab.dim]—[/]"
188
+ fmv = "[slab.dim]—[/]"
189
+ gain = "[slab.dim]—[/]"
190
+ if cp.market and cp.market.fair_market_value is not None:
191
+ fmv = f"[slab.foil]{money(cp.market.fair_market_value)}[/]"
192
+ gain = signed_money(cp.market.unrealized_gain_loss)
193
+ table.add_row(card_num, subjects, set_name, finish, grade, basis, fmv, gain)
194
+
195
+ matches = f"{result.total} match{'' if result.total == 1 else 'es'}"
196
+ console.print(heading("Collection", matches))
197
+ console.print(table)
198
+
199
+ if result.summary:
200
+ s = result.summary
201
+ parts = []
202
+ if s.total_acquisition_cost is not None:
203
+ parts.append(f"[slab.label]Acquisition[/] {money(s.total_acquisition_cost)}")
204
+ if s.total_additional_costs is not None:
205
+ parts.append(f"[slab.label]Additional[/] {money(s.total_additional_costs)}")
206
+ if s.total_cost_basis is not None:
207
+ parts.append(f"[slab.label]Cost Basis[/] {money(s.total_cost_basis)}")
208
+ if isinstance(s, PortfolioSummary) and s.portfolio_value is not None:
209
+ parts.append(f"[slab.label]FMV[/] [slab.foil]{money(s.portfolio_value)}[/]")
210
+ if s.total_unrealized_gain_loss is not None:
211
+ parts.append(f"[slab.label]Unrealized[/] {signed_money(s.total_unrealized_gain_loss)}")
212
+ if parts:
213
+ console.print(" " + " ".join(parts))
214
+
215
+ _more(len(result.items), result.total, "narrow your search, or `slab export` for the full list.")
216
+
217
+
218
+ def print_breaks(result: BreakSearchResult) -> None:
219
+ if not result.items:
220
+ console.print("[slab.dim]No breaks found.[/]")
221
+ console.print(" [slab.dim]Use `slab break new` to record opening a pack, box, or case.[/]")
222
+ return
223
+
224
+ table = slab_table([
225
+ ("Set", {"style": "slab.value"}),
226
+ ("Type", {"style": "slab.dim"}),
227
+ ("Cost", {"justify": "right"}),
228
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
229
+ ("$/Card", {"justify": "right", "style": "slab.dim"}),
230
+ ("Date", {"style": "slab.dim"}),
231
+ ("Source", {"style": "slab.dim"}),
232
+ ("UUID", {"style": "slab.muted"}),
233
+ ])
234
+
235
+ for b in result.items:
236
+ cpc = money(b.cost_per_card) if b.cost_per_card is not None else "—"
237
+ table.add_row(
238
+ b.set_name or "", b.break_type, money(b.total_cost),
239
+ str(b.copy_count), cpc, str(b.break_date or "—"),
240
+ b.source or "—", b.uuid[:8],
241
+ )
242
+
243
+ console.print(heading("Breaks", f"{result.total} total"))
244
+ console.print(table)
245
+ _more(len(result.items), result.total, "refine with filters.")
246
+
247
+
248
+ def _pull_row(cp) -> tuple[str, str, str, str, str]:
249
+ """One pull as (card #, subjects, finish, run/serial, FMV) — shared by both pull tables."""
250
+ card = cp.card
251
+ subjects = ", ".join(s.name for s in card.subjects) if card and card.subjects else "—"
252
+ finish = ((card.finish if card else None) or "[slab.dim]Base[/]")
253
+ run = ""
254
+ if card and card.print_run:
255
+ run = f"{cp.serial_number}/{card.print_run}" if cp.serial_number else f"/{card.print_run}"
256
+ fmv = "[slab.dim]—[/]"
257
+ if cp.market and cp.market.fair_market_value is not None:
258
+ fmv = f"[slab.foil]{money(cp.market.fair_market_value)}[/]"
259
+ return (card.card_number if card else "—", subjects, finish, run, fmv)
260
+
261
+
262
+ def _pull_table(pulls) -> object:
263
+ table = slab_table([
264
+ ("Card #", {"style": "slab.value"}),
265
+ ("Subject(s)", {}),
266
+ ("Finish", {"style": "slab.accent"}),
267
+ ("Run", {"justify": "right", "style": "slab.dim"}),
268
+ ("FMV", {"justify": "right"}),
269
+ ])
270
+ for cp in pulls:
271
+ table.add_row(*_pull_row(cp))
272
+ return table
273
+
274
+
275
+ def render_break_pulls(brk, result: CollectionResult) -> None:
276
+ """A break's report card: what you paid vs what the pulls are worth today, then the most
277
+ valuable pulls and the rarest (lowest print run). Every number in plain words — this view
278
+ answers "did the box pay for itself?" at a glance."""
279
+ console.print()
280
+ t = Text()
281
+ t.append(brk.set_name or "Break", style="slab.value")
282
+ t.append(f" — {brk.break_type} · {brk.break_date or '—'}"
283
+ + (f" · {brk.source}" if brk.source else ""), style="slab.dim")
284
+ console.print(t)
285
+
286
+ if not result.items:
287
+ console.print("\n [slab.dim]No pulls cataloged for this break yet — "
288
+ "`slab break fill` walks the checklist.[/]\n")
289
+ return
290
+
291
+ def _fmv(cp):
292
+ return cp.market.fair_market_value if (cp.market and cp.market.fair_market_value is not None) else None
293
+
294
+ priced = [cp for cp in result.items if _fmv(cp) is not None]
295
+ total_fmv = sum(_fmv(cp) * (cp.quantity or 1) for cp in priced)
296
+ unpriced = len(result.items) - len(priced)
297
+
298
+ # The money shot, in plain words: cost in, value out, and what that means.
299
+ net = total_fmv - brk.total_cost
300
+ verdict = "the box has paid for itself" if net >= 0 else "the box hasn't paid for itself (yet)"
301
+ console.print(
302
+ f"\n You paid [slab.value]{money(brk.total_cost)}[/] · your {len(result.items)} pulls are "
303
+ f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
304
+ )
305
+ if unpriced:
306
+ console.print(f" [slab.dim]{unpriced} pull(s) have no market price yet and count as $0 here — "
307
+ f"the real total can only be higher.[/]")
308
+
309
+ top = sorted(priced, key=_fmv, reverse=True)[:10]
310
+ if top:
311
+ console.print(heading("Most Valuable Pulls", f"top {len(top)} of {len(priced)} priced"))
312
+ console.print(_pull_table(top))
313
+
314
+ rare = sorted(
315
+ (cp for cp in result.items if cp.card and cp.card.print_run),
316
+ key=lambda cp: cp.card.print_run,
317
+ )[:5]
318
+ if rare:
319
+ console.print(heading("Rarest Pulls", "lowest print run — fewest copies exist"))
320
+ console.print(_pull_table(rare))
321
+ console.print()
322
+
323
+
324
+ def print_lots(result: LotSearchResult) -> None:
325
+ if not result.items:
326
+ console.print("[slab.dim]No purchases found.[/]")
327
+ console.print(" [slab.dim]Use `slab lot new` to record a multi-card buy (an eBay bundle, a show pickup).[/]")
328
+ return
329
+
330
+ table = slab_table([
331
+ ("Date", {"style": "slab.dim"}),
332
+ ("Cost", {"justify": "right"}),
333
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
334
+ ("$/Card", {"justify": "right", "style": "slab.dim"}),
335
+ ("Source", {"style": "slab.dim"}),
336
+ ("UUID", {"style": "slab.muted"}),
337
+ ])
338
+
339
+ for lot in result.items:
340
+ cpc = money(lot.cost_per_card) if lot.cost_per_card is not None else "—"
341
+ table.add_row(
342
+ str(lot.lot_date or "—"), money(lot.total_cost),
343
+ str(lot.copy_count), cpc, lot.source or "—", lot.uuid[:8],
344
+ )
345
+
346
+ console.print(heading("Purchases", f"{result.total} total"))
347
+ console.print(table)
348
+ _more(len(result.items), result.total, "refine with filters.")
349
+
350
+
351
+ def render_lot_contents(lot, result: CollectionResult) -> None:
352
+ """A purchase's report card: what you paid vs what the cards are worth today, then the most
353
+ valuable and rarest cards in it. Answers "did that bundle come out ahead?" at a glance — the
354
+ lot counterpart of `render_break_pulls`."""
355
+ console.print()
356
+ t = Text()
357
+ t.append(f"Purchase {lot.uuid[:8]}", style="slab.value")
358
+ t.append(f" — {lot.lot_date or '—'}" + (f" · {lot.source}" if lot.source else ""), style="slab.dim")
359
+ console.print(t)
360
+
361
+ if not result.items:
362
+ console.print("\n [slab.dim]No cards attached to this purchase yet — "
363
+ "`slab lot fill` adds them one by one.[/]\n")
364
+ return
365
+
366
+ def _fmv(cp):
367
+ return cp.market.fair_market_value if (cp.market and cp.market.fair_market_value is not None) else None
368
+
369
+ priced = [cp for cp in result.items if _fmv(cp) is not None]
370
+ total_fmv = sum(_fmv(cp) * (cp.quantity or 1) for cp in priced)
371
+ unpriced = len(result.items) - len(priced)
372
+
373
+ net = total_fmv - lot.total_cost
374
+ verdict = "the purchase is above water" if net >= 0 else "the purchase is underwater (for now)"
375
+ console.print(
376
+ f"\n You paid [slab.value]{money(lot.total_cost)}[/] · its {len(result.items)} cards are "
377
+ f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
378
+ )
379
+ if unpriced:
380
+ console.print(f" [slab.dim]{unpriced} card(s) have no market price yet and count as $0 here — "
381
+ f"the real total can only be higher.[/]")
382
+
383
+ top = sorted(priced, key=_fmv, reverse=True)[:10]
384
+ if top:
385
+ console.print(heading("Most Valuable", f"top {len(top)} of {len(priced)} priced"))
386
+ console.print(_pull_table(top))
387
+
388
+ rare = sorted(
389
+ (cp for cp in result.items if cp.card and cp.card.print_run),
390
+ key=lambda cp: cp.card.print_run,
391
+ )[:5]
392
+ if rare:
393
+ console.print(heading("Rarest", "lowest print run — fewest copies exist"))
394
+ console.print(_pull_table(rare))
395
+ console.print()
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Dashboards (collection + slab-wide) — shared visual helpers
400
+ # ---------------------------------------------------------------------------
401
+
402
+ def _dist_panel(title: str, items: list[LabeledCount]) -> Group | None:
403
+ """A ranked distribution as bars — heading + slate card. None when empty."""
404
+ if not items:
405
+ return None
406
+ maxv = max(i.count for i in items)
407
+ # Cap labels generously — set names like "2025-26 Upper Deck Series One" run past 24 chars and
408
+ # were being clipped; 40 fits typical set names while keeping the bar/count columns aligned.
409
+ lines = "\n".join(
410
+ f"{bar(i.count, maxv)} [slab.value]{i.count:>6,}[/] {i.label[:40]}" for i in items
411
+ )
412
+ return section(title, Text.from_markup(lines))
413
+
414
+
415
+ def _highlight_panel(title: str, cards: list[HighlightCard], show_cost: bool = False) -> Group | None:
416
+ """A short list of standout cards — heading + slate card. None when empty."""
417
+ if not cards:
418
+ return None
419
+ rows = []
420
+ for h in cards:
421
+ subj = ", ".join(h.subjects) or "—"
422
+ slot = (h.subset or "?") + (f" · {h.finish}" if h.finish else "")
423
+ run = f" /{h.print_run}" if h.print_run else ""
424
+ cost = f" [slab.gain]{money(h.cost_basis)}[/]" if (show_cost and h.cost_basis is not None) else ""
425
+ rows.append(f"[slab.value]#{h.card_number}[/] {subj} [slab.dim]{slot}{run}[/]{cost}")
426
+ return section(title, Text.from_markup("\n".join(rows)))
427
+
428
+
429
+ def _n(count: int, sing: str, plur: str | None = None) -> str:
430
+ """Count + correctly-pluralized noun: '1 break' / '2 breaks'."""
431
+ return f"{count:,} {sing if count == 1 else (plur or sing + 's')}"
432
+
433
+
434
+ def _breakdown_panel(title: str, total: int, parts: list[tuple[str, int, str]]) -> Group:
435
+ """A 'sum' card: an explicit `a + b = total` line over a single stacked bar, so the parts
436
+ visibly add up to the whole. `parts` is [(label, count, style), ...]."""
437
+ width = 46
438
+ segs, terms, used = [], [], 0
439
+ for i, (label, count, style) in enumerate(parts):
440
+ w = (width - used) if i == len(parts) - 1 else (round(count / total * width) if total else 0)
441
+ used += w
442
+ segs.append(f"[{style}]{'█' * w}[/]")
443
+ pct = f"{count / total:.0%}" if total else "0%"
444
+ terms.append(f"[{style}]{count:,} {label}[/] [slab.dim]({pct})[/]")
445
+ body = " [slab.dim]+[/] ".join(terms) + f" [slab.dim]=[/] [slab.value]{total:,} total[/]\n" + "".join(segs)
446
+ return section(title, Text.from_markup(body))
447
+
448
+
449
+ def _composition_panel(*, rookies, autos, relics, numbered, one_of_ones, footer=None) -> Group:
450
+ """The rookie/auto/relic/numbered/1-of-1 attribute strip — attributes in accent, the 1/1 in
451
+ foil (the rarest = the hero highlight). Shared by the collection + catalog dashboards."""
452
+ line = (
453
+ f"[slab.accent]🌟 {_n(rookies, 'rookie')}[/] [slab.accent]✍️ {_n(autos, 'auto')}[/] "
454
+ f"[slab.accent]🧵 {_n(relics, 'relic')}[/] [slab.value]#️⃣ {numbered:,} numbered[/] "
455
+ f"[slab.foil]💎 {_n(one_of_ones, '1/1')}[/]"
456
+ )
457
+ if footer:
458
+ line += f"\n[slab.dim]{footer}[/]"
459
+ return section("Composition", Text.from_markup(line))
460
+
461
+
462
+ def render_dashboard(d: DashboardStats) -> None:
463
+ console.print(slab_panel(Text.from_markup(
464
+ f"[slab.foil]🃏 {d.collector}'s Collection[/]\n"
465
+ f"[slab.dim]{_n(d.total_cards, 'card')} · {_n(d.players, 'player')} · "
466
+ f"{_n(d.teams, 'team')} · {_n(d.breaks, 'break')} · {_n(d.lots, 'purchase')}[/]"
467
+ ), foil=True))
468
+ # Build each section, then arrange into horizontal rows (they stack automatically on a terminal
469
+ # too narrow to fit them side by side — see theme.row).
470
+ cards = _breakdown_panel("Cards", d.total_cards, [
471
+ ("base", d.base_count, "slab.accent"),
472
+ ("parallels", d.parallel_count, "slab.foil"),
473
+ ])
474
+
475
+ rows: list[tuple[str, str]] = [(
476
+ "Cost Basis",
477
+ f"{money(d.total_cost_basis)} [slab.dim]avg {money(d.avg_cost_per_card)} / card[/]",
478
+ )]
479
+ if d.portfolio_value is not None:
480
+ mv = f"[slab.foil]{money(d.portfolio_value)}[/]"
481
+ if d.portfolio_change_7d is not None:
482
+ mv += f" {signed_money(d.portfolio_change_7d)} [slab.dim]appraisal 7d[/]"
483
+ rows.append(("Market Value", mv))
484
+ if d.total_unrealized_gain_loss is not None:
485
+ roi_str = ""
486
+ if d.portfolio_roi is not None:
487
+ sign = "+" if d.total_unrealized_gain_loss >= 0 else ""
488
+ roi_str = f" [slab.dim]({sign}{float(d.portfolio_roi) * 100:.1f}% ROI)[/]"
489
+ rows.append(("Unrealized", f"{signed_money(d.total_unrealized_gain_loss)}{roi_str}"))
490
+ if d.priced_coverage is not None:
491
+ rows.append(("Coverage", f"{pct_coverage(float(d.priced_coverage) * 100)} [slab.dim]priced[/]"))
492
+ # Portfolio value over time — the CLI stand-in for the portal's chart, from the same payload.
493
+ if len(d.portfolio_series) >= 2:
494
+ vals = [p.portfolio_value for p in d.portfolio_series]
495
+ net = float(vals[-1]) - float(vals[0])
496
+ rows.append(("Trend 90d", f"{sparkline(vals)} {signed_money(net)}"))
497
+ value = section("Value", kv_grid(rows), foil=True)
498
+
499
+ comp = _composition_panel(
500
+ rookies=d.rookies, autos=d.autos, relics=d.relics, numbered=d.numbered,
501
+ one_of_ones=d.one_of_ones, footer=f"{d.graded_count} graded · {d.raw_count} raw",
502
+ )
503
+ # How many cards you own from each set — the collection's "Biggest Sets".
504
+ your_sets = _dist_panel("Your Sets", d.top_sets)
505
+ rarest = _highlight_panel("💎 Rarest", d.rarest)
506
+
507
+ breaks = None
508
+ if d.break_stats:
509
+ table = slab_table([
510
+ ("Date", {"style": "slab.dim"}),
511
+ ("Product", {"style": "slab.value"}),
512
+ ("Type", {"style": "slab.dim"}),
513
+ ("Cost", {"justify": "right"}),
514
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
515
+ ("$/card", {"justify": "right", "style": "slab.dim"}),
516
+ ])
517
+ for b in d.break_stats:
518
+ table.add_row(str(b.break_date or "—"), b.set_name or "—", b.break_type or "—",
519
+ money(b.total_cost), str(b.cards), money(b.cost_per_card))
520
+ breaks = titled("Breaks", table)
521
+
522
+ lots = None
523
+ if d.lot_stats:
524
+ table = slab_table([
525
+ ("Date", {"style": "slab.dim"}),
526
+ ("Source", {"style": "slab.value"}),
527
+ ("Cost", {"justify": "right"}),
528
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
529
+ ("$/card", {"justify": "right", "style": "slab.dim"}),
530
+ ])
531
+ for lot in d.lot_stats:
532
+ table.add_row(str(lot.lot_date or "—"), lot.source or "—",
533
+ money(lot.total_cost), str(lot.cards), money(lot.cost_per_card))
534
+ lots = titled("Purchases", table)
535
+
536
+ console.print(cards)
537
+ console.print(value)
538
+ console.print(comp)
539
+ if your_sets:
540
+ console.print(your_sets)
541
+ if breaks:
542
+ console.print(breaks)
543
+ if lots:
544
+ console.print(lots)
545
+ if rarest:
546
+ console.print(rarest)
547
+
548
+
549
+ def _catalog_stat_panels(c: CatalogStats, *, title: str = "📚 slab catalog") -> list:
550
+ """The catalog-overview renderables (header + card breakdown + composition + biggest sets),
551
+ the top of the `slab community` board."""
552
+ panels: list = [slab_panel(Text.from_markup(
553
+ f"[slab.foil]{title}[/]\n"
554
+ f"[slab.dim]{_n(c.sets, 'set')} · {_n(c.players, 'player')} · {_n(c.teams, 'team')}[/]"
555
+ ), foil=True)]
556
+ # Cards = base + parallels, shown so the parts visibly add up.
557
+ panels.append(_breakdown_panel("Cards", c.total_cards, [
558
+ ("base", c.base_cards, "slab.accent"),
559
+ ("parallels", c.parallels, "slab.foil"),
560
+ ]))
561
+ panels.append(_composition_panel(
562
+ rookies=c.rookies, autos=c.autos, relics=c.relics,
563
+ numbered=c.numbered, one_of_ones=c.one_of_ones,
564
+ ))
565
+ p = _dist_panel("Biggest Sets", c.biggest_sets)
566
+ if p:
567
+ panels.append(p)
568
+ return panels
569
+
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # Community board (mirrors the portal's community page)
573
+ # ---------------------------------------------------------------------------
574
+
575
+ def _ccard_label(card: CommunityCard) -> str:
576
+ """One-line markup for a community-leaderboard card: `#num Player — Set · Subset · Finish /run`."""
577
+ subj = ", ".join(card.subjects) or "—"
578
+ ctx = " · ".join(filter(None, [card.set_name, card.subset, card.finish]))
579
+ run = f" [slab.foil]/{card.print_run}[/]" if card.print_run else ""
580
+ tail = f" [slab.dim]{ctx}[/]" if ctx else ""
581
+ return f"[slab.value]#{card.card_number}[/] {subj}{tail}{run}"
582
+
583
+
584
+ def _valuable_table(title: str, entries: list[ValuableCard]) -> Group | None:
585
+ if not entries:
586
+ return None
587
+ table = slab_table([
588
+ ("Card", {}),
589
+ ("Grade", {"style": "slab.dim"}),
590
+ ("FMV", {"justify": "right", "style": "slab.foil"}),
591
+ ("Comps", {"justify": "right", "style": "slab.muted"}),
592
+ ])
593
+ for e in entries:
594
+ table.add_row(Text.from_markup(_ccard_label(e.card)), e.market.grade_key,
595
+ money(e.market.fair_market_value), str(e.market.sample_size))
596
+ return titled(title, table)
597
+
598
+
599
+ def _collected_cards_table(entries: list[CollectedCard]) -> Group | None:
600
+ if not entries:
601
+ return None
602
+ table = slab_table([
603
+ ("Card", {}),
604
+ ("Collectors", {"justify": "right", "style": "slab.value"}),
605
+ ("Copies", {"justify": "right", "style": "slab.dim"}),
606
+ ])
607
+ for e in entries:
608
+ table.add_row(Text.from_markup(_ccard_label(e.card)), str(e.collector_count), str(e.copy_count))
609
+ return titled("Most Collected", table)
610
+
611
+
612
+ def _collected_players_table(entries: list[CollectedPlayer]) -> Group | None:
613
+ if not entries:
614
+ return None
615
+ table = slab_table([
616
+ ("Player", {"style": "slab.value"}),
617
+ ("Collectors", {"justify": "right", "style": "slab.value"}),
618
+ ("Copies", {"justify": "right", "style": "slab.dim"}),
619
+ ])
620
+ for e in entries:
621
+ table.add_row(e.name, str(e.collector_count), str(e.copy_count))
622
+ return titled("Most Collected Players", table)
623
+
624
+
625
+ def _hot_momentum(cur: int, prev: int) -> str:
626
+ """The window-over-window badge in plain terms: 'new' (was silent), ×N up, ×N down, or steady."""
627
+ if prev == 0:
628
+ return "[slab.accent]new[/]"
629
+ ratio = cur / prev
630
+ if ratio >= 1.5:
631
+ return f"[slab.accent]↑{ratio:.1f}×[/]"
632
+ if ratio <= 0.67:
633
+ return f"[slab.dim]↓{prev / cur:.1f}×[/]" if cur else "[slab.dim]↓[/]"
634
+ return "[slab.dim]steady[/]"
635
+
636
+
637
+ def _hot_trend(pct: float | None) -> str:
638
+ """Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down)."""
639
+ if pct is None:
640
+ return "[slab.dim]—[/]"
641
+ if pct > 2:
642
+ return f"[slab.gain]↗ +{pct:.0f}%[/]"
643
+ if pct < -2:
644
+ return f"[slab.loss]↘ {pct:.0f}%[/]"
645
+ return f"[slab.dim]→ {pct:+.0f}%[/]"
646
+
647
+
648
+ def _hottest_players_table(entries: list[HotPlayer]) -> Group | None:
649
+ if not entries:
650
+ return None
651
+ table = slab_table([
652
+ ("Player", {"style": "slab.value"}),
653
+ ("Sales (30d)", {"justify": "right", "style": "slab.value"}),
654
+ ("vs Prior 30d", {"justify": "right"}),
655
+ ("$ Volume", {"justify": "right"}),
656
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
657
+ ("Prices", {"justify": "right"}),
658
+ ])
659
+ for e in entries:
660
+ table.add_row(
661
+ e.name,
662
+ str(e.sales_30d),
663
+ _hot_momentum(e.sales_30d, e.sales_prev_30d),
664
+ money(e.dollar_volume_30d),
665
+ str(e.distinct_cards_30d),
666
+ _hot_trend(e.price_trend_pct),
667
+ )
668
+ return titled("🔥 Hottest Players", table)
669
+
670
+
671
+ def _rarest_owned_table(entries: list[RarestOwnedCard]) -> Group | None:
672
+ if not entries:
673
+ return None
674
+ table = slab_table([
675
+ ("Card", {}),
676
+ ("Collectors", {"justify": "right", "style": "slab.value"}),
677
+ ])
678
+ for e in entries:
679
+ table.add_row(Text.from_markup(_ccard_label(e.card)), str(e.collector_count))
680
+ return titled("💎 Rarest Owned", table)
681
+
682
+
683
+ def _ticker_panel(items: list[TickerItem]) -> Group | None:
684
+ if not items:
685
+ return None
686
+ lines = "\n".join(f"{i.icon} {i.text}" for i in items)
687
+ return section("What's Happening", Text.from_markup(lines))
688
+
689
+
690
+ def render_community_board(b: CommunityBoard) -> None:
691
+ """The whole community picture — catalog overview + activity ticker + leaderboards + popular
692
+ chase sets — mirroring the portal's community page from the single GET /community payload."""
693
+ for panel in _catalog_stat_panels(b.stats, title="🌐 community board"):
694
+ console.print(panel)
695
+
696
+ for block in (
697
+ _ticker_panel(b.ticker),
698
+ _valuable_table("Most Valuable — Raw", b.most_valuable_raw),
699
+ _valuable_table("Most Valuable — Graded", b.most_valuable_graded),
700
+ _collected_cards_table(b.most_collected),
701
+ _collected_players_table(b.most_collected_players),
702
+ _hottest_players_table(b.hottest_players),
703
+ _rarest_owned_table(b.rarest_owned),
704
+ ):
705
+ if block:
706
+ console.print(block)
707
+
708
+ if b.popular_sets:
709
+ table = slab_table([
710
+ ("Name", {"style": "slab.value"}),
711
+ ("Type", {"style": "slab.dim"}),
712
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
713
+ ("Subs", {"justify": "right", "style": "slab.value"}),
714
+ ("Creator", {"style": "slab.dim"}),
715
+ ])
716
+ for s in b.popular_sets:
717
+ table.add_row(s.name, s.set_type, str(s.card_count),
718
+ str(s.subscriber_count), s.creator_name or "—")
719
+ console.print(titled("Popular Chase Sets", table))
720
+
721
+
722
+ # ---------------------------------------------------------------------------
723
+ # Custom sets
724
+ # ---------------------------------------------------------------------------
725
+
726
+ def print_custom_sets(result: CustomSetSearchResult | list[CustomSetOut]) -> None:
727
+ items = result.items if isinstance(result, CustomSetSearchResult) else result
728
+ if not items:
729
+ console.print("[dim]No custom sets found.[/dim]")
730
+ console.print(" [dim]Use `slab chase create` to make one.[/dim]")
731
+ return
732
+
733
+ total = result.total if isinstance(result, CustomSetSearchResult) else len(items)
734
+ table = slab_table([
735
+ ("Name", {"style": "slab.value"}),
736
+ ("Type", {"style": "slab.dim"}),
737
+ ("Vis", {"justify": "center"}),
738
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
739
+ ("Subs", {"justify": "right", "style": "slab.dim"}),
740
+ ("Creator", {}),
741
+ ("UUID", {"style": "slab.muted"}),
742
+ ])
743
+
744
+ for s in items:
745
+ vis = "🔒" if s.visibility == "private" else "🌐"
746
+ sub_marker = " [slab.gain]✓[/]" if s.is_subscribed else ""
747
+ table.add_row(
748
+ s.name, s.set_type, vis, str(s.card_count),
749
+ f"{s.subscriber_count}{sub_marker}", s.creator_name or "—", s.uuid[:8],
750
+ )
751
+
752
+ console.print(heading("Custom Sets", f"{total} total"))
753
+ console.print(table)
754
+
755
+
756
+ def print_custom_set_detail(detail: CustomSetDetail) -> None:
757
+ vis = "Public" if detail.visibility == "public" else "Private"
758
+ sub = "subscribed" if detail.is_subscribed else "not subscribed"
759
+
760
+ head = [f"[slab.dim]{detail.set_type} · {vis} · {sub}[/]"]
761
+ if detail.description:
762
+ head.append(detail.description)
763
+ head.append(f"[slab.label]Creator[/] {detail.creator_name or '—'} "
764
+ f"[slab.label]Subscribers[/] {detail.subscriber_count}")
765
+ head.append(f"[slab.muted]{detail.uuid}[/]")
766
+ if detail.completion:
767
+ c = detail.completion
768
+ style = "slab.gain" if c.completion_pct == 100 else ("slab.foil" if c.completion_pct >= 50 else "slab.loss")
769
+ head.append(
770
+ f"{bar(c.completion_pct, 100, 20, style=style)} "
771
+ f"[slab.value]{c.owned_cards}/{c.total_cards}[/] [slab.dim]({c.completion_pct}%)[/]"
772
+ )
773
+
774
+ console.print(heading(detail.name))
775
+ console.print(slab_panel(Text.from_markup("\n".join(head)), foil=False))
776
+
777
+ if detail.cards:
778
+ table = slab_table([
779
+ ("#", {"style": "slab.dim", "justify": "right"}),
780
+ ("Card", {}),
781
+ ("Match", {"style": "slab.dim"}),
782
+ ("Owned", {"justify": "center"}),
783
+ ])
784
+
785
+ for entry in detail.cards:
786
+ card = entry.card
787
+ subjects = ", ".join(s.name for s in card.subjects) if card else "—"
788
+ slot = (card.subset or "?") + (f" · {card.finish}" if card and card.finish else "")
789
+ pr = f" /{card.print_run}" if card and card.print_run else ""
790
+ label = f"{card.card_number} {subjects} [slab.dim]— {slot}{pr}[/]" if card else "—"
791
+
792
+ match = entry.match_mode
793
+ if entry.match_mode == "exact_serial" and entry.serial_number is not None:
794
+ match = f"serial #{entry.serial_number}"
795
+
796
+ if entry.owned:
797
+ owned = f"[slab.gain]✓[/] [slab.dim]{entry.owned_printing or ''}[/]"
798
+ else:
799
+ owned = "[slab.loss]✗[/]"
800
+ table.add_row(str(entry.position), label, match, owned)
801
+
802
+ console.print(table)
803
+ console.print()
804
+
805
+
806
+ # ---------------------------------------------------------------------------
807
+ # Market pricing
808
+ # ---------------------------------------------------------------------------
809
+
810
+ def _card_headline(card_number, subjects: str, set_name: str | None, slot: str) -> Text:
811
+ """The `#num Player — Set — Subset · Finish` context line above a market/comps/history card."""
812
+ t = Text()
813
+ t.append(f"#{card_number} ", style="slab.value")
814
+ t.append(subjects)
815
+ t.append(f" — {set_name or '—'} — {slot}", style="slab.dim")
816
+ return t
817
+
818
+
819
+ def render_card_market(m: CardMarket) -> None:
820
+ """Display the market pricing for a catalog card — all grade buckets."""
821
+ subjects = ", ".join(m.subjects) or "—"
822
+ slot = (m.subset or "?") + (f" · {m.finish}" if m.finish else "")
823
+ console.print()
824
+ console.print(_card_headline(m.card_number, subjects, m.set_name, slot))
825
+
826
+ if not m.price_points:
827
+ console.print("\n [slab.dim]No market data available for this card.[/]\n")
828
+ return
829
+
830
+ # The Δ columns are FMV (appraisal) drift, not the window's sales — FMV smooths 90 days of
831
+ # comps, so these lag the live market (two-lane rule, CLAUDE.md). Headers say FMV so a
832
+ # "+$5" cell reads as the appraisal moving, not as last week's market.
833
+ table = slab_table([
834
+ ("Grade", {"style": "slab.value"}),
835
+ ("FMV", {"justify": "right"}),
836
+ ("Range", {"justify": "right", "style": "slab.dim"}),
837
+ ("FMV Δ7d", {"justify": "right"}),
838
+ ("FMV Δ30d", {"justify": "right"}),
839
+ ("Comps", {"justify": "right", "style": "slab.dim"}),
840
+ ])
841
+
842
+ for pp in sorted(m.price_points, key=lambda p: (p.finish or "", p.grade_key)):
843
+ label = pp.grade_key
844
+ if pp.finish:
845
+ label = f"{pp.finish} · {pp.grade_key}"
846
+ fmv = f"[slab.foil]{money(pp.price_median)}[/]"
847
+ rng = "—"
848
+ if pp.price_low is not None and pp.price_high is not None:
849
+ rng = f"${pp.price_low:,.0f} – ${pp.price_high:,.0f}"
850
+ table.add_row(label, fmv, rng, signed_money(pp.price_change_7d),
851
+ signed_money(pp.price_change_30d), str(pp.sample_size))
852
+
853
+ window = m.price_points[0].window_days
854
+ console.print(heading("Market Price", f"{window}-day window, as of {m.as_of_date}"))
855
+ console.print(table)
856
+ console.print()
857
+
858
+
859
+ def _format_label(fmt: str) -> str:
860
+ """SealedFormat value -> display label: hobby_box -> Hobby Box."""
861
+ return fmt.replace("_", " ").title()
862
+
863
+
864
+ def _sealed_config(p: SealedProductOut) -> str:
865
+ """A compact config string for a sealed SKU: '12 packs × 8 cards' / '8 boxes'."""
866
+ parts = []
867
+ if p.boxes_per_case:
868
+ parts.append(f"{p.boxes_per_case} boxes")
869
+ if p.packs_per_box:
870
+ parts.append(f"{p.packs_per_box} packs")
871
+ if p.cards_per_pack:
872
+ parts.append(f"{p.cards_per_pack} cards")
873
+ return " × ".join(parts) if parts else "—"
874
+
875
+
876
+ def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCards) -> None:
877
+ """The set market view: every sealed SKU with its FMV, then the priciest cards inside —
878
+ what the wax costs next to what you're chasing in it."""
879
+ console.print()
880
+ t = Text()
881
+ t.append(s.name or s.slug, style="slab.value")
882
+ t.append(f" — {s.brand or '—'} — {s.season or '—'}", style="slab.dim")
883
+ console.print(t)
884
+
885
+ # --- sealed SKUs ---
886
+ priced = [p for p in sealed if p.price_median is not None]
887
+ if not sealed:
888
+ console.print("\n [slab.dim]No sealed products catalogued for this set.[/]")
889
+ else:
890
+ table = slab_table([
891
+ ("Format", {"style": "slab.value"}),
892
+ ("Config", {"style": "slab.dim"}),
893
+ ("FMV", {"justify": "right"}),
894
+ ("MSRP", {"justify": "right", "style": "slab.dim"}),
895
+ ("Comps", {"justify": "right", "style": "slab.dim"}),
896
+ ("As of", {"style": "slab.dim"}),
897
+ ])
898
+ # Priced first (dearest on top), unpriced after so the whole SKU list is still visible.
899
+ # No low-confidence marker here — the Comps column IS the confidence signal (a 1 or 2
900
+ # says "thin" on its own), and a suffix on FMV breaks the right-aligned money column.
901
+ for p in sorted(sealed, key=lambda p: (p.price_median is None, -float(p.price_median or 0))):
902
+ fmv = f"[slab.foil]{money(p.price_median)}[/]" if p.price_median is not None else "[slab.dim]—[/]"
903
+ table.add_row(
904
+ _format_label(p.format.value),
905
+ _sealed_config(p),
906
+ fmv,
907
+ money(p.msrp) if p.msrp is not None else "—",
908
+ str(p.sample_size) if p.sample_size is not None else "—",
909
+ str(p.as_of_date or "—"),
910
+ )
911
+ console.print(heading("Sealed", f"{len(priced)}/{len(sealed)} SKUs priced"))
912
+ console.print(table)
913
+
914
+ # --- top cards ---
915
+ if not top.cards:
916
+ console.print("\n [slab.dim]No card pricing in this set yet.[/]\n")
917
+ return
918
+
919
+ table = slab_table([
920
+ ("#", {"justify": "right", "style": "slab.dim"}),
921
+ ("Card", {"style": "slab.value"}),
922
+ ("Subject", {}),
923
+ ("Slot", {"style": "slab.dim"}),
924
+ ("Grade", {"style": "slab.dim"}),
925
+ ("FMV", {"justify": "right"}),
926
+ ("Comps", {"justify": "right", "style": "slab.dim"}),
927
+ ])
928
+ for i, entry in enumerate(top.cards, 1):
929
+ slot = (entry.subset or "?") + (f" · {entry.finish}" if entry.finish else "")
930
+ if entry.print_run:
931
+ slot += f" /{entry.print_run}"
932
+ fmv = f"[slab.foil]{money(entry.market.fair_market_value)}[/]"
933
+ table.add_row(
934
+ str(i),
935
+ f"#{entry.card_number}",
936
+ ", ".join(entry.subjects) or "—",
937
+ slot,
938
+ entry.market.grade_key,
939
+ fmv,
940
+ str(entry.market.sample_size),
941
+ )
942
+ console.print(heading("Top Cards", f"of {top.total_priced} priced printings"))
943
+ console.print(table)
944
+ console.print(" [slab.dim]Drill in with `slab card price` / `slab card comps`.[/]\n")
945
+
946
+
947
+ def render_comps(c: CardComps) -> None:
948
+ """Display recent comps (raw sales) for a catalog card — the evidence behind its FMV."""
949
+ subjects = ", ".join(c.subjects) or "—"
950
+ slot = (c.subset or "?") + (f" · {c.finish}" if c.finish else "")
951
+ console.print()
952
+ console.print(_card_headline(c.card_number, subjects, c.set_name, slot))
953
+
954
+ if not c.comps:
955
+ console.print("\n [slab.dim]No comps found for this card yet.[/]\n")
956
+ return
957
+
958
+ table = slab_table([
959
+ ("Date", {"style": "slab.dim"}),
960
+ ("Price", {"justify": "right", "style": "slab.foil"}),
961
+ ("Grade", {}),
962
+ ("Market", {"style": "slab.dim"}),
963
+ ("Type", {"style": "slab.dim"}),
964
+ ("Title", {}),
965
+ ])
966
+
967
+ for comp in c.comps:
968
+ when = comp.sold_date.isoformat() if comp.sold_date else "—"
969
+ price = money(comp.sale_price) if comp.sale_price is not None else "—"
970
+ grade = comp.grade_key or "—"
971
+ market = comp.marketplace or "—"
972
+ sale_type = comp.sale_type or "—"
973
+ title = comp.title if len(comp.title) <= 50 else comp.title[:49] + "…"
974
+ table.add_row(when, price, grade, market, sale_type, title)
975
+
976
+ console.print(heading("Recent Comps", f"showing {len(c.comps)} of {c.total}"))
977
+ console.print(table)
978
+ console.print()
979
+
980
+
981
+ def _render_portfolio_by_set(by_set: list | None) -> None:
982
+ """Per-set breakdown, most valuable first. Highlights the top set and shows comp coverage."""
983
+ if not by_set:
984
+ return
985
+
986
+ table = slab_table([
987
+ ("Set", {"style": "slab.value", "no_wrap": False}),
988
+ ("Cards", {"justify": "right", "style": "slab.dim"}),
989
+ ("Cost Basis", {"justify": "right"}),
990
+ ("FMV", {"justify": "right"}),
991
+ ("Gain/Loss", {"justify": "right"}),
992
+ ("Comps", {"justify": "right"}),
993
+ ])
994
+
995
+ for i, st in enumerate(by_set):
996
+ # crown the single most valuable set
997
+ name = f"[slab.foil]★[/] {st['name']}" if i == 0 and st["has_fmv"] else st["name"]
998
+ cards = f"{st['total_qty']:,}"
999
+ basis = money(st["cost_basis"])
1000
+ fmv = f"[slab.foil]{money(st['fmv'])}[/]" if st["has_fmv"] else "[slab.dim]—[/]"
1001
+ gain = signed_money(st["unrealized"])
1002
+ comps = pct_coverage(st["comp_pct"])
1003
+
1004
+ table.add_row(name, cards, basis, fmv, gain, comps)
1005
+
1006
+ console.print(heading("By Set", "most valuable first"))
1007
+ console.print(table)
1008
+
1009
+
1010
+ def _render_most_valuable_break(mvb: dict | None) -> None:
1011
+ """Highlight the single break whose pulled cards carry the most market value — did it pay off?"""
1012
+ if not mvb:
1013
+ return
1014
+
1015
+ date_str = f" · {mvb['break_date']}" if mvb.get("break_date") else ""
1016
+ title = Text()
1017
+ title.append(mvb["set_name"], style="slab.value")
1018
+ title.append(f" {mvb['break_type']}{date_str}", style="slab.dim")
1019
+
1020
+ g = mvb["gain"]
1021
+ verdict = "paid off" if g >= 0 else "underwater"
1022
+ roi = f" [slab.dim]({'+' if g >= 0 else '-'}{abs(g) / mvb['cost'] * 100:.0f}%)[/]" if mvb["cost"] else ""
1023
+
1024
+ body = kv_grid([
1025
+ ("Cost", f"{money(mvb['cost'])} [slab.dim]({_n(mvb['copy_count'], 'card')})[/]"),
1026
+ ("Market Value", f"[slab.foil]{money(mvb['fmv'])}[/] [slab.dim]({mvb['priced']} priced)[/]"),
1027
+ ("Net vs Cost", f"{signed_money(g)}{roi} [slab.dim]· {verdict}[/]"),
1028
+ ])
1029
+
1030
+ console.print(heading("Most Valuable Break"))
1031
+ console.print(slab_panel(Group(title, Text(""), body), foil=True))
1032
+
1033
+
1034
+ def render_portfolio(
1035
+ total: int, totals: dict, movers: list | None = None, by_set: list | None = None,
1036
+ most_valuable_break: dict | None = None,
1037
+ ) -> None:
1038
+ """Display portfolio-level financial summary, per-set breakdown, and optional top movers.
1039
+
1040
+ `totals` is the grand-total dict (cost_basis, fmv, unrealized, roi, priced_qty, total_qty)
1041
+ built from the server's PortfolioSummary — see `_portfolio_totals`."""
1042
+ rows: list[tuple[str, str]] = [("Cost Basis", money(totals["cost_basis"]))]
1043
+
1044
+ if totals["fmv"] is not None:
1045
+ rows.append(("Fair Market Value", f"[slab.foil]{money(totals['fmv'])}[/]"))
1046
+
1047
+ g = totals["unrealized"]
1048
+ if g is not None:
1049
+ roi_str = ""
1050
+ if totals["roi"] is not None:
1051
+ sign = "+" if g >= 0 else ""
1052
+ roi_str = f" [slab.dim]({sign}{float(totals['roi']) * 100:.1f}% ROI)[/]"
1053
+ rows.append(("Unrealized Gain/Loss", f"{signed_money(g)}{roi_str}"))
1054
+
1055
+ priced, denom = totals["priced_qty"], totals["total_qty"]
1056
+ pct = (priced / denom * 100) if denom > 0 else 0
1057
+ rows.append((
1058
+ "Comps Coverage",
1059
+ f"{pct_coverage(pct)} [slab.dim]have comps · {priced} of {denom} copies priced[/]",
1060
+ ))
1061
+ else:
1062
+ rows.append(("Market", "[slab.dim]no market data available yet[/]"))
1063
+
1064
+ console.print(heading("Portfolio Valuation", _n(total, "card")))
1065
+ console.print(slab_panel(kv_grid(rows), foil=True))
1066
+
1067
+ _render_portfolio_by_set(by_set)
1068
+ _render_most_valuable_break(most_valuable_break)
1069
+
1070
+ # Top movers
1071
+ if movers:
1072
+ table = slab_table([
1073
+ ("Card #", {"style": "slab.value"}),
1074
+ ("Subject(s)", {}),
1075
+ ("Finish", {"style": "slab.accent"}),
1076
+ ("Grade", {"style": "slab.dim"}),
1077
+ ("Cost Basis", {"justify": "right"}),
1078
+ ("FMV", {"justify": "right"}),
1079
+ ("Gain/Loss", {"justify": "right"}),
1080
+ ])
1081
+
1082
+ for cp in movers:
1083
+ card = cp.card
1084
+ subjects = ", ".join(s.name for s in card.subjects) if card and card.subjects else "—"
1085
+ card_num = card.card_number if card else "—"
1086
+ finish = (card.finish if card else None) or "[slab.dim]Base[/]"
1087
+ grade = _format_collection_grade(cp)
1088
+ basis = money(cp.cost_basis) if cp.cost_basis is not None else "[slab.dim]—[/]"
1089
+ fmv = "[slab.dim]—[/]"
1090
+ gain = "[slab.dim]—[/]"
1091
+ if cp.market and cp.market.fair_market_value is not None:
1092
+ fmv = f"[slab.foil]{money(cp.market.fair_market_value)}[/]"
1093
+ gain = signed_money(cp.market.unrealized_gain_loss)
1094
+ table.add_row(card_num, subjects, finish, grade, basis, fmv, gain)
1095
+
1096
+ console.print(heading("Top Movers"))
1097
+ console.print(table)
1098
+
1099
+ console.print()
1100
+
1101
+
1102
+ # ---------------------------------------------------------------------------
1103
+ # Price history (cards + sealed share one table shape)
1104
+ # ---------------------------------------------------------------------------
1105
+
1106
+ # Plain-language note behind the ⚠ marker — a thin-sample FMV renders differently on purpose so a
1107
+ # 2-comp number can't masquerade as a solid one (a single cross-license sale once moved a box's
1108
+ # headline by $30).
1109
+ _THIN_NOTE = "⚠ thin market — fewer than 3 sales in the window; treat the value as indicative"
1110
+
1111
+
1112
+ def _history_table(points) -> tuple:
1113
+ """(table, any_thin) for a list of CardPricePoint — the shared body of both history views."""
1114
+ table = slab_table([
1115
+ ("Date", {"style": "slab.dim"}),
1116
+ ("FMV", {"justify": "right", "style": "slab.foil"}),
1117
+ ("Range", {"justify": "right", "style": "slab.dim"}),
1118
+ ("Change", {"justify": "right"}),
1119
+ ("Comps", {"justify": "right", "style": "slab.dim"}),
1120
+ ])
1121
+ prev_median = None
1122
+ any_thin = False
1123
+ for p in points:
1124
+ rng = "—"
1125
+ if p.price_low is not None and p.price_high is not None:
1126
+ rng = f"${p.price_low:,.0f} – ${p.price_high:,.0f}"
1127
+ change = "[slab.dim]—[/]"
1128
+ if prev_median is not None:
1129
+ change = signed_money(p.price_median - prev_median)
1130
+ comps = str(p.sample_size)
1131
+ if p.low_confidence:
1132
+ comps += " ⚠"
1133
+ any_thin = True
1134
+ table.add_row(str(p.date), money(p.price_median), rng, change, comps)
1135
+ prev_median = p.price_median
1136
+ return table, any_thin
1137
+
1138
+
1139
+ def _history_summary(points) -> None:
1140
+ """First-vs-last net change line under a history table."""
1141
+ first, last = points[0], points[-1]
1142
+ total_change = last.price_median - first.price_median
1143
+ pct = (total_change / first.price_median * 100) if first.price_median else 0
1144
+ sign = "+" if total_change >= 0 else ""
1145
+ console.print(
1146
+ f" {signed_money(total_change)} [slab.dim]({sign}{pct:.1f}% over {len(points)} data points)[/]"
1147
+ )
1148
+
1149
+
1150
+ def render_price_history(h: CardPriceHistory) -> None:
1151
+ """Display a card's price history as a table with trend indicators."""
1152
+ subjects = ", ".join(h.subjects) or "—"
1153
+ console.print()
1154
+ console.print(_card_headline(h.card_number, subjects, h.set_name, h.grade_key))
1155
+
1156
+ if not h.points:
1157
+ console.print("\n [slab.dim]No price history available for this card/grade.[/]\n")
1158
+ return
1159
+
1160
+ table, any_thin = _history_table(h.points)
1161
+ console.print(heading("Price History", f"{h.grade_key} · {h.start_date} to {h.end_date}"))
1162
+ console.print(table)
1163
+ _history_summary(h.points)
1164
+ if any_thin:
1165
+ console.print(f" [slab.dim]{_THIN_NOTE}[/]")
1166
+ console.print()
1167
+
1168
+
1169
+ def render_sealed_price_history(h: SealedPriceHistory) -> None:
1170
+ """Display a sealed product's price history — same table as a card's, keyed by SKU."""
1171
+ console.print()
1172
+ t = Text()
1173
+ t.append(h.set_name or "—", style="slab.value")
1174
+ t.append(f" — {_format_label(h.format.value)}", style="slab.dim")
1175
+ console.print(t)
1176
+
1177
+ if not h.points:
1178
+ console.print("\n [slab.dim]No price history for this sealed product yet.[/]\n")
1179
+ return
1180
+
1181
+ table, any_thin = _history_table(h.points)
1182
+ console.print(heading("Price History", f"{h.start_date} to {h.end_date}"))
1183
+ console.print(table)
1184
+ _history_summary(h.points)
1185
+ if any_thin:
1186
+ console.print(f" [slab.dim]{_THIN_NOTE}[/]")
1187
+ console.print()