Add market value: tiered prices and an eBay Browse provider

Researched the options first. PriceCharting is the standard for retro prices
but requires a paid subscription for both its API and its bulk download.
eBay's sold-price data sits behind the Marketplace Insights API, which is a
limited release closed to new developers. The free game-price APIs cover
current digital storefronts, not physical retro copies. So there is no free
route to sold prices, and this uses eBay Browse — active listings, which are
asking prices, labelled as such rather than presented as valuations.

Schema now holds three prices per game (loose, CIB, new), with marketValue
as whichever tier matches that copy's condition. Changing a condition
re-prices from the stored tiers with no further lookup, and the dashboard
can later show both actual value and what a collection would be worth
complete.

The judgement lives in classification and aggregation, both pure and both
tested without credentials:

  * listings are sorted into tiers from their titles, and accessories,
    reproductions and multi-game lots are discarded — a "box only" listing
    at $45 counted as a copy would halve the loose estimate for a $130 cart
  * the discard qualifier is required. The first version matched a bare
    "box", which threw out "complete in box" and "with box and manual",
    i.e. most of the CIB tier, while keeping exactly the listings the
    filter existed to remove. A test asserting on tiers rather than counts
    caught it.
  * median with an interquartile trim, since one optimist asking 50x moves
    a mean and not a median
  * sample counts travel with the estimate, because a tier drawn from two
    listings warrants less confidence than one drawn from thirty

Credentials are optional: with none set, /api/prices/status reports
configured=false and refresh answers 503 with instructions, while the rest
of the app is unaffected.

101 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:21:32 -04:00
co-authored by Claude Opus 5
parent d5a0e42fed
commit ca70bcef34
19 changed files with 1475 additions and 9 deletions
+40
View File
@@ -214,6 +214,46 @@ reinterpret every stored export.
New query parameters: `condition`, `region`, `minRating`, `hasValue`. New sort
keys: `rating`, `value`, `price`, `purchased`.
### Market value
Prices come from **eBay's Browse API**, which needs a free developer account:
register at developer.ebay.com, create a production application keyset, and put
the App ID and Cert ID in `.env` as `EBAY_CLIENT_ID` / `EBAY_CLIENT_SECRET`.
Without them the pricing endpoints answer 503 with an explanation and nothing
else is affected.
```
GET /api/prices/status is a provider configured?
POST /api/prices/refresh {dryRun, limit} price some games
```
**These are asking prices, not sold prices.** Browse returns active listings.
eBay's completed-sales data lives behind the Marketplace Insights API, which is
a limited release closed to new developers, and PriceCharting — the usual
alternative — requires a paid subscription. Asking prices skew high: sellers
list optimistically and unsold listings linger. Treat the numbers as an upper
bound. The provider name (`ebay-asking`) is stored with every value it writes,
so the source is always visible next to the figure.
Deriving a price from listings takes more than an average:
- **Listings are classified into loose / CIB / new** from the title, because a
feed of mixed conditions has no single price. Accessories are discarded
outright — a "box only" listing at $45 counted as a copy would halve the loose
estimate for a $130 cartridge. So are reproductions and multi-game lots.
- **The qualifier is required when discarding.** An early version matched a bare
"box", which threw away "complete in box" and "with box and manual" — most of
the CIB tier — while keeping the cheap box-only listings the filter existed to
remove. Caught by a test asserting on the tier, not on the count.
- **Median, not mean,** with an interquartile trim. One optimist asking 50x drags
a mean past the point of usefulness; a median ignores them.
- **Sample counts travel with the estimate.** A tier from two listings deserves
less confidence than one from thirty.
Three prices are stored per game, and `marketValue` is whichever tier matches
that copy's condition. Changing a game's condition re-prices it from the stored
tiers without another lookup.
### Database changes
```bash