The idea
Lottery advice online is mostly folklore — "hot numbers," "your birthday is
lucky," systems that don't change anything mathematically. I wanted the
opposite: an app that tells you the truth about what you're actually buying.
That became Strategy Tracker — expected
value per draw, historical number frequency, and a birthday-bias detector
(a large share of players pick numbers 1–31, which measurably raises the odds
you'll split a jackpot with someone else if you win).
To be clear going in: no lottery has a positive EV, and nothing here changes
your odds. What changes is whether you understand the actual cost, and
whether you're playing at a relatively better or worse moment.
Architecture
- Flutter for the client — one codebase for iOS and web (Android next).
- FastAPI + DuckDB for the backend — draw history and stats live in DuckDB, which is fast enough for the aggregation queries (frequency counts, cold-streak detection, sum-distribution analysis) without needing a heavier OLAP setup.
- Postgres for auth/user accounts, kept separate from the DuckDB draw data.
-
Fly.io, one small
shared-cpu-1xmachine, scale-to-zero. Cheap enough to run as a side project — real cost has been under $6/month. - GitHub Actions drives everything on a schedule: scrapers pull each lottery's result a few hours after the draw, EV recalculates the moment a new jackpot is announced, and a separate pipeline generates per-lottery statistics content automatically.
Supporting 9 different lotteries meant 9 different bonus-ball formats
UK, Germany, Canada, Ireland, Austria, Switzerland, South Africa, Poland, and
Eurojackpot don't agree on how many bonus numbers a draw has — most have one,
Eurojackpot has two, Poland has none. That "none" case was the interesting
one: a naive implementation defaults an absent value to 0, and 0 quietly
looks like a valid drawn number everywhere in the UI until someone points out
your Polish Lotto results are showing a bonus ball that doesn't exist.
The morning everything broke
One small shared machine running scrapers, content generation, and live app
traffic all at once has a failure mode: if enough scheduled jobs land in the
same few minutes, they stampede the cold machine under memory pressure. That's
exactly what happened — several unrelated cron jobs (in two different repos)
ended up scheduled within seconds of each other, and DuckDB's write-locking
meant they queued up and timed each other out. ~11 hours of "backend
unreachable" failures across the content pipeline before I caught it.
Fix was mostly boring: bump the machine's memory, and enforce a hard rule —
every scheduled job that touches the backend has to be at least 30 minutes
from every other one, across both repos. Wrote it down as a standing rule
so it doesn't happen again as more lotteries get added.
What's next
Android build, a proper iOS App Store listing, and expanding the EV math to
a few more lotteries. If you're into "boringly honest" side projects or have
opinions on DuckDB for this kind of workload, I'd love to hear from you.
Top comments (0)