Featured build · Go systems case study

Live

pricewatch

A Go CLI that prices an 8,800-card Pokémon collection on a free budget of 1,000 API requests a day, and never guesses which print a card is.

$ pricewatch run --budget 100 --no-wait
run 3 (pokewallet)
38 requests, 52 cards
ok 50  failed 2  deferred 0
4886 cards not due yet

changed since each card's last price
 BEFORE    NOW   DELTA      %
  50.47  60.00   +9.53  +18.9
  ex ruby & sapphire 59/109 reverse holo

progress saved · next run resumes here
Role
Sole developer
Status
Live · runs hourly on GitHub Actions
Outcome
~915 requests a day to keep 4,900 prices current
Stack
Go, SQLite, GitHub Actions, PokéWallet API, MCP

Why I built it

Learning Go on a problem with real limits

After nine years of C# backend work, I wanted to learn Go on something that would push back. My own collection, 8,800 rows exported from TCG Collector, gave me a real product with a hard constraint. Where two designs worked, I picked the one that exercised more Go.

The problem

8,800 cards, 1,000 requests a day

The free price API allows 100 requests an hour and 1,000 a day. The collection can't be priced in one go, so pricewatch never tries. Each run is a bounded slice of work, and progress lives in SQLite so the next run carries on where the last one stopped.

How a run works

Bounded slices, durable progress

Built

Import resolves the collection once. Every hour after that, a run picks the cards that are due, prices them within the rate limits, saves each observation and republishes the status page.

  1. Step 1

    Resolve

    Match each CSV row to one card by region, set, number, variant and language.

  2. Step 2

    Pick due cards

    Never priced first, then most overdue, then most valuable.

  3. Step 3

    Pace

    Spend against the API's own remaining count, pause at the hour limit.

  4. Step 4

    Price

    A bounded worker pool. One request prices every variant of a card.

  5. Step 5

    Save + report

    One observation per card, plus every card that moved.

  6. Step 6

    Publish

    Rebuild the public status page from derived numbers only.

Hourly loop ↶

GitHub Actions runs at seven past each hour. A missed hour just means the next run has a little more to do.

Value tiers

Spend requests where the money is

A card's value sets how often it's due. Pricier cards move more in dollars, so they're checked more often.

Card valueChecked every
$100+1 day
$20 to $1002 days
$5 to $204 days
Under $57 days

Daily budget

~915 / 1,000

requests a day to keep about 4,900 priceable cards on schedule.

01,000

What it refuses to guess

A wrong match fails silently

The export has no card IDs. When a row can't be matched to exactly one card, pricewatch reports it instead of picking one. On chase cards, the gap between prints dwarfs any price movement.

Tropius 001/084 · Normal

$0.06

Tropius 001/084 · Reverse Holo

$0.20

Rebuilt from the database after every hourly runLive status page

pricewatch status page

The live pricewatch status page: price index, coverage and the week's biggest price moves.

Rebuilt from the database after every hourly run · Live status page

Ask it anything

Questions in plain English

pricewatch mcp is a local MCP server. Claude starts it, and seven read-only tools answer questions from the same database the hourly job writes.

  1. Which Pokémon is worth the most across every card I own?

    pricewatch · query

    Pikachu, by a long way. 52 cards worth $2,849.93, and one of them, Pikachu with Grey Felt Hat, is over a third of that at $1,015.89.

    PokémonCardsValue
    Pikachu52$2,849.93
    Sylveon21$2,361.85
    Umbreon8$1,553.93
  2. Is TCG Collector overvaluing my chase cards?

    pricewatch · query

    Mostly no. Nearly every card worth $100 or more is within a few dollars of market. Four are listed more than $50 high.

    CardListedMarket
    Paradise Resort (Staff)$641.76$433.51
    Umbreon ex$1,410.28$1,326.66
    Pikachu with Grey Felt Hat$1,081.11$1,015.89
    Sylveon ex$533.18$476.49

    data_as_of Oct 5, 2026

Read-only, enforced by SQLite

The database opens with mode=ro and query_only, so even the raw SQL tool can't write. SQLite enforces it, not a check on the query text.

Every answer is dated

Results carry data_as_of. If a fresh copy can't be downloaded, the server answers from its cache and says the data is stale.

Prints stay separate

find_cards returns one row per print, and price history takes one exact print. When a name matches several, Claude sees them all and has to pick or ask.

Engineering decisions

Safe to stop, safe to re-run

Two-stage Ctrl-C

The first press stops new cards and lets in-flight ones finish. The second cancels them. Completed work is saved either way, using context.WithoutCancel so the checkpoint outlives the cancel.

Pace against the source's count

The daily count lives in SQLite and is corrected from response headers, so a restart can't overspend. A “too many requests” answer is read, waited out, and the run carries on.

Standard library first

Two third-party packages: pure-Go SQLite and a rate limiter. CSV, HTTP, JSON, logging, embedded templates and fake-clock tests all come from the standard library.

Coming from C#

What surprised me after nine years of .NET

Errors as values

if err != nilthrow / catch

Repetitive, but failure is in the signature and every caller has to acknowledge it. I wouldn't swap back, though I'd take a shorter syntax.

Context vs CancellationToken

context.ContextCancellationToken

Cancellation and deadline travel together, which made the three lifetimes in shutdown easy to keep apart.

Goroutines and select

go + selectasync / Task.WhenAny

No async coloring up the call chain. One select reads as “send the job, or stop.”

Interfaces declared by the consumer

implicit: IStore + DI

Great at this size. For a bigger domain I'd still want C#'s explicit contracts and a container.

Current state

Phases 1 to 3 complete, plus an MCP server

Built
  • Collection import with reported, never guessed, matches
  • Bounded, resumable pricing runs with a worker pool
  • Value tiers and due-card scheduling
  • Hourly GitHub Actions runs against a private data repo
  • Public status page rebuilt every run
  • Offline tests with a fake clock for the worker pool
  • Special prints priced from their own products: ball, Energy and Rocket reverse holos, and Prerelease, Staff, Worlds and 30th Anniversary promos
  • MCP server so Claude can answer questions about the collection

What’s next

Match the cards it can't price yet

The pipeline is done. The remaining work is matching, so more of the collection gets a price without guessing.

Planned next
  • Prints still reported as unsupported: Cosmos, Prize Pack, set stamps and some promos (about 3% of the collection)
  • Rows the import reports as unmatched or ambiguous

Explore the work

Built to run unattended

Next in the set

Featured build

Live
A sample Job Hunt Pipeline digest with role cards showing fit scores and must-haves checklists.

Job Hunt Pipeline

A daily AI job search that screens LinkedIn roles against my rules and resume.

  • C#
  • .NET 10
  • Claude Code

Case study 5 of 5. Up next: Job Hunt Pipeline.

Featured build

In Development
PokeJudge console output for a late-arrival scenario with a clarifying question and grounded recommendation.

PokéJudge AI

An AI rules assistant that asks the right questions before it recommends a cited ruling.

  • C#
  • .NET
  • RAG / Retrieval

Let's connect

If you're hiring, have a project in mind, or want to talk shop, I'd love to hear from you.

Email Me