Resale Market Pricing Engine: Sports Card Application
A multi-dimensional comparable-sales pricing model for resale market valuation — similarity scoring, hype detection, confidence tiering, and best-time-to-sell analysis (Demoing Sports Card Market).
The problem
No two sports card sales are exactly comparable. A search for “2020 Topps Chrome Ohtani” returns base cards, refractors, autos, and 1/1 superfractors in the same results set. Standard approaches fail: book values lag the market by months, raw averaging produces garbage estimates, and exact-match-only approaches break down for any card outside the top 500 most-traded.
The model solves this by scoring a broad set of sold listings against the target card across five weighted dimensions and using the top-scoring comps — weighted by similarity squared — to produce a dollar-weighted price estimate that degrades gracefully as comp quality decreases.
Similarity dimensions
| Card Variant / Type | 35% — Base vs. refractor vs. auto vs. patch /10 is the dominant price driver, more than set or manufacturer. |
| Set / Manufacturer Tier | 25% — Topps Chrome vs. Topps Series 1 vs. generic carry meaningfully different market values. |
| Temporal Recency | 20% — Player values move fast. Steep recency decay applied via exponential weighting. |
| Parallel / Color | 12% — Gold /10 vs. Silver /25 vs. base parallel are distinct markets. |
| Condition / Grade | 8% — Raw condition scoring plus grader company weights applied as multipliers. |
Set and manufacturer tier system
The set a card belongs to creates persistent price stratification independent of variant type. A refractor from Topps Chrome trades at a different price point than a refractor from a mid-tier product, even for the same player. The model encodes this as three set tiers with a compatibility matrix:
| Tier | Example Sets | Cross-Tier Note |
|---|---|---|
| Premium | Topps Chrome, Bowman Chrome, Bowman Draft, Topps Museum Collection, Panini National Treasures, Panini Prizm, Panini Select, Upper Deck The Cup, SP Authentic | Full tier-compatibility score when matched |
| Mid | Topps Series 1/2, Topps Heritage, Topps Stadium Club, Panini Donruss Optic, Panini Mosaic, Panini Contenders, Upper Deck Series 1/2, O-Pee-Chee Platinum | 0.35× when matched against Premium tier |
| Base | Topps, Bowman, Panini Donruss, Panini Score, Panini Hoops, Upper Deck, O-Pee-Chee, Fleer | 0.10× when matched against Premium tier |
Parallel and print run scoring
Parallel scoring uses a two-step approach: print run proximity first, then color tier as a fallback when print run is not available.
- When print run is known: Score by log-ratio of the two print runs. A /25 comp for a /10 card scores better than a /100 comp for the same /10 card. A 1/1 vs. a /25 scores 0.30 — below the minimum threshold in most cases.
- When print run is unknown: Fall back to parallel tier classification (ultra_rare / rare / uncommon / common / base) inferred from color and keyword keywords in the listing title.
- Print run extraction: The model parses ‘/25’, ‘25/50’, ‘x/25’ patterns from listing titles automatically. This is the most reliable signal for numbered cards where the inventory entry may not have the print run explicitly recorded.
Temporal scoring
Card values move faster than most collectibles markets — a player trade, injury, or playoff run can move prices 50% in a week. The recency decay curve is steeper than the default for this reason:
| Days Since Sale | Score | Rationale |
|---|---|---|
| 0–7 days | 1.00 | Full weight — most predictive of current market |
| 8–14 days | 0.95 | Minimal decay — still highly current |
| 15–30 days | 0.90 | Current month — reliable signal |
| 31–60 days | 0.78 | Meaningful decay begins — one news cycle ago |
| 61–90 days | 0.65 | Solid supporting comp |
| 91–135 days | 0.45 | Use with caution; significant events may have intervened |
| 136–225 days | 0.28 | Background signal only |
| 226–315 days | 0.15 | Low weight; use as fallback |
| 316–410 days | 0.12–0.19 | Year-ago comps; boosted if same season (playoff cards in playoff season) |
| 410+ days | 0.04 | Historical reference only |
Graded vs. raw market separation
Graded and raw cards are separate markets. A PSA 9 and a raw NM-MT card of the same issue may look like comparable sales but they are not. Graded cards command a consistent premium and attract a different buyer segment. The model handles this with a cross-type multiplier: when a comp is graded and the item is raw (or vice versa), the full similarity score is multiplied by 0.15 — pushing almost all cross-type comps below the 0.35 minimum threshold and effectively excluding them.
This is a hard architectural choice, not a soft discount. The model operates on the principle that graded and raw markets should be priced separately using their own comp pools. If you have insufficient graded comps for a graded card, the correct response is to source more graded comps, not to dilute the estimate with raw sales. Though, with the rest of the similarity scoring, if it’s truly the same card and not just similar, it will still appear as a comp — just discounted heavily compared to a graded version.
Grading company weights
Not all graded cards are equal, and not all grading companies are equally trusted by the market. When a comp is graded, the model applies a per-company weight on top of the similarity score. This is separate from the grade proximity scoring within the condition dimension — it is a market-trust discount applied to the comp as a whole.
| Grading Company | Weight | Rationale |
|---|---|---|
| AGS / CGX (AI graders) | 1.00 | AI/laser grading — highest consistency, no human subjectivity |
| BGS (Beckett) | 0.90 | Respected, consistent; subgrades provide granularity |
| BGSA (Beckett Auth) | 0.92 | Autograph focus; slight premium over standard BGS |
| SGC | 0.85 | Improving reputation; solid for vintage |
| GAI | 0.80 | Less common; reasonable consistency |
| CSG | 0.75 | Newer entrant; reputation still building |
| HGA | 0.70 | Inconsistent grading reputation in collector community |
| PSA | 0.65 | Largest pop reports; reputational discount for known grade inflation and authentication issues |
| Raw (ungraded) | 1.00 | Full weight in raw market; graded vs. raw separation handled by cross-type multiplier |
PSA weight rationale: PSA’s weight (0.65) reflects well-documented concerns about grade inflation, population manipulation, and authentication failures that have affected market trust since 2020–2021. A PSA 9 comp is still used — it is not excluded — but it receives a meaningful discount relative to more consistent graders. This weight is adjustable in the script if market conditions change or for personal preference.
Hype detection
An O(n²) rolling window scan identifies periods where sold listing volume spikes above a threshold within a 30-day window. Comps falling inside detected hype windows are excluded from price estimation but reported in the Notes column. A second-pass density fallback catches edge cases where the score filter trimmed a hot bucket below visibility.
The point: using a hype-inflated price from six months ago as a comp produces a systematically wrong estimate. The number of excluded comps is always reported.
Confidence tiering
| HIGH | 10+ comps, 4+ in 90 days — full hype detection operational |
| MEDIUM | 5+ comps, 2+ in 90 days — reliable estimate, trend detection active |
| LOW | 3+ comps, any recency — estimates produced, category median surfaced |
| INSUFFICIENT | Fewer than 3 comps — falls back to category median if available |
Color-coded in the Excel output. Every card knows how much to trust its own number.
Architecture
Single-file Python script (~1,472 lines), no external dependencies beyond openpyxl for Excel I/O. Reads a card inventory spreadsheet produced by eBay API or PWCC export, processes each card through the full similarity pipeline, writes formatted Excel output.
A —pwcc flag merges PWCC auction hammer prices on top of eBay data before scoring — useful for high-end graded cards where eBay comp volume is thin.
The framework is domain-agnostic. The similarity scoring architecture, confidence tiering, hype detection logic, and output structure can be remapped to any collectors or resale market where comparable sales data exists and items vary across multiple independent value dimensions.
The core scoring function
This is the heart of the model — the function that weighs a single comparable sale against the target card across all five dimensions, then applies the sport, graded/raw, and grader-company multipliers on top.
def compute_similarity(item: dict, comp: dict) -> float:
"""
Weight a comp against the item across all dimensions.
Weights:
Variant/type 35% — dominant price driver (base vs. auto vs. 1/1)
Set/mfr tier 25% — Topps Chrome vs. generic base
Temporal 20% — recency; card values move fast
Parallel 12% — color/rarity parallel tier
Condition 8% — raw condition or grade proximity
Multipliers applied on top:
Sport: cross-sport x0.10 (effectively excluded)
Graded/raw: cross-type x0.15 (effectively excluded)
Grader: per-company weight (AGS 1.00, PSA 0.65, etc.)
"""
item_variant = get_variant_tier(item.get("name", "") + " " + item.get("condition", ""))
comp_variant = get_variant_tier(comp.get("title", "") + " " + comp.get("condition", ""))
v_score = variant_tier_score(item_variant, comp_variant)
item_set_tier = get_set_tier(item.get("set", ""))
comp_set_tier = get_set_tier(extract_set_from_title(comp.get("title", "")))
s_score = set_tier_score(item_set_tier, comp_set_tier)
t_score = seasonal_score(comp.get("date"))
item_run = item.get("print_run")
comp_run = comp.get("comp_numbered") or extract_numbered_from_title(comp.get("title", ""))
item_par = get_parallel_tier(item.get("name", ""), item_run)
comp_par = get_parallel_tier(comp.get("title", ""), comp_run)
p_score = parallel_tier_score(item_par, comp_par)
item_grade = parse_grading_info(item.get("condition", ""))
comp_grade = parse_grading_info(comp.get("condition", ""))
c_score = grade_score(item_grade, comp_grade)
raw = (WEIGHT_VARIANT * v_score +
WEIGHT_SET * s_score +
WEIGHT_TEMPORAL * t_score +
WEIGHT_PARALLEL * p_score +
WEIGHT_CONDITION * c_score)
raw *= sport_multiplier(
item.get("sport", "unknown"),
comp.get("comp_sport", "unknown")
)
item_is_graded = item_grade["is_graded"]
comp_is_graded = comp_grade["is_graded"]
if item_is_graded != comp_is_graded:
raw *= GRADED_RAW_CROSS_MULTIPLIER
if comp_is_graded:
raw *= grader_weight(comp_grade.get("grader", "unknown"))
return round(raw, 4)Download the full source file (card_pricing_model.py, ~1,472 lines)
Special card flags
The model parses the card name and listing title for attributes that require explicit Notes output. Flags do not exclude comps or override the similarity score — they only surface information that affects interpretation of the estimate:
| Flag | Trigger Keywords | Effect on Output |
|---|---|---|
| [ROOKIE] | rookie, rc, 1st, first year, debut | Surfaces note; RC premium often 2–10× base card |
| [AUTO] | auto, autograph, signed, on-card auto, rpa | Surfaces note; verifies on-card vs. sticker distinction |
| [PATCH] | patch, relic, jersey, game-used, logoman, nameplate | Surfaces note; swatch quality affects value significantly |
| [NUMBERED] | /25, /10, 1/1, SSP, superfractor | Extracts print run; similarity scored by run proximity |
| [ERROR] | error, variation, misprint, photo variation | Surfaces warning; niche market, comp availability limited |
| [LIMITED] | hobby exclusive, case hit, award winner, box topper | Surfaces warning; verify comp set matches exactly |
| [HYPE] | ≥12 comp sales in any 30-day window | SELL NOW flag; hype-inflated historical comps excluded |
Hype and trend detection
Collectibles markets experience volume spikes driven by events: rookie breakouts, playoff runs, Hall of Fame announcements, scandal drops, and viral moments. A card priced during a hype window will show inflated sold comps that do not reflect sustainable market value. The model detects these spikes and handles them differently depending on whether they are current or historical.
Detection method: A rolling 30-day window scans all dated comps. If 12 or more sales cluster within any 30-day period, that window is flagged as a hype event. The threshold is tunable: 5 for sensitive (catches steady popular sellers), 12 for balanced (default), 20 for strict (only unmistakable viral events).
Current hype: If the spike is within the last 30 days, a [HYPE] TRENDING NOW / SELL NOW note is surfaced. These comps are retained and given maximum weight — a current hype price is the real price right now.
Historical hype: If the spike occurred in the past and has since subsided, those comps are excluded from the price estimate. Using a hype-inflated price from six months ago as a comp produces a systematically wrong estimate. The number of excluded comps is reported in the output.
Density fallback: A second pass checks raw candidate counts from the data layer (not just scored comps) to catch hype events where the score filter trimmed an unusually hot bucket below the visibility threshold.
Key classes and functions
| Function | Description |
|---|---|
compute_similarity(item, comp) | Core scoring function. Takes the item dict and a comp dict, computes weighted similarity across all five dimensions, then applies sport multiplier, graded/raw cross-type multiplier, and grader company weight in sequence. Returns a float in [0, 1]. Comps below MIN_SIMILARITY (0.35) are excluded from all downstream calculations. |
detect_hype_windows(comps) | O(n²) rolling window scan across dated comps, capped at n=150 (MAX_SNAPS) making the worst case 22,500 iterations. For each comp date as a window anchor, counts how many other comps fall within the next 30 days. Windows meeting the threshold are deduped by week to prevent adjacent overlapping windows from double-counting. Returns a list of window dicts with anchor date, comp count, average price, current/historical flag, and comp URLs for traceability. |
estimate_price(item, category_medians) | Main estimation function. Orchestrates flag detection, comp scoring, hype window detection and exclusion, confidence tier assignment, weighted price calculation, price floor enforcement, book value outlier check, and best-time-to-sell analysis. Returns a result dict consumed by write_output. |
detect_card_flags(text) | Regex-based parser for card name and listing title. Returns a dict of boolean flags (is_rc, is_auto, is_patch, is_numbered, is_refractor, is_error, is_limited) plus extracted print_run integer. Runs on both the item being priced and each comp title to enable flag-aware similarity adjustments. |
parse_grading_info(condition_str) | Parses ‘PSA 9’, ‘BGS 9.5’, ‘AGS 10’, ‘Raw NM-MT’ into structured grading info dict. Handles all major grading companies by regex match, extracts numeric grade, identifies grader, and sets is_graded flag. Used to separate graded and raw comp pools via the cross-type multiplier. |
Configuration constants
All tunable parameters are defined as named constants at the top of the script. The most likely adjustments:
- GRADING_COMPANY_WEIGHTS: Adjust PSA weight upward if market conditions improve, or add new graders as they emerge (CSG, HGA trajectories are still developing).
- HYPE_WINDOW_THRESHOLD: Lower to 5 for a collection heavy in niche/low-volume cards where smaller spikes are meaningful; raise to 20 for high-volume base cards where genuine demand can look like a spike.
- GRADED_RAW_CROSS_MULTIPLIER: Raise from 0.15 toward 0.30–0.40 only if you want cross-type comps as a pricing fallback when same-type comp volume is insufficient. The default keeps markets separated.
- WEIGHT_* constants: Rebalance dimensions for specific collection types. Vintage pre-war cards where condition is the primary value driver warrant higher WEIGHT_CONDITION and lower WEIGHT_VARIANT.
- CATEGORY_PRICE_FLOORS: Set per-sport and per-variant-type, detailed below.
Category price floors
No card prices below these regardless of comps. Floors are intentionally low — card floors are less predictable than fashion:
| Category / Variant | Floor |
|---|---|
| baseball/auto | $15.00 |
| baseball/patch_auto | $25.00 |
| baseball/numbered | $10.00 |
| baseball/refractor | $5.00 |
| baseball/base | $1.00 |
| basketball/auto | $20.00 |
| basketball/patch_auto | $35.00 |
| basketball/numbered | $12.00 |
| basketball/refractor | $8.00 |
| basketball/base | $1.00 |
| football/auto | $15.00 |
| football/patch_auto | $25.00 |
| football/numbered | $10.00 |
| football/refractor | $5.00 |
| football/base | $1.00 |
| hockey/auto | $10.00 |
| hockey/patch_auto | $20.00 |
| hockey/numbered | $8.00 |
| hockey/refractor | $5.00 |
| hockey/base | $1.00 |
| DEFAULT | $1.00 |
Basketball carries the highest floors across every variant tier — autos, patch-autos, and numbered cards in basketball consistently outprice their equivalents in the other three sports, reflecting the sport’s stronger collector demand and shallower card pool per season. Hockey sits lowest, consistent with its smaller collector market.
Data layer
The pricing model is data-layer agnostic — it reads whatever comp spreadsheet the upstream data collection produces. The current implementation supports:
- eBay sold listings: Via eBay API (Finding API / Browse API). Sold listing data is the most liquid market signal for sports cards and covers the widest range of cards across all price points.
- PWCC Marketplace: Via —pwcc flag. PWCC auction hammer prices represent a premium buyer segment and are particularly valuable for high-end graded cards where eBay may have thin comp volume.
Additional sources (Goldin, Heritage, COMC, 130point.com) can be integrated by producing a comp spreadsheet in the same column layout. The flags field in each comp slot is the primary integration point — the data layer is responsible for writing sport, grader, grade, rc, auto, numbered, and error flags in pipe-delimited format.
It’s also possible to build a database with API inputs from sources, and run the script off the database rather than Excel inputs/outputs — with results written back to mapped database fields.
Output columns
The Excel output contains 24 columns per card:
| Column Group | Columns |
|---|---|
| Card identity | Card name, Player, Sport, Set, Year, Condition/Grade |
| Special flags | RC (Y), Auto (Y), Patch (Y), Numbered (/xx) |
| Pricing | Book Value, Est. Sale Price, Price Low, Price High |
| Confidence | Confidence tier (color-coded HIGH/MEDIUM/LOW/INSUFFICIENT) |
| Timing analysis | Best Month to Sell, Best Season, Peak Price, Off-Peak Price, Peak Premium % |
| Comp diagnostics | Qualifying Comps, Recent Comps (90d), Hype Windows Excluded |
| Notes | All flags, warnings, and callouts in pipe-delimited text |
The Python source is available in the project files. A database-backed version replacing Excel I/O with direct API inputs and database outputs is documented as a logical next implementation step.