slab-cli 0.23.0__tar.gz → 0.25.0__tar.gz

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.
Files changed (35) hide show
  1. {slab_cli-0.23.0 → slab_cli-0.25.0}/PKG-INFO +2 -2
  2. {slab_cli-0.23.0 → slab_cli-0.25.0}/pyproject.toml +2 -2
  3. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/client.py +47 -14
  4. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/pricing.py +54 -96
  5. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/registry.py +2 -1
  6. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/display.py +120 -72
  7. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/prompts.py +60 -15
  8. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/theme.py +11 -3
  9. {slab_cli-0.23.0 → slab_cli-0.25.0}/README.md +0 -0
  10. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/__init__.py +0 -0
  11. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/banner.py +0 -0
  12. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/browse.py +0 -0
  13. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/__init__.py +0 -0
  14. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/breaks.py +0 -0
  15. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/catalog.py +0 -0
  16. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/collection.py +0 -0
  17. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/custom_sets.py +0 -0
  18. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/export.py +0 -0
  19. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/lots.py +0 -0
  20. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/setup.py +0 -0
  21. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/update.py +0 -0
  22. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/config.py +0 -0
  23. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/context.py +0 -0
  24. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/flags.py +0 -0
  25. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/help.py +0 -0
  26. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/main.py +0 -0
  27. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/nav.py +0 -0
  28. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/paging.py +0 -0
  29. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/picker.py +0 -0
  30. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/__init__.py +0 -0
  31. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/base.py +0 -0
  32. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/posix.py +0 -0
  33. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/windows.py +0 -0
  34. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/sources.py +0 -0
  35. {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/updates.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.23.0
3
+ Version: 0.25.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
- Requires-Dist: slab-schemas>=0.36.0
6
+ Requires-Dist: slab-schemas>=0.46.0
7
7
  Requires-Dist: httpx>=0.27
8
8
  Requires-Dist: rich>=13.0
9
9
  Requires-Dist: inquirerpy>=0.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.23.0"
3
+ version = "0.25.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -12,7 +12,7 @@ dependencies = [
12
12
  # 0.34 reframes the grading desk from verdicts to break-even readings (BreakEvenRegion,
13
13
  # GradingDesk.break_even) — the desk shows data, never tells the user to grade.
14
14
  # 0.35 adds Liquidity/LiquidityLabel, which the market view's Sells column renders.
15
- "slab-schemas>=0.36.0",
15
+ "slab-schemas>=0.46.0", # metrics registry: served momentum/trend labels
16
16
  "httpx>=0.27",
17
17
  "rich>=13.0",
18
18
  "InquirerPy>=0.3",
@@ -17,6 +17,7 @@ from slab_schemas.cards import (
17
17
  SetSearchResult,
18
18
  )
19
19
  from slab_schemas.community import CommunityBoard
20
+ from slab_schemas.serials import CardSerials
20
21
  from slab_schemas.collection import (
21
22
  BreakCreate,
22
23
  BreakOut,
@@ -26,16 +27,17 @@ from slab_schemas.collection import (
26
27
  LotOut,
27
28
  LotSearchQuery,
28
29
  LotSearchResult,
29
- LotUpdate,
30
30
  CardCopyCostCreate,
31
31
  CardCopyCostOut,
32
32
  CardCopyCostUpdate,
33
33
  CardCopyCreate,
34
34
  CardCopyOut,
35
35
  CardCopyUpdate,
36
+ CollectionGroupQuery,
36
37
  CollectionResult,
37
38
  CollectionSearchQuery,
38
39
  CollectorOut,
40
+ SetGroupResult,
39
41
  )
40
42
  from slab_schemas.custom_sets import (
41
43
  CustomSetCardAdd,
@@ -48,11 +50,12 @@ from slab_schemas.custom_sets import (
48
50
  CustomSetSearchResult,
49
51
  CustomSetUpdate,
50
52
  )
51
- from slab_schemas.dashboard import CatalogStats, DashboardStats
53
+ from slab_schemas.dashboard import DashboardStats
52
54
  from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
53
55
  from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
54
56
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
55
57
  from slab_schemas.timeseries import CardPriceHistory
58
+ from slab_schemas.vocab import VocabOut
56
59
 
57
60
 
58
61
  class ApiError(Exception):
@@ -81,6 +84,8 @@ class SlabClient:
81
84
  headers["x-api-key"] = api_key
82
85
  self._base_url = base_url
83
86
  self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30)
87
+ self._vocab: VocabOut | None = None
88
+ self._vocab_fetched = False
84
89
 
85
90
  def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
86
91
  try:
@@ -151,6 +156,12 @@ class SlabClient:
151
156
  )
152
157
  return CollectionGradingDesk.model_validate(resp.json())
153
158
 
159
+ def get_card_serials(self, card_uuid: str) -> CardSerials:
160
+ """Which numbered copies of this printing have surfaced — sales with a stated serial and
161
+ collection copies — grouped by serial (GET /cards/{uuid}/serials)."""
162
+ resp = self._request("GET", f"/cards/{card_uuid}/serials")
163
+ return CardSerials.model_validate(resp.json())
164
+
154
165
  def get_card_price_history(
155
166
  self,
156
167
  card_uuid: str,
@@ -200,6 +211,29 @@ class SlabClient:
200
211
  resp = self._request("GET", f"/sealed/{product_uuid}/price-history", params=params)
201
212
  return SealedPriceHistory.model_validate(resp.json())
202
213
 
214
+ # --- vocabulary (public) ---
215
+
216
+ def get_vocab(self) -> VocabOut:
217
+ """Every enumerable value the API accepts or serves — wire enums, sort grammars, and the
218
+ LIVE catalog dimensions (grading companies, attributes) that grow as sets are seeded.
219
+ Raises like any other call; `vocab()` is the forgiving, cached form pickers use."""
220
+ resp = self._request("GET", "/vocab")
221
+ return VocabOut.model_validate(resp.json())
222
+
223
+ def vocab(self) -> VocabOut | None:
224
+ """The served vocabulary, fetched at most ONCE per process and None when the API can't
225
+ answer (offline, unauthorized, down). Same contract as the portal's `getVocab()`: a
226
+ picker renders the served list when it has one and its static fallback otherwise, so a
227
+ new grading company reaches the CLI with no release — and a dead API never blocks a
228
+ prompt. The miss is cached too, so one failure doesn't retry at every prompt."""
229
+ if not self._vocab_fetched:
230
+ self._vocab_fetched = True
231
+ try:
232
+ self._vocab = self.get_vocab()
233
+ except (ApiError, ApiConnectionError):
234
+ self._vocab = None
235
+ return self._vocab
236
+
203
237
  # --- account / identity ---
204
238
 
205
239
  def get_account_context(self) -> MeOut:
@@ -237,14 +271,6 @@ class SlabClient:
237
271
  resp = self._request("POST", f"/collectors/{collector_uuid}/lots/search", json=body)
238
272
  return LotSearchResult.model_validate(resp.json())
239
273
 
240
- def update_lot(self, collector_uuid: str, lot_uuid: str, payload: LotUpdate) -> LotOut:
241
- resp = self._request(
242
- "PATCH",
243
- f"/collectors/{collector_uuid}/lots/{lot_uuid}",
244
- json=payload.model_dump(mode="json", exclude_unset=True),
245
- )
246
- return LotOut.model_validate(resp.json())
247
-
248
274
  def delete_lot(self, collector_uuid: str, lot_uuid: str) -> None:
249
275
  self._request("DELETE", f"/collectors/{collector_uuid}/lots/{lot_uuid}")
250
276
 
@@ -274,6 +300,17 @@ class SlabClient:
274
300
  resp = self._request("POST", f"/collectors/{collector_uuid}/collection/search", json=body)
275
301
  return CollectionResult.model_validate(resp.json())
276
302
 
303
+ def collection_sets(
304
+ self, collector_uuid: str, query: CollectionGroupQuery | None = None
305
+ ) -> SetGroupResult:
306
+ """The collection rolled up by set — the SERVER's per-set math (copies, distinct cards,
307
+ total value), ranked most valuable first and paged over groups, so a set is never split
308
+ across a page. This is the number the portfolio view renders per set; the CLI must not
309
+ re-derive it from copies (cli/AGENTS.md, "Served numbers, not client math")."""
310
+ body = (query or CollectionGroupQuery()).model_dump(mode="json", exclude_none=True)
311
+ resp = self._request("POST", f"/collectors/{collector_uuid}/collection/sets", json=body)
312
+ return SetGroupResult.model_validate(resp.json())
313
+
277
314
  # --- costs ---
278
315
 
279
316
  def add_cost(self, collector_uuid: str, copy_uuid: str, payload: CardCopyCostCreate) -> CardCopyCostOut:
@@ -308,10 +345,6 @@ class SlabClient:
308
345
  resp = self._request("GET", f"/collectors/{collector_uuid}/sources")
309
346
  return resp.json()
310
347
 
311
- def get_catalog_stats(self) -> CatalogStats:
312
- resp = self._request("GET", "/stats")
313
- return CatalogStats.model_validate(resp.json())
314
-
315
348
  def get_community_board(self, limit: int = 10) -> CommunityBoard:
316
349
  resp = self._request("GET", "/community", params={"limit": limit})
317
350
  return CommunityBoard.model_validate(resp.json())
@@ -2,10 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from slab_schemas.cards import SetSearchQuery
6
- from slab_schemas.collection import CollectionSearchQuery, PortfolioSummary
7
-
8
- from slab_schemas.cards import CardOut, SetOut
5
+ from slab_schemas.cards import CardOut, SetOut, SetSearchQuery
6
+ from slab_schemas.collection import (
7
+ CollectionGroupQuery,
8
+ CollectionSearchQuery,
9
+ PortfolioSummary,
10
+ SetGroupOut,
11
+ )
9
12
 
10
13
  from ..context import MissingCollector, ctx
11
14
  from ..display import (
@@ -17,6 +20,7 @@ from ..display import (
17
20
  render_portfolio,
18
21
  render_price_history,
19
22
  render_sealed_price_history,
23
+ render_serials,
20
24
  render_set_market,
21
25
  )
22
26
  from ..paging import fetch_all_pages
@@ -71,6 +75,17 @@ def cmd_comps(args: list[str]) -> None:
71
75
  render_comps(ctx.client.get_card_comps(card.uuid))
72
76
 
73
77
 
78
+ def cmd_serials(args: list[str]) -> None:
79
+ """Which numbered copies of a card have surfaced. Same player → set → card flow as `price`,
80
+ then one row per serial seen: how many collections hold it, how many times it has sold, and
81
+ its last sale. Sales and held copies are shown apart — the collector who bought #24 on eBay
82
+ is one transfer seen from both sides. Unnumbered printings say so and stop."""
83
+ card = _pick_card(args)
84
+ if card is None:
85
+ return
86
+ render_serials(ctx.client.get_card_serials(card.uuid))
87
+
88
+
74
89
  def _split_desk_flags(args: list[str]) -> tuple[list[str], float | None, str | None]:
75
90
  """Pull `--fee <amount>` and `--company <name>` out of a grading query. Hand-rolled (the
76
91
  shared flag grammar is search-specific); an unparseable fee errors loudly rather than
@@ -116,57 +131,34 @@ def cmd_grade_sweep(args: list[str]) -> None:
116
131
  )
117
132
 
118
133
 
119
- def _fetch_all_copies(page: int = 200, cap: int = 5000) -> tuple[list, PortfolioSummary | None]:
120
- """Page through the whole collection for the per-set/mover/break breakdowns, and grab the
121
- server's summary block (full-result aggregates, identical on every page) from the first page —
122
- that's the source of truth for grand totals. `cap` is a safety bound so a runaway collection
123
- can't spin forever."""
124
- copies, first = fetch_all_pages(
125
- lambda limit, offset: ctx.client.search_collection(
126
- ctx.collector, CollectionSearchQuery(sort="-unrealized", limit=limit, offset=offset)
134
+ # How many top movers the portfolio view lists, and the page it reads them from: the server's
135
+ # `-unrealized` sort puts the biggest paper gains first (unpriced copies have no gain and sort
136
+ # last), so one page is the whole answer — no need to pull every copy to find the top ten.
137
+ _MOVERS_SHOWN = 10
138
+ _MOVERS_PAGE = 25
139
+
140
+
141
+ def _fetch_set_groups(page: int = 200, cap: int = 2000) -> list[SetGroupOut]:
142
+ """Every set the collection holds from, as the server's grouped rollup — copies, distinct
143
+ cards, and total value per set, most valuable first. Pages over GROUPS (a set is never split
144
+ across a page boundary) as headers only (`include_copies=False`); `cap` bounds the paging if a
145
+ collection somehow spans thousands of products."""
146
+ groups, _ = fetch_all_pages(
147
+ lambda limit, offset: ctx.client.collection_sets(
148
+ ctx.collector,
149
+ CollectionGroupQuery(
150
+ group_sort="-value", include_copies=False, limit=limit, offset=offset
151
+ ),
127
152
  ),
128
153
  page=page,
129
154
  cap=cap,
130
155
  )
131
- return copies, (first.summary if first else None)
132
-
133
-
134
- def _aggregate_by_set(copies: list) -> list[dict]:
135
- """Roll copies up per set, mirroring the server's headline math so the parts reconcile with the
136
- whole: cost basis is summed per-copy, FMV is quantity-weighted, comp coverage is copy-weighted."""
137
- sets: dict[str, dict] = {}
138
- for cp in copies:
139
- card = cp.card
140
- name = (card.set_name if card else None) or "— Unknown set —"
141
- s = sets.setdefault(name, {
142
- "name": name, "copies": 0, "total_qty": 0, "priced_qty": 0,
143
- "cost_basis": 0.0, "fmv": 0.0, "has_fmv": False,
144
- })
145
- qty = cp.quantity or 1
146
- s["copies"] += 1
147
- s["total_qty"] += qty
148
- if cp.cost_basis is not None:
149
- s["cost_basis"] += float(cp.cost_basis)
150
- if cp.market and cp.market.fair_market_value is not None:
151
- s["fmv"] += float(cp.market.fair_market_value) * qty
152
- s["priced_qty"] += qty
153
- s["has_fmv"] = True
154
-
155
- for s in sets.values():
156
- s["unrealized"] = (s["fmv"] - s["cost_basis"]) if s["has_fmv"] else None
157
- s["comp_pct"] = (s["priced_qty"] / s["total_qty"] * 100) if s["total_qty"] else 0.0
158
-
159
- # Most valuable first; sets with no market data sink to the bottom (sorted by cost basis).
160
- return sorted(
161
- sets.values(),
162
- key=lambda s: (s["has_fmv"], s["fmv"] if s["has_fmv"] else s["cost_basis"]),
163
- reverse=True,
164
- )
156
+ return groups
165
157
 
166
158
 
167
159
  def _portfolio_totals(summary: PortfolioSummary) -> dict:
168
160
  """Grand totals for the valuation panel, straight from the server's summary block — the same
169
- math the dashboard shows, so the two never diverge."""
161
+ numbers `GET .../dashboard` serves, so the two never diverge."""
170
162
  fmv = summary.portfolio_value
171
163
  return {
172
164
  "cost_basis": float(summary.total_cost_basis or 0),
@@ -181,67 +173,33 @@ def _portfolio_totals(summary: PortfolioSummary) -> dict:
181
173
  }
182
174
 
183
175
 
184
- def _most_valuable_break(copies: list, breaks: list) -> dict | None:
185
- """The break whose pulled copies carry the most market value — did opening that box pay off?
186
- FMV is summed from the copies we already priced; cost/metadata comes from the breaks list."""
187
- fmv_by_break: dict[str, float] = {}
188
- priced_by_break: dict[str, int] = {}
189
- for cp in copies:
190
- if cp.break_uuid and cp.market and cp.market.fair_market_value is not None:
191
- qty = cp.quantity or 1
192
- fmv_by_break[cp.break_uuid] = (
193
- fmv_by_break.get(cp.break_uuid, 0.0) + float(cp.market.fair_market_value) * qty
194
- )
195
- priced_by_break[cp.break_uuid] = priced_by_break.get(cp.break_uuid, 0) + qty
196
-
197
- if not fmv_by_break:
198
- return None
199
-
200
- top_uuid = max(fmv_by_break, key=fmv_by_break.get)
201
- brk = next((b for b in breaks if b.uuid == top_uuid), None)
202
- if brk is None:
203
- return None
204
-
205
- cost = float(brk.total_cost)
206
- fmv = fmv_by_break[top_uuid]
207
- return {
208
- "set_name": brk.set_name or "— Unknown set —",
209
- "break_type": brk.break_type,
210
- "break_date": brk.break_date,
211
- "cost": cost,
212
- "fmv": fmv,
213
- "gain": fmv - cost,
214
- "copy_count": brk.copy_count,
215
- "priced": priced_by_break.get(top_uuid, 0),
216
- }
217
-
218
-
219
176
  def cmd_portfolio(args: list[str]) -> None:
220
- """Portfolio-level financial summary — cost basis vs market value, per-set stats, top movers."""
221
- copies, summary = _fetch_all_copies()
222
- if not copies:
177
+ """Portfolio-level financial summary — cost basis vs market value, the per-set rollup, and
178
+ the top movers — every number as the SERVER computed it. Grand totals are the response's
179
+ `summary` block (the dashboard's math), the per-set table is `POST .../collection/sets`, and
180
+ the movers are the first page of the `-unrealized` sort. Nothing is re-derived from copies:
181
+ the old per-set client math paged a capped 5,000 copies and summed them, which both duplicated
182
+ the dashboard's definitions and went quietly wrong past the cap."""
183
+ page = ctx.client.search_collection(
184
+ ctx.collector, CollectionSearchQuery(sort="-unrealized", limit=_MOVERS_PAGE)
185
+ )
186
+ if not page.items:
223
187
  console.print("[dim]No cards in your collection yet.[/dim]")
224
188
  return
225
- if summary is None:
189
+ if page.summary is None:
226
190
  # The API always includes the summary block; its absence is a server bug — say so
227
191
  # rather than invent numbers client-side.
228
192
  console.print("[red]Server response had no portfolio summary — can't value the collection.[/red]")
229
193
  return
230
194
 
231
- by_set = _aggregate_by_set(copies)
232
- totals = _portfolio_totals(summary)
233
-
234
- # Top movers: copies with real unrealized P&L, biggest gain first (already sorted by the fetch).
195
+ # Top movers: copies with real unrealized P&L, biggest gain first (the server's sort).
235
196
  movers = [
236
- cp for cp in copies
197
+ cp for cp in page.items
237
198
  if cp.market and cp.market.unrealized_gain_loss is not None
238
- ][:10] or None
239
-
240
- breaks = ctx.client.search_breaks(ctx.collector).items
241
- mvb = _most_valuable_break(copies, breaks)
199
+ ][:_MOVERS_SHOWN] or None
242
200
 
243
201
  render_portfolio(
244
- len(copies), totals, movers=movers, by_set=by_set, most_valuable_break=mvb
202
+ page.total, _portfolio_totals(page.summary), movers=movers, by_set=_fetch_set_groups()
245
203
  )
246
204
 
247
205
 
@@ -61,6 +61,7 @@ GROUPS: list[Group] = [
61
61
  Command("price", "Market price + 7d/30d trend", pricing.cmd_price, "<query>"),
62
62
  Command("grade", "The grading math: payoffs per grade + break-even", pricing.cmd_grade, "<query> [--fee N]"),
63
63
  Command("comps", "Recent raw sales behind the price", pricing.cmd_comps, "<query>"),
64
+ Command("serials", "Which numbered copies have surfaced (sales + collections)", pricing.cmd_serials, "<query>"),
64
65
  Command("history", "Price history over time", pricing.cmd_history, "<query>"),
65
66
  ]),
66
67
  Group("set", "browse and price sealed products", [
@@ -79,7 +80,7 @@ GROUPS: list[Group] = [
79
80
  Command("cost", "Log a cost (grading, shipping) on a copy", collection.cmd_cost),
80
81
  Command("export", "Export a set to a printable checklist", export.cmd_export),
81
82
  Command("dashboard", "Overview: counts, value, composition", collection.cmd_dashboard),
82
- Command("portfolio", "Portfolio: cost basis vs FMV + top movers", pricing.cmd_portfolio),
83
+ Command("portfolio", "Portfolio: cost basis vs FMV, by set, top movers", pricing.cmd_portfolio),
83
84
  Command("grade", "Grading math for every raw copy, best upside first", pricing.cmd_grade_sweep, "[--fee N]"),
84
85
  ]),
85
86
  Group("break", "case / box breaks", [
@@ -6,7 +6,7 @@ from rich.console import Group
6
6
  from rich.text import Text
7
7
 
8
8
  from slab_schemas.cards import CardOut, CardSearchResult, SetOut, SetSearchResult
9
- from slab_schemas.collection import BreakSearchResult, CollectionResult, LotSearchResult
9
+ from slab_schemas.collection import BreakSearchResult, CollectionResult, LotSearchResult, SetGroupOut
10
10
  from slab_schemas.community import (
11
11
  CollectedCard,
12
12
  CollectedPlayer,
@@ -22,8 +22,10 @@ from slab_schemas.dashboard import CatalogStats, DashboardStats, HighlightCard,
22
22
  from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
23
23
  from slab_schemas.enums import BreakEvenRegion, Grade, LiquidityLabel, SealedFormat
24
24
  from slab_schemas.liquidity import Liquidity
25
+ from slab_schemas.metrics import momentum_label, trend_label
25
26
  from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTopCards
26
27
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
28
+ from slab_schemas.serials import CardSerials
27
29
  from slab_schemas.timeseries import CardPriceHistory
28
30
 
29
31
  from .browse import Page, browse
@@ -504,10 +506,29 @@ def _pull_table(pulls) -> object:
504
506
  return table
505
507
 
506
508
 
509
+ # A break's or lot's "worth today" is CLIENT math: FMV summed over the copies in the response.
510
+ # There is no server number for it (the API prices copies, not containers), so this is the one
511
+ # place the CLI adds market values up itself — and it says exactly what it added, from this ONE
512
+ # string, on both report cards. It must never read as the dashboard's portfolio math: that is
513
+ # as-of, full-collection, and served (`cli/AGENTS.md`, "Served numbers, not client math").
514
+ PAGE_NET_SCOPE = "priced copies on this page only; unpriced count as $0"
515
+
516
+
517
+ def _net_scope_line(result: CollectionResult, unpriced: int) -> str:
518
+ """The honest footnote under a break/lot money line: what the total covers and what it skips."""
519
+ parts = [f"Worth today = {PAGE_NET_SCOPE}"]
520
+ if unpriced:
521
+ parts.append(f"{unpriced} unpriced here, so the real total can only be higher")
522
+ if result.total > len(result.items):
523
+ parts.append(f"{len(result.items)} of {result.total} copies shown")
524
+ return f" [slab.dim]{' · '.join(parts)}.[/]"
525
+
526
+
507
527
  def render_break_pulls(brk, result: CollectionResult) -> None:
508
528
  """A break's report card: what you paid vs what the pulls are worth today, then the most
509
529
  valuable pulls and the rarest (lowest print run). Every number in plain words — this view
510
- answers "did the box pay for itself?" at a glance."""
530
+ answers "did the box pay for itself?" at a glance. "Worth today" is summed here from the
531
+ page's priced copies (`PAGE_NET_SCOPE`), and labeled so."""
511
532
  console.print()
512
533
  t = Text()
513
534
  t.append(brk.set_name or "Break", style="slab.value")
@@ -534,9 +555,7 @@ def render_break_pulls(brk, result: CollectionResult) -> None:
534
555
  f"\n You paid [slab.value]{money(brk.total_cost)}[/] · your {len(result.items)} pulls are "
535
556
  f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
536
557
  )
537
- if unpriced:
538
- console.print(f" [slab.dim]{unpriced} pull(s) have no market price yet and count as $0 here — "
539
- f"the real total can only be higher.[/]")
558
+ console.print(_net_scope_line(result, unpriced))
540
559
 
541
560
  top = sorted(priced, key=_fmv, reverse=True)[:10]
542
561
  if top:
@@ -588,7 +607,7 @@ def browse_lots(result: LotSearchResult, *, detail=None) -> None:
588
607
  def render_lot_contents(lot, result: CollectionResult) -> None:
589
608
  """A purchase's report card: what you paid vs what the cards are worth today, then the most
590
609
  valuable and rarest cards in it. Answers "did that bundle come out ahead?" at a glance — the
591
- lot counterpart of `render_break_pulls`."""
610
+ lot counterpart of `render_break_pulls`, same `PAGE_NET_SCOPE` label on its total."""
592
611
  console.print()
593
612
  t = Text()
594
613
  t.append(f"Purchase {lot.uuid[:8]}", style="slab.value")
@@ -613,9 +632,7 @@ def render_lot_contents(lot, result: CollectionResult) -> None:
613
632
  f"\n You paid [slab.value]{money(lot.total_cost)}[/] · its {len(result.items)} cards are "
614
633
  f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
615
634
  )
616
- if unpriced:
617
- console.print(f" [slab.dim]{unpriced} card(s) have no market price yet and count as $0 here — "
618
- f"the real total can only be higher.[/]")
635
+ console.print(_net_scope_line(result, unpriced))
619
636
 
620
637
  top = sorted(priced, key=_fmv, reverse=True)[:10]
621
638
  if top:
@@ -865,26 +882,39 @@ def _collected_players_table(entries: list[CollectedPlayer]) -> Group | None:
865
882
  return titled("Most Collected Players", table)
866
883
 
867
884
 
868
- def _hot_momentum(cur: int, prev: int) -> str:
869
- """The window-over-window badge in plain terms: 'new' (was silent), ×N up, ×N down, or steady."""
870
- if prev == 0:
885
+ # The hottest-players badges render the label the SERVER computed (`HotPlayer.momentum` /
886
+ # `HotPlayer.trend`). The thresholds that turn a ratio into "up" are the API's metric
887
+ # definitions (`slab_schemas.metrics`), not the CLI's to copy: three clients once each carried
888
+ # their own ±2% band and disagreed. When an older server omits the fields, the fallback is the
889
+ # registry's own functions, imported — the same rule, never a restated literal.
890
+ # `tests/test_hot_labels.py` asserts no threshold number lives in this module.
891
+
892
+
893
+ def _hot_momentum(e: HotPlayer) -> str:
894
+ """The window-over-window badge in plain terms: 'new' (was silent), ×N up, ×N down, or steady.
895
+ The label is served; the ×N beside it is the served counts' ratio, shown for scale."""
896
+ cur, prev = e.sales_30d, e.sales_prev_30d
897
+ label = e.momentum or momentum_label(cur, prev)
898
+ if label == "new":
871
899
  return "[slab.accent]new[/]"
872
- ratio = cur / prev
873
- if ratio >= 1.5:
874
- return f"[slab.accent]↑{ratio:.1f}×[/]"
875
- if ratio <= 0.67:
900
+ if label == "up":
901
+ return f"[slab.accent]↑{cur / prev:.1f}×[/]" if prev else "[slab.accent]↑[/]"
902
+ if label == "down":
876
903
  return f"[slab.dim]↓{prev / cur:.1f}×[/]" if cur else "[slab.dim]↓[/]"
877
904
  return "[slab.dim]steady[/]"
878
905
 
879
906
 
880
- def _hot_trend(pct: float | None) -> str:
881
- """Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down)."""
882
- if pct is None:
907
+ def _hot_trend(e: HotPlayer) -> str:
908
+ """Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down).
909
+ The direction is served; the percent beside it is the served `price_trend_pct`."""
910
+ pct = e.price_trend_pct
911
+ label = e.trend or trend_label(pct)
912
+ if pct is None or label is None:
883
913
  return "[slab.dim]—[/]"
884
- if pct > 2:
885
- return f"[slab.gain]↗ +{pct:.0f}%[/]"
886
- if pct < -2:
887
- return f"[slab.loss]↘ {pct:.0f}%[/]"
914
+ if label == "up":
915
+ return f"[slab.gain]↗ {pct:+.0f}%[/]"
916
+ if label == "down":
917
+ return f"[slab.loss]↘ {pct:+.0f}%[/]"
888
918
  return f"[slab.dim]→ {pct:+.0f}%[/]"
889
919
 
890
920
 
@@ -903,10 +933,10 @@ def _hottest_players_table(entries: list[HotPlayer]) -> Group | None:
903
933
  table.add_row(
904
934
  e.name,
905
935
  str(e.sales_30d),
906
- _hot_momentum(e.sales_30d, e.sales_prev_30d),
936
+ _hot_momentum(e),
907
937
  money(e.dollar_volume_30d),
908
938
  str(e.distinct_cards_30d),
909
- _hot_trend(e.price_trend_pct),
939
+ _hot_trend(e),
910
940
  )
911
941
  return titled("🔥 Hottest Players", table)
912
942
 
@@ -1254,6 +1284,51 @@ def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCard
1254
1284
  console.print(" [slab.dim]Drill in with `slab card price` / `slab card comps`.[/]\n")
1255
1285
 
1256
1286
 
1287
+ def render_serials(s: CardSerials) -> None:
1288
+ """Which numbered copies of a printing have surfaced — one row per serial, sales and held
1289
+ copies kept apart (the same transfer can be seen from both sides). The meaning text comes
1290
+ from the response's glossary, so the CLI, the site and the API say the same words."""
1291
+ subjects = ", ".join(s.subjects) or "—"
1292
+ console.print()
1293
+ console.print(_card_headline(s.card_number, subjects, s.set_name, _slot(s)))
1294
+
1295
+ if not s.print_run:
1296
+ console.print("\n [slab.dim]This printing isn't serial-numbered, so there are no serials to track.[/]\n")
1297
+ return
1298
+
1299
+ unseen = max(0, s.print_run - s.serials_seen)
1300
+ pct = f" ({s.coverage_pct:.0f}%)" if s.coverage_pct is not None else ""
1301
+ console.print(heading("Serials Seen", f"{s.serials_seen} of /{s.print_run}{pct} · {unseen} never surfaced"))
1302
+ if not s.serials:
1303
+ console.print(" [slab.dim]No numbered copy of this card has surfaced in a sale or a collection yet.[/]\n")
1304
+ return
1305
+
1306
+ table = slab_table([
1307
+ ("Serial", {"justify": "right", "style": "slab.foil"}),
1308
+ ("Held", {"justify": "right"}),
1309
+ ("Sales", {"justify": "right"}),
1310
+ ("Last sale", {"justify": "right", "style": "slab.foil"}),
1311
+ ("Grade", {"style": "slab.dim"}),
1312
+ ("Last seen", {"style": "slab.dim"}),
1313
+ ])
1314
+ for sig in s.serials:
1315
+ last = sig.sales[-1] if sig.sales else None
1316
+ table.add_row(
1317
+ f"{sig.serial_number}/{s.print_run}",
1318
+ str(sig.held) if sig.held else "[slab.dim]—[/]",
1319
+ str(len(sig.sales)) if sig.sales else "[slab.dim]—[/]",
1320
+ money(last.sale_price) if last and last.sale_price is not None else "—",
1321
+ (last.grade_key or "—") if last else "—",
1322
+ sig.last_seen.isoformat() if sig.last_seen else "—",
1323
+ )
1324
+ console.print(table)
1325
+ for key in ("serials.coverage", "serials.held"):
1326
+ info = s.glossary.get(key)
1327
+ if info:
1328
+ console.print(f" [slab.dim]{info.label}: {info.summary}[/]")
1329
+ console.print()
1330
+
1331
+
1257
1332
  def render_comps(c: CardComps) -> None:
1258
1333
  """Display recent comps (raw sales) for a catalog card — the evidence behind its FMV."""
1259
1334
  subjects = ", ".join(c.subjects) or "—"
@@ -1442,67 +1517,41 @@ def render_collection_grading_desk(sweep: CollectionGradingDesk) -> None:
1442
1517
  console.print()
1443
1518
 
1444
1519
 
1445
- def _render_portfolio_by_set(by_set: list | None) -> None:
1446
- """Per-set breakdown, most valuable first. Highlights the top set and shows comp coverage."""
1520
+ def _render_portfolio_by_set(by_set: list[SetGroupOut] | None) -> None:
1521
+ """Per-set breakdown straight from the server's grouped rollup (`POST .../collection/sets`):
1522
+ copies held, distinct cards, and what they're worth, most valuable first. Only SERVED columns —
1523
+ there is no per-set cost basis, gain, or comps coverage here because the API computes none,
1524
+ and a client-side version was a second, drifting definition of the dashboard's math (it
1525
+ summed a capped page of copies and called it the set)."""
1447
1526
  if not by_set:
1448
1527
  return
1449
1528
 
1450
1529
  table = slab_table([
1451
1530
  ("Set", {"style": "slab.value", "no_wrap": False}),
1531
+ ("Copies", {"justify": "right", "style": "slab.dim"}),
1452
1532
  ("Cards", {"justify": "right", "style": "slab.dim"}),
1453
- ("Cost Basis", {"justify": "right"}),
1454
- ("FMV", {"justify": "right"}),
1455
- ("Gain/Loss", {"justify": "right"}),
1456
- ("Comps", {"justify": "right"}),
1533
+ ("Value", {"justify": "right"}),
1457
1534
  ])
1458
1535
 
1459
- for i, st in enumerate(by_set):
1460
- # crown the single most valuable set
1461
- name = f"[slab.foil]★[/] {st['name']}" if i == 0 and st["has_fmv"] else st["name"]
1462
- cards = f"{st['total_qty']:,}"
1463
- basis = money(st["cost_basis"])
1464
- fmv = f"[slab.foil]{money(st['fmv'])}[/]" if st["has_fmv"] else "[slab.dim]—[/]"
1465
- gain = signed_money(st["unrealized"])
1466
- comps = pct_coverage(st["comp_pct"])
1536
+ for i, g in enumerate(by_set):
1537
+ # crown the single most valuable set (the server ranks by value; a None value is unpriced)
1538
+ name = f"[slab.foil]★[/] {g.name}" if i == 0 and g.total_value is not None else g.name
1539
+ value = f"[slab.foil]{money(g.total_value)}[/]" if g.total_value is not None else "[slab.dim]—[/]"
1540
+ table.add_row(name, f"{g.copy_count:,}", f"{g.card_count:,}", value)
1467
1541
 
1468
- table.add_row(name, cards, basis, fmv, gain, comps)
1469
-
1470
- console.print(heading("By Set", "most valuable first"))
1542
+ console.print(heading("By Set", "most valuable first · unpriced copies add nothing to Value"))
1471
1543
  console.print(table)
1472
1544
 
1473
1545
 
1474
- def _render_most_valuable_break(mvb: dict | None) -> None:
1475
- """Highlight the single break whose pulled cards carry the most market value — did it pay off?"""
1476
- if not mvb:
1477
- return
1478
-
1479
- date_str = f" · {mvb['break_date']}" if mvb.get("break_date") else ""
1480
- title = Text()
1481
- title.append(mvb["set_name"], style="slab.value")
1482
- title.append(f" {mvb['break_type'].replace('_', ' ')}{date_str}", style="slab.dim")
1483
-
1484
- g = mvb["gain"]
1485
- verdict = "paid off" if g >= 0 else "underwater"
1486
- roi = f" [slab.dim]({'+' if g >= 0 else '-'}{abs(g) / mvb['cost'] * 100:.0f}%)[/]" if mvb["cost"] else ""
1487
-
1488
- body = kv_grid([
1489
- ("Cost", f"{money(mvb['cost'])} [slab.dim]({_n(mvb['copy_count'], 'card')})[/]"),
1490
- ("Market Value", f"[slab.foil]{money(mvb['fmv'])}[/] [slab.dim]({mvb['priced']} priced)[/]"),
1491
- ("Net vs Cost", f"{signed_money(g)}{roi} [slab.dim]· {verdict}[/]"),
1492
- ])
1493
-
1494
- console.print(heading("Most Valuable Break"))
1495
- console.print(slab_panel(Group(title, Text(""), body), foil=True))
1496
-
1497
-
1498
1546
  def render_portfolio(
1499
- total: int, totals: dict, movers: list | None = None, by_set: list | None = None,
1500
- most_valuable_break: dict | None = None,
1547
+ total: int, totals: dict, movers: list | None = None, by_set: list[SetGroupOut] | None = None,
1501
1548
  ) -> None:
1502
- """Display portfolio-level financial summary, per-set breakdown, and optional top movers.
1549
+ """Display portfolio-level financial summary, the per-set rollup, and optional top movers.
1503
1550
 
1504
- `totals` is the grand-total dict (cost_basis, fmv, unrealized, roi, priced_qty, total_qty)
1505
- built from the server's PortfolioSummary — see `_portfolio_totals`."""
1551
+ Every number here is one the server computed: `totals` is the grand-total dict (cost_basis,
1552
+ fmv, unrealized, roi, priced_qty, total_qty) built from the response's `PortfolioSummary`
1553
+ (see `_portfolio_totals`), `by_set` is the served `SetGroupOut` rollup, and `movers` is the
1554
+ first page of the server's `-unrealized` sort."""
1506
1555
  rows: list[tuple[str, str]] = [("Cost Basis", money(totals["cost_basis"]))]
1507
1556
 
1508
1557
  if totals["fmv"] is not None:
@@ -1529,7 +1578,6 @@ def render_portfolio(
1529
1578
  console.print(slab_panel(kv_grid(rows), foil=True))
1530
1579
 
1531
1580
  _render_portfolio_by_set(by_set)
1532
- _render_most_valuable_break(most_valuable_break)
1533
1581
 
1534
1582
  # Top movers
1535
1583
  if movers:
@@ -12,6 +12,7 @@ from typing import Protocol
12
12
 
13
13
  from InquirerPy.base.control import Choice
14
14
 
15
+ from .context import ctx
15
16
  from .nav import inquirer # the nav proxy: every prompt gains Esc=back / Ctrl-C=quit
16
17
 
17
18
  from slab_schemas.cards import CardOut, SetOut, SetSearchResult
@@ -435,22 +436,69 @@ def select_grade(message: str = "Your grade assessment:") -> str | None:
435
436
  return inquirer.select(message=message, choices=choices, max_height="70%").execute()
436
437
 
437
438
 
439
+ # ---------------------------------------------------------------------------
440
+ # Served-vocab pickers
441
+ # ---------------------------------------------------------------------------
442
+ # These lists come from GET /vocab when the API answers and fall back to the literals below when it
443
+ # doesn't — the portal's `getVocab()` pattern (portal/src/lib/vocab.ts). Grading companies are LIVE
444
+ # catalog rows (a new one appears when a set is seeded), and the wire enums can grow in a schemas
445
+ # release the installed CLI predates; either way the served list is the truth and the fallback is
446
+ # only what this build knew. The label maps are the human wording for the values we know about; an
447
+ # unknown served value still renders (title-cased) rather than being silently dropped.
448
+
449
+ _GRADING_COMPANY_FALLBACK = ["PSA", "BGS", "SGC", "CGC"]
450
+ _GRADING_COMPANY_LABELS = {"BGS": "BGS (Beckett)"}
451
+
452
+ _MATCH_MODE_FALLBACK = ["exact", "any_printing", "exact_serial"]
453
+ _MATCH_MODE_LABELS = {
454
+ "exact": "Exact — this specific printing (parallel/finish)",
455
+ "any_printing": "Any printing — any version of this card slot",
456
+ "exact_serial": "Exact serial — this card with a specific serial number",
457
+ }
458
+
459
+ _VISIBILITY_FALLBACK = ["private", "public"]
460
+ _VISIBILITY_LABELS = {
461
+ "private": "Private — only you can see it",
462
+ "public": "Public — any collector can find and subscribe",
463
+ }
464
+
465
+
466
+ def vocab_values(
467
+ field: str, fallback: list[str], vocab=None, *, exclude: frozenset[str] = frozenset()
468
+ ) -> list[str]:
469
+ """The served list for one vocab field, else `fallback`. Pure: pass `vocab` (a VocabOut, or
470
+ None for "the API didn't answer") to test it; the pickers pass the client's cached one. A
471
+ served list that is empty (a catalog with no grading companies yet) also falls back, so a
472
+ prompt never renders with nothing to pick. `exclude` drops values this picker must not offer
473
+ because another flow owns them (applied to served and fallback alike)."""
474
+ values = getattr(vocab, field, None) if vocab is not None else None
475
+ chosen = list(values) if values else list(fallback)
476
+ return [v for v in chosen if v not in exclude]
477
+
478
+
479
+ def _served_choices(
480
+ field: str, fallback: list[str], labels: dict[str, str], exclude: frozenset[str] = frozenset()
481
+ ) -> list[Choice]:
482
+ values = vocab_values(field, fallback, ctx.client.vocab(), exclude=exclude)
483
+ return [Choice(value=v, name=labels.get(v) or v.replace("_", " ").title()) for v in values]
484
+
485
+
438
486
  def select_grading_company() -> str:
439
- choices = [
440
- Choice(value="PSA", name="PSA"),
441
- Choice(value="BGS", name="BGS (Beckett)"),
442
- Choice(value="SGC", name="SGC"),
443
- Choice(value="CGC", name="CGC"),
444
- ]
487
+ choices = _served_choices("grading_companies", _GRADING_COMPANY_FALLBACK, _GRADING_COMPANY_LABELS)
445
488
  return inquirer.select(message="Grading company:", choices=choices).execute()
446
489
 
447
490
 
491
+ # `any_card` is a PLAYER slot, not a way to match a card: both callers of select_match_mode are
492
+ # tuning a CARD entry, and the player rung is reached through `select_entry_kind` instead. So it
493
+ # is excluded from this picker however the list arrives — offering it here would send a card entry
494
+ # with a subject-shaped mode.
495
+ _CARD_ENTRY_MODE_EXCLUDE = frozenset({"any_card"})
496
+
497
+
448
498
  def select_match_mode() -> str:
449
- choices = [
450
- Choice(value="exact", name="Exact — this specific printing (parallel/finish)"),
451
- Choice(value="any_printing", name="Any printing — any version of this card slot"),
452
- Choice(value="exact_serial", name="Exact serial — this card with a specific serial number"),
453
- ]
499
+ choices = _served_choices(
500
+ "match_modes", _MATCH_MODE_FALLBACK, _MATCH_MODE_LABELS, exclude=_CARD_ENTRY_MODE_EXCLUDE
501
+ )
454
502
  return inquirer.select(message="Match mode:", choices=choices).execute()
455
503
 
456
504
 
@@ -464,10 +512,7 @@ def select_entry_kind() -> str:
464
512
 
465
513
 
466
514
  def select_visibility() -> str:
467
- choices = [
468
- Choice(value="private", name="Private — only you can see it"),
469
- Choice(value="public", name="Public — any collector can find and subscribe"),
470
- ]
515
+ choices = _served_choices("visibilities", _VISIBILITY_FALLBACK, _VISIBILITY_LABELS)
471
516
  return inquirer.select(message="Visibility:", choices=choices).execute()
472
517
 
473
518
 
@@ -16,6 +16,8 @@ Building block cheat-sheet:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import math
20
+
19
21
  from rich import box
20
22
  from rich.console import Console, Group, RenderableType
21
23
  from rich.panel import Panel
@@ -148,9 +150,15 @@ def signed_money(v) -> str:
148
150
 
149
151
 
150
152
  def pct_coverage(pct: float) -> str:
151
- """A coverage/health percentage, colored by band: green ≥75, gold ≥40, red below."""
152
- color = "slab.gain" if pct >= 75 else ("slab.foil" if pct >= 40 else "slab.loss")
153
- return f"[{color}]{pct:.0f}%[/]"
153
+ """A coverage/health percentage, colored by band: green ≥75, gold ≥40, red below.
154
+
155
+ The ratio itself is two served counts (e.g. `priced_count / card_count`), so the only thing a
156
+ client decides is the rounding — and it must round the way the frontend does (`Math.round`,
157
+ half UP: 62.5 → 63), not Python's `format(..., '.0f')` (half to EVEN: 62.5 → 62), or the two
158
+ clients print different percentages for the same set. `floor(x + 0.5)` is `Math.round`."""
159
+ shown = math.floor(pct + 0.5)
160
+ color = "slab.gain" if shown >= 75 else ("slab.foil" if shown >= 40 else "slab.loss")
161
+ return f"[{color}]{shown}%[/]"
154
162
 
155
163
 
156
164
  _SPARK_TICKS = "▁▂▃▄▅▆▇█"
File without changes
File without changes