IMockMarket
A stock market simulator built as a closed economy: every account starts with the same capital, trades carry the costs a real broker charges, and seasonal leagues rank players on return alone — so results measure skill rather than deposits.

01. The Problem
Most trading simulators fail beginners in two specific ways. They let you top up your balance, so nobody's results are comparable and the leaderboard measures generosity rather than skill. And they make trading free, so they quietly teach the opposite of the most important lesson in retail investing — that the spread and the FX markup are what actually erode returns. The goal was a sandbox where the numbers mean something.
02. The Logic
A closed economy enforced by three invariants: no deposit endpoint exists, wallet transfers are restricted to a user's own wallets, and the signup grant is the only money that can ever enter an account — making percentage return a like-for-like comparison between any two players.
Trading costs modelled on how brokers actually earn in 2026 rather than on flat commissions, which are largely extinct in US retail: a bid-ask half-spread applied either side of the quote, and an FX markup taken from the converted amount. Costs are recorded separately from the execution price so the app can show what a real broker hides.
Seasonal ranking in opt-in leagues, with each player's baseline frozen at season start so prior gains aren't credited twice, and money persisting across boundaries while only the ranking resets.
Daily equity snapshots written by a scheduled job, so a league table is one indexed query rather than members × holdings × price lookups on every page load.
Defence-in-depth auth: JWT in an HttpOnly cookie with double-submit CSRF, email verification via single-use hashed OTPs with expiry and an attempt cap, token revocation, and fixed-window rate limiting keyed by authenticated user where available and by a proxy-aware client IP otherwise.
Money handled as Decimal end-to-end against Numeric columns, with row-level locking around every balance and holding mutation so concurrent trades cannot double-spend.
Live prices over a Finnhub websocket, with nightly history backfill and stock seeding scheduled around third-party rate limits.
Server-driven cost configuration, so the client quotes the exact figure the server will charge instead of keeping a second copy of the rates that silently drifts.
03. The Stack
04. The Solution
Implementation Result
A full-stack simulation where a React 19 SPA talks to a Flask API over an HttpOnly-cookie session. Users verify by email, trade 45 live-tracked symbols with realistic execution costs, hold multi-currency wallets, track holdings and history, watchlist and compare stocks, follow other traders through a privacy-preserving shadow feed, and compete in seasonal leagues they join with a code. The frontend caches and revalidates through TanStack Query; the backend owns its schema through Alembic migrations, runs scheduled jobs for price history and equity snapshots, and documents itself through an OpenAPI spec served with the deployed host injected at request time.
Key Outcomes
- 01.45 symbols tracked live over websockets, with nightly history backfill paced to stay inside free-tier provider limits.
- 02.Trading costs modelled at 5bp per side on trades and 0.5% on currency conversion — a round trip at an unchanged price loses money, exactly as it does in reality.
- 03.90-day seasons with per-user baselines and daily equity snapshots; leagues capped at 50 members and 5 per user, both as deliberate product limits rather than performance ceilings.
- 04.Email verification with 10-minute single-use codes, a 5-attempt cap and a 60-second resend cooldown, with every failure mode returning an identical response so the endpoints cannot be used to enumerate accounts.
- 05.Schema owned by Alembic across six migrations, each written to be reversible and to backfill existing rows rather than stranding them.
- 06.Six design documents covering the deployment topology, the cookie migration, the money-precision work, rate limiting, email verification, and the economy and leaderboard rules.
Reflection
- A type assertion is an instruction to stop checking. Casting an error object to silence the compiler hid a null dereference that took an entire page to a white screen — it type-checked, built, and linted cleanly the whole way. Error boundaries now contain render failures to the page rather than the app.
- A browser CORS error is a symptom, not a diagnosis. A production outage that presented as a CORS policy failure turned out to be a base URL missing its path prefix: the preflight hit an unmatched route, and the one endpoint without that prefix kept returning 200 and hid it.
- Invariants that a database constraint cannot express — a balance check, a member count — need explicit row locks. A UNIQUE index cannot stop two people passing the same COUNT check simultaneously.
- Caching is a design decision with a shape. Migrating from per-component effects to a query cache fixed a class of staleness bugs outright, but only after realising the stale user profile was broken at both ends: a localStorage snapshot on the client and frozen JWT claims on the server.
- Designing for privacy constrains the product in useful ways. Ranking on percentage return rather than balance, and replacing an ego-centric board with opt-in leagues, made the feature both safer and more social than the version that published more.