Skip to content

Methodology · v1

The number, and where it came from.

Every price Cardhog displays is the median of three live comp sources, USD-converted, with stale rows dropped before the median is computed. This page describes the exact pipeline.

01 / Sources

Three live comp sources. All independently fetched.

None of these are scraped — every one is an API integration we maintain. If a source goes dark, the banner on the per-card detail page calls it out specifically.

i USD

TCGplayer

Market price · US dealer

Dealer-side US pricing aggregated by TCGplayer themselves. We receive it via the Pokémon TCG API embed and, for the cards we care about most, a second pass via PokemonPriceTracker.

via pokemontcg.io · pokemonpricetracker.com

ii USD

PokemonPriceTracker

eBay sold aggregates · eBay sold (Phase 3)

The unique signal — recent eBay sold listings with a sample-count attached. Cardhog wires the eBay leg in Phase 3 of the roadmap; today this source contributes a TCGplayer-derived dealer price.

via pokemonpricetracker.com

iii EUR → USD

Pokewallet

CardMarket trend · EU dealer

CardMarket EU dealer-side pricing via Pokewallet. EUR-denominated; converted to USD at aggregation time using the daily ECB rate (see § FX rate below).

via pokewallet.io

02 / Algorithm

Median of the qualifying sources. Not a weighted index.

The pipeline runs whenever a refresh job fires. Five steps, no proprietary weighting beyond the rules below.

  1. 1

    Pick the latest row per source.

    A source may have multiple variants stored (Normal, Holofoil, Reverse Holofoil, …). The aggregator takes the row with the most recent `price_updated_at`; per-variant breakouts live on the per-card detail page.

  2. 2

    Drop sources older than 30 days.

    If a source has not updated its price for this card in over 30 days, it is excluded from this run. The threshold is fixed in code (`AggregateCardPrice::STALE_AFTER_DAYS`) so it changes with a deploy, not a config tweak.

  3. 3

    Drop sources with too few sales.

    Sources that expose a sale-count signal (planned: PokemonPriceTracker's eBay leg, Phase 3) get dropped when their sample count is below 5. Dealer-price sources leave the sample count null and are exempt — there is no per-day-sales signal to threshold against.

  4. 4

    Convert each survivor to USD.

    USD sources pass through. EUR sources (Pokewallet today) get multiplied by the daily ECB rate from Frankfurter (see § FX rate). If the FX rate is unavailable for any reason, the EUR source is dropped from that run — a degraded median beats publishing nothing.

  5. 5

    Compute the median + IQR.

    Standard median: middle element when the count is odd, average of the two middles when even. IQR (Q3 − Q1) is computed with the textbook inclusive method and stored alongside as a spread signal. Both numbers are written to a single `cardhog_median` row on the card.

03 / Empty case

When every source is stale, we say so.

If the 30-day staleness rule and the sample-count threshold combine to leave no qualifying sources, we publish nothing — and surface this banner instead. No imputed value, no last-known-good number with a different timestamp pretending to be current.

Within the app, the same condition appears as a compact "no comps" chip in cells where a price would normally render.

04 / FX rate

ECB daily reference rate, cached for 24 hours.

EUR sources get converted to USD using the daily reference rate published by the European Central Bank, fetched through Frankfurter (api.frankfurter.dev — free, no key, ECB-sourced). The rate is cached for 24 hours so we hit the upstream at most once a day per worker pool.

Rate

1 EUR = $1.1406

Fetched

0 seconds ago 2026-06-30T11:24:51+00:00

Source

Frankfurter / ECB frankfurter.dev

05 / Changes

When this changes, you'll hear about it.

Algorithm tweaks, new sources, threshold changes — they all land here first. Material changes ship with a dated note in the changelog; the page version above the hero bumps with them.