analytics
Run Pokémon card market analytics
Pull daily series for a basket of cards, compute movement over a window, and build a report whose method survives being questioned.
Who this is for
You are writing market reports, running a fund or a shop, or building a dashboard, and the numbers you publish need a method attached.
Plan you need
Complete price history and monthly bulk snapshots are paid features. The seven days on the free tier are enough to prototype the pipeline.
Analytics on trading card prices is unusually easy to do badly, because the inputs are heterogeneous and the outputs look authoritative regardless. A chart of a card over six months is a persuasive object whether or not the underlying series mixes completed sales with asking prices, whether or not a spike is three data points, and whether or not two of the days were interpolated. The chart does not tell you which.
The way out is to carry the method with the number all the way to the edge of your product. Every observation names its source, its basis, its sample size and its capture date. If your pipeline preserves those four fields from ingestion to render, then every claim you publish can be traced back, and the ones that cannot survive that trace get filtered out before anyone sees them rather than after.
The code
A set-level index: pull the daily series for every card in a set, drop the thin days, and compute a value-weighted movement over a window. The filtering is most of the work and all of the credibility.
import os, httpx
from collections import defaultdict
BASE = "https://api.pokemontcgapi.com"
HEADERS = {"X-Api-Key": os.environ["PTCG_API_KEY"]}
MIN_SAMPLE = 5 # below this, one odd sale moves the number more than the market
def cards_in_set(client, code):
"""Page a set. Follow links.next; the cursor is stable at any depth."""
url = f"{BASE}/v1/sets/{code}/cards?limit=250&select=id,name,rarity,index_eur"
while url:
res = client.get(url, headers=HEADERS)
res.raise_for_status()
page = res.json()
yield from page["data"]
url = page.get("links", {}).get("next")
def series(client, card_id, start, end):
res = client.get(
f"{BASE}/v1/cards/{card_id}/prices/history",
params={
"source": "PTCG_INDEX", # one source per series, always
"variant": "INDEX",
"currency": "EUR",
"from": start,
"to": end,
},
headers=HEADERS,
)
res.raise_for_status()
return res.json()["data"]
def set_index(code, start, end):
daily_total = defaultdict(float)
daily_count = defaultdict(int)
with httpx.Client(timeout=30) as client:
for card in cards_in_set(client, code):
# Skip cards with no meaningful market: they add noise, not breadth.
if card.get("index_eur") is None:
continue
for point in series(client, card["id"], start, end):
if (point["sample_n"] or 0) < MIN_SAMPLE:
continue # thin day: excluded, not smoothed over
daily_total[point["as_of"]] += point["price"]
daily_count[point["as_of"]] += 1
# Only days with enough constituents. A day represented by four cards is
# not comparable to one represented by two hundred.
return {
day: round(total / daily_count[day], 2)
for day, total in sorted(daily_total.items())
if daily_count[day] >= 20
}
index = set_index("sv3", "2026-05-01", "2026-08-16")
first, last = next(iter(index.values())), list(index.values())[-1]
print(f"{len(index)} days · {(last - first) / first * 100:+.1f}%")One source and one variant per series
A time series is only meaningful if every point measures the same thing. Completed sales, open listings and published guide values respond to different pressures and sit at different levels, so a series that drifts between them shows movement that is an artefact of the mix rather than of the market. Pick a source and a variant, hold them fixed for the whole series, and put both in the chart legend.
That is why the history endpoint takes source and variant as parameters and defaults to a single derived index rather than to "whatever is available". If you want to compare sources, plot two series and let the reader see the gap. Averaging them together hides exactly the information that would tell someone whether to trust either.
Filter thin days before you plot them
Most cards trade infrequently. A day with three observations produces a number that will swing wildly on the next sale, and a chart that includes it shows volatility that belongs to the sampling, not to the asset. Because sample_n is on every point, filtering is a comparison rather than a modelling exercise: pick a floor, drop what falls below it, and say in the footnote what the floor was.
The same logic applies at the aggregate level. A set-level index computed from four constituents on Tuesday and two hundred on Wednesday is not a series. Require a minimum breadth per day and omit the days that miss it. The resulting chart has gaps, and the gaps are the honest representation of the days you could not measure.
Scale with bulk, not with more requests
Screening the whole catalogue by paging it is the wrong tool. Two things make it unnecessary. Each card carries a denormalised index value and a last-price timestamp, maintained by the refresh job, so "the fifty most valuable holo rares" is a single sorted query rather than a crawl. And for anything genuinely wide, nightly bulk snapshots ship the whole dataset as gzipped JSONL with a sha256 and a row count you can verify against.
Reserve per-card history calls for the basket you actually analyse. A workflow that snapshots nightly, computes locally, and drills into a few dozen cards through the API is cheaper for you and faster to iterate on than one that treats the API as a query engine for every intermediate step.
What usually goes wrong
Not a disclaimer list: these are the failure modes that cost people time on this specific integration.
- Publishing a number without its window
- Every figure needs its date range and its filters printed next to it. "Up 14%" is unfalsifiable; "up 14% between 1 June and 16 August, index series, days with fewer than five observations excluded" is a claim someone can check.
- Survivorship in the basket
- If you build a basket from today's most valuable cards and backtest it, you have measured the past of things that happened to do well. Fix the basket at the start of the window, not the end.
- Treating a currency conversion as free
- Converted figures are flagged as converted and use a daily reference rate. For a series spanning months, some of the movement is the exchange rate. Report in the source currency, or say plainly that you did not.
Endpoints used
- GET
/v1/cards/{id}/prices/historyThe daily series: one source, one variant, one currency. - GET
/v1/cardsScreen and rank on the denormalised index value in one request. - GET
/v1/sets/{code}/cardsEnumerate a set to build a basket, stable at any page depth. - GET
/v1/bulkNightly whole-catalogue snapshots with sha256 and row counts.