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/__init__.py +0 -0
- slab_cli/banner.py +92 -0
- slab_cli/client.py +386 -0
- slab_cli/commands/__init__.py +4 -0
- slab_cli/commands/breaks.py +140 -0
- slab_cli/commands/catalog.py +84 -0
- slab_cli/commands/collection.py +676 -0
- slab_cli/commands/custom_sets.py +484 -0
- slab_cli/commands/export.py +129 -0
- slab_cli/commands/lots.py +114 -0
- slab_cli/commands/pricing.py +386 -0
- slab_cli/commands/registry.py +140 -0
- slab_cli/commands/setup.py +24 -0
- slab_cli/config.py +21 -0
- slab_cli/context.py +50 -0
- slab_cli/display.py +1187 -0
- slab_cli/flags.py +189 -0
- slab_cli/help.py +87 -0
- slab_cli/main.py +95 -0
- slab_cli/paging.py +46 -0
- slab_cli/picker.py +78 -0
- slab_cli/prompts.py +549 -0
- slab_cli/sources.py +17 -0
- slab_cli/theme.py +184 -0
- slab_cli-0.1.0.dist-info/METADATA +70 -0
- slab_cli-0.1.0.dist-info/RECORD +28 -0
- slab_cli-0.1.0.dist-info/WHEEL +4 -0
- slab_cli-0.1.0.dist-info/entry_points.txt +3 -0
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()
|