Skip to content

feat: fair value range per card #91

Description

@hjacque

Goal

Surface a composite Opportunity Score and Fair Value Range per card per PSA Grade — a data-backed answer to "is this listing a deal?" — so buying decisions are driven by signal rather than intuition.

Design

Fair Value Range

A (low, mid, high) triplet per Product per PSA Grade, computed independently per grade. Null for grades with insufficient sold history (no fabrication).

Base Range (from #84):

  • mid = median of eBay sold comps within the Sold Comp Window
  • low = mid − 1 stddev
  • high = mid + 1 stddev

Sold Comp Window (adaptive):

  • 30 days if sufficient comps exist, expands to 90 days otherwise
  • Exact thresholds require data calibration — not hardcoded

Opportunity Score

A numeric score (0–100) per Product per PSA Grade combining all signals. Higher = stronger opportunity.

Structure — hierarchical:

  • Price dislocation vs Fair Value Range is the gate (required for any non-zero score)
  • The 7 supporting signals determine confidence and amplify/dampen the score
Signal Source Role
eBay sold prices #84 Base Range (required — primary signal)
Relative Pop Velocity #86 Supply pressure relative to same-era cards; high velocity compresses score
Card Popularity Score #99 Composite: Pokémon popularity (#89) + multi-Pokémon bonus (#97) + premium symbols (#98)
52-week high/low #92 Buying near 52-week low amplifies opportunity
Listing Depth #93 Shallow depth discounts the Market Price floor
PSA grade price spread #94 Cross-grade coherence; validates range relative to adjacent grades
Listing Staleness #95 Stale listings discounted — not real market willingness to transact
Grade Liquidity Share #96 Illiquid grades discounted — a deal you can't exit isn't an opportunity

Model iteration:
Signal weights are not hardcoded. The infrastructure stores each signal's value per sync with full history, enabling retrospective analysis (score at T vs price at T+90d) once sufficient data accumulates. Weights are calibrated empirically.

Schema

Two separate tables, full history (rows accumulate, never overwritten):

FairValueRange
  productId      String
  grade          Int          (1–10)
  low            Float?
  mid            Float?
  high           Float?
  sampleCount    Int
  windowDays     Int          (30 or 90 — actual window used)
  computedAt     DateTime

OpportunityScore
  productId      String
  grade          Int
  score          Float        (0–100)
  priceVsRange   Float        (% below low — the raw dislocation)
  popVelocitySignal    Float?
  popularitySignal     Float?
  weekHighLowSignal    Float?
  listingDepthSignal   Float?
  gradeSpreadSignal    Float?
  stalenessSignal      Float?
  liquiditySignal      Float?
  computedAt     DateTime

Computation

A dedicated FairValueSyncService runs nightly (or triggered after #84's eBay Sync). Pure computation over stored data — no external API calls. Reads eBay sold history and signal data already in the database, writes to the two tables above.

UI surfaces

  1. Main table column — highest-scoring PSA Grade's Opportunity Score, sortable
  2. Side panel — full per-grade breakdown: Fair Value Range + Opportunity Score + per-signal values
  3. Opportunities page — dedicated page showing top buying opportunities across all tracked Products for the day; at most N cards scoring above a minimum floor (both tunable); ranked by score descending; empty page on a slow day is intentional

Each opportunity card shows:

  • Card image
  • Card name + Product Set
  • Highest-scoring PSA Grade + score
  • Current Market Price vs Fair Value Range (e.g. €170 vs €220–280)
  • Dominant signal driving the score

Roadmap

Phase 1 — Data foundation 🔴

Issue What
#84 eBay sold listings — gates everything

Phase 2 — Fair value core 🔴

Ships together once #84 data exists. #92#95 reuse existing data or scraping infrastructure and are fast to build.

Issue What
#91 (this issue) Schema, FairValueSyncService, Base Range computation, all UI surfaces
#92 52-week high / low — derivable from existing price history
#93 CardMarket listing depth — same scraping pass as current source
#94 PSA grade price spread — derived from existing PSA Grade Prices
#95 CardMarket listing staleness — needs HTML verification first

Phase 3 — Signal enrichment 🟡

Ship after Phase 2, once #84 data has accumulated enough to calibrate thresholds.

Issue What
#96 Grade Liquidity Share — per-grade eBay sold volume distribution
#86 Relative Pop Velocity — requires PSA pop snapshot history (architectural change)

Post-MVP — Popularity signal 🟢

Long dependency chain with manual data-entry work. Adds meaningful demand-side signal once in place.

Issue What
#87 Pokémon entity (Pokédex seed)
#88 Pokémon ↔ card associations
#101 Research: external Pokémon popularity data sources
#89 Pokémon popularity score
#97 Multi-Pokémon card signal
#98 Premium card symbol signal
#99 Card popularity score (aggregator)
#100 Filter and browse products by Pokémon

Blocked by

Value

Turns Gather from a price tracker into a buying-decision tool. The Opportunities page makes the signal actionable without requiring manual browsing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions