The REST API behind iMockMarket, a stock-trading simulator where players trade real market data with fake money and are ranked against their friends.
Built with Flask + PostgreSQL. It owns the entire domain: accounts and sessions, wallets in multiple currencies, buy/sell execution with realistic trading costs, live and historical price data pulled from three market-data providers, social "shadow" links, and seasonal leaderboards.
| Frontend repo | https://github.com/HakeemTheEmperor/trade-sim-app |
| Backend repo (this one) | https://github.com/HakeemTheEmperor/trade-sim-backend |
| Live app | https://app.imockmarket.toluwalase.me |
| Live API | https://api-imockmarket.toluwalase.me/api/v1 |
| API docs (Swagger UI) | https://api-imockmarket.toluwalase.me/docs |
- What this service does
- Architecture
- Tech stack
- Getting started
- Configuration
- Database
- API reference
- Authentication and security
- The simulated economy
- Background jobs and market data
- Deployment
- Further documentation
- Contributing
- License
| Capability | Summary |
|---|---|
| Accounts | Signup gated behind a 6-digit emailed OTP; sign-in issues a JWT in an HttpOnly cookie. Roles: USER, ADMIN, SUPER_ADMIN. |
| Wallets | Multi-currency cash wallets. Every new account is created with 100,000.00 in a USD wallet; additional wallets start at 0. |
| Trading | Buy and sell against live prices, with a bid-ask half-spread applied on each side and fractional quantities supported. |
| Market data | ~45 seeded symbols, real-time prices over a Finnhub websocket, nightly fundamentals refresh and daily price-history backfill. |
| Portfolio | Holdings, per-symbol quantity, equity valuation, and a paginated transaction ledger. |
| Watchlist | Per-user symbol watchlist. |
| Shadows | Mutual opt-in links that let one player follow another's activity, with invite/accept/decline and notifications. |
| Leagues & leaderboards | Named, join-code leagues (max 50 members, 5 leagues per user) ranked by percentage return over 90-day seasons. |
| Notifications | In-app notification feed with unread counts. |
Browser (trade-sim-app)
│ fetch(..., credentials: "include") + X-CSRF-TOKEN header
▼
Cloudflare ──► Render ──► ProxyFix (recovers the real client IP)
│
▼
Flask app (app/__init__.py: create_app)
│
┌─────────────┼──────────────┐
▼ ▼ ▼
CORS + security JWT cookie rate limiter
headers + CSRF (per IP / user)
│
▼
Blueprint (app/routes/*) ← HTTP shape only
│
▼
Service (app/services/*) ← business rules
│
▼
Model (app/models/*) ← SQLAlchemy ORM
│
▼
PostgreSQL
Alongside the request path, inside the same process:
• APScheduler — nightly seed, history backfill, equity snapshots, season roll
• WebSocketListener — streams Finnhub trades into stock_price
The codebase keeps a strict three-layer split. When adding a feature, add one file per layer rather than growing a route:
| Layer | Directory | Responsibility | Must not |
|---|---|---|---|
| Routes | app/routes/ |
Parse and validate the request, call one service, shape the JSON response, apply @jwt_required and @rate_limit. |
Contain business rules or touch db.session. |
| Services | app/services/ |
All business logic, transactions, and invariants (ownership checks, fee maths, ranking). Raise domain exceptions from app/custom_exceptions.py. |
Know about request, jsonify, or HTTP status codes. |
| Models | app/models/ |
SQLAlchemy table definitions, enums, relationships, and small row-level helpers. | Contain multi-entity workflows. |
Errors are converted to HTTP responses in exactly one place —
app/error_handlers.py — so a service can raise a domain error without knowing
what status code it becomes.
trade-sim-backend/
├── app/
│ ├── __init__.py # create_app(): config, extensions, blueprints, startup jobs
│ ├── index.py # WSGI entrypoint (`app.index:app`) used by gunicorn
│ ├── custom_exceptions.py # Domain exceptions raised by services
│ ├── error_handlers.py # The single exception → HTTP response mapping
│ ├── data_seed.py # Seeds available stocks + fundamentals from FMP
│ ├── websocket_listener.py # Finnhub real-time trade stream → stock_price
│ ├── integrations/
│ │ └── providers.py # Every external endpoint URL, in one place
│ ├── models/ # SQLAlchemy models (one file per table group)
│ ├── routes/ # Flask blueprints, all mounted under /api/v1
│ ├── services/ # Business logic
│ ├── utils/
│ │ ├── auth_utils.py # Role decorators, identity helpers
│ │ ├── enums_utils.py # Shared enums
│ │ ├── fees.py # Trade half-spread and FX spread maths
│ │ ├── rate_limit.py # In-memory fixed-window limiter
│ │ ├── update_history.py # Daily price-history backfill (Massive/Polygon)
│ │ └── validation_utils.py # Input validation helpers
│ ├── static/openapi.yaml # OpenAPI 3 spec served at /openapi.yaml
│ └── docs/ # Additional spec fragments
├── migrations/ # Alembic; the schema is owned here, not by create_all
├── docs/ # Design and operations write-ups (see below)
├── Dockerfile
├── docker-compose.yml
├── bootstrap.sh # `flask db upgrade` then exec gunicorn
├── Pipfile / Pipfile.lock
└── .env.example # Every variable, annotated — start here
| Concern | Choice |
|---|---|
| Language | Python 3.13 |
| Web framework | Flask |
| ORM / migrations | Flask-SQLAlchemy, Flask-Migrate (Alembic) |
| Database | PostgreSQL 16 |
| Auth | flask-jwt-extended (JWT in HttpOnly cookies + double-submit CSRF) |
| Scheduling | APScheduler (BackgroundScheduler) |
| Realtime | websocket-client against Finnhub |
| API docs | OpenAPI 3 + flask-swagger-ui |
| Server | gunicorn (1 worker, 4 threads) |
| Packaging | pipenv, Docker |
| Hosting | Render (app), Supabase (Postgres), Cloudflare (DNS/proxy) |
With Docker (recommended)
- Docker and Docker Compose. Compose ships with Docker Desktop; on Linux see the install guide.
Without Docker
- Python 3.13
pip(bundled with Python — check withpip --version)pipenv—pip install pipenv --user- A PostgreSQL 16 instance you can reach
git clone https://github.com/HakeemTheEmperor/trade-sim-backend.git
cd trade-sim-backend
cp .env.example .env # then edit — see Configuration below
docker compose up --buildThe API is then available at http://localhost:5000, with Swagger UI at http://localhost:5000/docs and a health probe at http://localhost:5000/health.
To stop, and to also drop the Postgres volume:
docker compose down # stop
docker compose down -v # stop and delete the database volumepipenv install --dev
cp .env.example .env # point SQLALCHEMY_DATABASE_URI at your own Postgres
export FLASK_APP=app.index
RUN_BACKGROUND_JOBS=false pipenv run flask db upgrade # apply migrations
pipenv run flask run --port 5000 # or: ./bootstrap.shRUN_BACKGROUND_JOBS=false is important for any flask db … command: it stops
create_app() from starting the seeder, scheduler and websocket, which would
otherwise try to query tables the migration is about to create.
On the first boot against an empty database the app will:
- Create the super-admin from
ADMIN_EMAIL/ADMIN_PASSWORD(already verified — it never goes through the OTP flow). - Open a leaderboard season and enrol existing accounts.
- Start a background thread that seeds ~45 symbols from FMP and backfills price history from Massive/Polygon. This takes roughly 15 minutes, because the backfill sleeps 20s between symbols to respect a 5 req/min free tier. The API serves traffic normally throughout; stock endpoints just return little until it finishes.
Both jobs are skipped when the tables are already populated, so restarts are cheap — important, since one full seed uses ~45 of FMP's 250 daily requests.
All configuration is environment variables, loaded from .env via
python-dotenv (and by docker-compose through env_file).
.env.example is the authoritative reference. It documents
every variable, the reasoning behind each default, and the failure mode of
getting it wrong. The tables below are a summary.
| Variable | Purpose |
|---|---|
SQLALCHEMY_DATABASE_URI |
Postgres connection string. Overridden by docker-compose from the POSTGRES_* values so the two can't drift. |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
Credentials for the composed db service. |
JWT_SECRET_KEY |
Signs every JWT. Must be long and random — anyone holding it can forge a token for any user or role. Generate with python -c "import secrets; print(secrets.token_urlsafe(48))". |
ADMIN_EMAIL / ADMIN_PASSWORD |
Seed super-admin created on startup. |
CORS_ORIGINS |
Comma-separated allowed browser origins. Never * — cookie auth uses credentialed requests. |
Every provider URL is assembled in one module,
app/integrations/providers.py; only keys and
optional base overrides live in the environment.
| Provider | Variable | Used for | Free-tier limit that shaped the design |
|---|---|---|---|
| Financial Modeling Prep | FMP_API_KEY |
Seeding symbols, company profiles, market caps | ~250 req/day; one seed ≈ 45 |
| Finnhub | FINNHUB_API_KEY |
Real-time price websocket | 1 connection — hence the single gunicorn worker |
| Massive (formerly Polygon.io) | POLYGON_API_KEY |
Daily price-history backfill | 5 req/min — hence the 20s sleep per symbol |
| exchangerate-api.com | EXCHANGE_RATE_API |
Cross-currency wallet transfers | Daily refresh; rates cached to ~1 req/day |
| Brevo | BREVO_API_KEY |
Signup verification emails | 300 emails/day |
Local development needs none of these to sign up. Leave
BREVO_API_KEYunset and the OTP is written to the server log at WARNING level (code=123456) instead of being emailed, so the whole verification flow works without a Brevo account. In production it is required — without it signup still returns201and no user can ever verify.
These three are worth reading about before deploying, because getting them wrong produces a misleading symptom rather than an obvious error:
| Variable | Wrong value looks like | Correct value |
|---|---|---|
JWT_COOKIE_DOMAIN |
Browsing works fine, but any action logs the user out. (The access cookie is still sent; the CSRF cookie can't be read by JS on another host, so the API answers 401 Missing CSRF token and the client treats it as an expired session.) |
The shared parent with a leading dot, e.g. .toluwalase.me, whenever the frontend and API are on different subdomains. Empty for localhost. |
TRUSTED_PROXY_HOPS |
Too low: every client collapses onto one infrastructure IP and rate limits over-restrict. Too high: silently exploitable — the extra X-Forwarded-For entry is client-controlled, so anyone can forge an IP and walk past the limiter. |
Measure it. 0 local, 2 Render only, 3 Cloudflare-proxied → Render. Verify with LOG_CLIENT_IP=true, then turn it back off. |
VITE_API_BASE_URL (frontend) |
Every endpoint 404s, surfacing in the browser as a CORS preflight failure, not a 404. /health still returns 200, so the base URL looks fine. |
Must include the /api/v1 prefix. |
users ──┬── wallets ────────────┬── transactions
│ (multi-currency, │ (BUY / SELL / TRANSFER, with fee + currency)
│ 100k USD at signup)│
├── users_stock_wallet ──── available_stocks ──┬── stock_price
│ (holdings) └── stock_history
├── watchlists ──────────── available_stocks
├── email_verification_codes (hashed OTP, expiry, attempt counter)
├── shadow_links (subject ↔ shadow, PENDING/ACCEPTED)
├── notifications (typed, JSON payload, read flag)
├── league_memberships ──── leagues (join code, owner, member cap)
├── season_participants ─── seasons (baseline equity per season)
└── equity_snapshots (nightly cash / holdings / equity)
revoked_tokens — JWT blocklist by jti, checked on every request
exchange_rates — cached FX pairs with the provider's own next_update
Notes worth knowing before you touch a query:
- All money is
Numeric, neverfloat. Balances, prices, fees and equity areDecimalend to end; a custom Flask JSON provider serialises them as JSON numbers so response shapes are unchanged. Seedocs/money-decimal-and-migrations.md. - Quantities are fractional (
Numeric(15, 6)), so partial shares work. - Every user-owned table cascades on user delete.
The schema is owned by Alembic, not db.create_all().
export FLASK_APP=app.index
# Create a migration after changing a model
RUN_BACKGROUND_JOBS=false pipenv run flask db migrate -m "short description"
# Review the generated file in migrations/versions/ — always. Autogenerate
# misses enum changes, server defaults and index renames.
RUN_BACKGROUND_JOBS=false pipenv run flask db upgrade # apply
RUN_BACKGROUND_JOBS=false pipenv run flask db downgrade # roll back onebootstrap.sh runs flask db upgrade on every container start, so deploys
apply migrations automatically before gunicorn binds its port.
Interactive docs are served by the app itself at /docs (Swagger UI), backed
by the OpenAPI 3 spec at /openapi.yaml. That route rewrites the servers:
entry from PUBLIC_API_BASE_URL at request time, so "Try it out" targets the
deployed host rather than the localhost value committed to the file.
- Base path: every blueprint is mounted under
/api/v1./healthand/openapi.yamlare the only routes outside it. - Auth: send the session cookie (
credentials: "include"in the browser). State-changing requests must additionally echo the readablecsrf_access_tokencookie as anX-CSRF-TOKENheader. - Responses: JSON. Successful payloads are wrapped as
{"data": …}; errors are{"error": "message"}. - Money: JSON numbers, computed as
Decimalserver-side.
Auth — /api/v1/auth
| Method | Path | Auth | Rate limit | Description |
|---|---|---|---|---|
POST |
/signup |
— | 10 / 60s | Create an account; emails a 6-digit OTP. Returns 201 with no session. |
POST |
/verify-email |
— | 10 / 300s | Exchange the OTP for an active account and a session cookie. |
POST |
/resend-otp |
— | 3 / 300s | Re-issue a verification code. |
POST |
/signin |
— | 5 / 60s | Sign in. Sets the JWT + CSRF cookies. 403 ACCOUNT_UNVERIFIED if the OTP was never entered. |
GET |
/me |
✔ | — | Current user, read from the database (not from JWT claims, so profile edits are reflected immediately). |
POST |
/logout |
✔ | — | Revoke the token (added to the revoked_tokens blocklist) and clear cookies. |
POST |
/reset-password |
✔ | 5 / 300s | Change password. |
POST |
/admin-signup |
admin | — | Create an elevated account. |
Stocks & trading — /api/v1/stocks
| Method | Path | Auth | Rate limit | Description |
|---|---|---|---|---|
GET |
/all |
✔ | — | Paginated, sortable list of tradable symbols. |
GET |
/symbol/<symbol> |
✔ | — | One stock with its current price. |
GET |
/id/<id> |
✔ | — | Same, by internal id. |
GET |
/search/symbol/<symbol> |
✔ | — | Symbol search. |
GET |
/search/company/<name> |
✔ | — | Company-name search. |
GET |
/stock/price/<symbol> |
✔ | — | Latest price only. |
GET |
/stock/history/<symbol> |
✔ | — | Daily close history for charts. |
POST |
/buy |
✔ | 30 / 60s | Buy. Executes above the quote by the half-spread. |
POST |
/sell |
✔ | 30 / 60s | Sell. Executes below the quote by the half-spread. |
GET |
/user |
✔ | — | All holdings. |
GET |
/user/quantity/<symbol> |
✔ | — | Held quantity for one symbol. |
GET |
/portfolio |
✔ | — | Valued portfolio (cash + holdings + equity). |
Wallets — /api/v1/wallet
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/all |
✔ | All wallets for the caller. |
GET |
/<wallet_id> |
✔ | One wallet. |
POST |
/create |
✔ | Create a wallet in another currency (starts at 0). |
DELETE |
/delete |
✔ | Delete a wallet. |
POST |
/transfer |
✔ | Move money between your own wallets only; cross-currency transfers pay the FX spread. |
Transactions — /api/v1/transactions
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/history |
✔ | Paginated ledger, filterable by wallet and currency. |
GET |
/transaction/<id> |
✔ | One transaction, including the fee charged. |
Watchlist — /api/v1/watchlist
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/get |
✔ | The caller's watchlist. |
POST |
/add/<symbol> |
✔ | Add a symbol. |
DELETE |
/delete/<symbol> |
✔ | Remove a symbol. |
GET |
/check/<symbol> |
✔ | Whether a symbol is watched. |
Shadows (social) — /api/v1/shadow
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/invite |
✔ | Invite another user to link. |
POST |
/invite/<link_id>/accept |
✔ | Accept an invite. |
POST |
/invite/<link_id>/decline |
✔ | Decline an invite. |
GET |
/invites |
✔ | Incoming invites. |
GET |
/shadows |
✔ | Users shadowing you. |
DELETE |
/shadows/<link_id> |
✔ | Remove a shadow. |
GET |
/following |
✔ | Users you shadow. |
DELETE |
/following/<link_id> |
✔ | Stop following. |
Leagues & leaderboard — /api/v1/leagues, /api/v1/leaderboard
| Method | Path | Auth | Rate limit | Description |
|---|---|---|---|---|
GET |
/leagues |
✔ | — | Leagues you belong to. |
POST |
/leagues |
✔ | 10 / 3600s | Create a league; returns its join code. |
POST |
/leagues/join |
✔ | 10 / 300s | Join by code. |
GET |
/leagues/<id> |
✔ | — | Season standings, computed live. |
GET |
/leagues/<id>/career |
✔ | — | All-time standings. |
DELETE |
/leagues/<id>/leave |
✔ | — | Leave a league. |
DELETE |
/leagues/<id> |
✔ | — | Delete a league (owner only, and only while empty). |
GET |
/leaderboard/season |
✔ | — | The current season window. |
Users, notifications, config, ops
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/users/user |
✔ | Profile. |
POST |
/api/v1/users/user/edit |
✔ | Edit profile. |
GET |
/api/v1/users/all |
admin | List users. |
GET |
/api/v1/notifications/ |
✔ | Notification feed. |
GET |
/api/v1/notifications/unread-count |
✔ | Unread badge count. |
POST |
/api/v1/notifications/<id>/read |
✔ | Mark one read. |
POST |
/api/v1/notifications/read-all |
✔ | Mark all read. |
GET |
/api/v1/config/trading |
✔ | Current spread/fee rates, so the client can show costs before a trade instead of hardcoding them. |
GET |
/health |
— | Liveness probe. Runs SELECT 1, so pinging it also keeps the free-tier database from idling. |
Sessions. The JWT lives in an HttpOnly cookie, not localStorage, so
JavaScript — and therefore any XSS — cannot read it. Access tokens last 6 hours
by default (JWT_ACCESS_TOKEN_EXPIRES) and logout adds the token's jti to a
revoked_tokens blocklist that is consulted on every authenticated request.
CSRF. Because the cookie is sent automatically, cookie auth needs CSRF
protection. flask-jwt-extended's double-submit scheme is enabled: a second,
JS-readable csrf_access_token cookie must be echoed as an X-CSRF-TOKEN
header on every POST/PUT/PATCH/DELETE. The frontend does this centrally
in apiClient.ts. See
docs/httponly-cookie-migration.md.
Rate limiting. A dependency-free, in-memory fixed-window limiter
(app/utils/rate_limit.py) keyed by client IP or user id, applied to the
endpoints listed above. It is process-local by design, which is adequate for the
single-worker deployment; scaling to multiple workers means moving the counters
to a shared store such as Redis. Counters reset on deploy and on free-tier
spin-down.
Client IP. ProxyFix is configured with a measured TRUSTED_PROXY_HOPS so
the limiter sees the real client address rather than the edge's. See the warning
in Variables that fail quietly and
docs/rate-limiting-and-hardening-plan.md.
Response headers. Every response carries X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer, and HSTS.
Information disclosure. Endpoints are deliberately vague where being helpful would leak: sign-in doesn't distinguish an unknown email from a wrong password, and a failed transfer returns the same message whether the wallet doesn't exist or isn't yours — so the endpoint can't be used to enumerate wallet ids or owners. Auth rejections are logged in full server-side, because three very different production failures otherwise look identical.
The leaderboard only means something if real-world wealth can't buy rank, so the economy is closed. Three rules enforce that, and all three are load-bearing:
- No deposits.
DEPOSITandWITHDRAWALexist in the transaction enum but are referenced nowhere. Money enters an account exactly once: the100,000.00default balance at signup. - Transfers are own-wallet only.
transfer_fundsverifies that both wallets belong to the caller. Validating only the sender was the one hole in an otherwise closed economy — and, with a leaderboard, an obvious collusion route. - Trading costs money, so there is no free-value loop to grind.
| Cost | Default | Applies to |
|---|---|---|
TRADE_HALF_SPREAD |
0.0005 (5bp per side, ~0.1% round trip) |
Buys execute above the quote, sells below |
FX_SPREAD |
0.005 (0.5%) |
Cross-currency transfers only |
Spreads rather than commissions, because flat commissions are largely gone from US retail while the bid-ask spread is always present — and it is the cost retail traders least understand, which makes it the one worth teaching.
Leaderboards rank by percentage return over 90-day seasons
(SEASON_LENGTH_DAYS). Money persists across seasons; only the ranking resets,
rebaselining to the equity you hold when the season opens. Accounts created
within SEASON_JOIN_GRACE_DAYS (30) of the open are enrolled immediately with
their own starting equity as baseline — without that window, signing up a minute
after a season opened would make you unrankable for a whole quarter.
Full reasoning, including the tie-handling and known gaps:
docs/economy-and-leaderboards.md.
Started inside create_app() and gated behind RUN_BACKGROUND_JOBS, so CLI
commands and any additional web workers don't duplicate them.
| Job | Schedule | What it does |
|---|---|---|
DataSeed.load_available_stocks |
00:00 daily | Refresh symbols, profiles and market caps from FMP. |
UpdateHistory.update_price_history |
00:05 daily | Backfill daily closes from Massive/Polygon, sleeping 20s per symbol for the rate limit. |
LeaderboardService.snapshot_all |
01:00 daily | Snapshot each user's cash, holdings and equity — deliberately after the price refresh, so equity is valued against today's prices. |
LeaderboardService.ensure_season |
01:30 daily | Roll into the next season once the current one lapses. A missed run self-corrects the next day. |
WebSocketListener |
continuous | Streams Finnhub trades and writes them into stock_price. |
Why a single gunicorn worker. The scheduler and the websocket both start in
create_app(), and Finnhub's free tier allows one connection. Extra workers would duplicate the jobs and break the socket. Concurrency comes from threads (--threads 4) instead. If you ever scale horizontally, run the extra instances withRUN_BACKGROUND_JOBS=false.
Production runs on Render (app) against Supabase Postgres, behind Cloudflare, with the frontend on Vercel — both under a shared parent domain so cookie auth works.
bootstrap.sh is the container entrypoint: it applies migrations, then execs
gunicorn bound to $PORT (never set PORT in .env — the platform injects it,
and overriding it reads as "no open ports detected" and fails the deploy).
A complete, step-by-step runbook — database creation, connection-string
gotchas, every environment variable with its production value, custom domains,
the cookie-domain trap, and UptimeRobot keep-alive pings — lives in
docs/deployment.md. Start there rather than
improvising; several steps fail in non-obvious ways.
| Document | Read it when |
|---|---|
docs/deployment.md |
Deploying, or debugging anything environment-shaped. |
docs/economy-and-leaderboards.md |
Touching money, fees, seasons or rankings. |
docs/email-verification.md |
Working on signup, OTPs, or Brevo. |
docs/httponly-cookie-migration.md |
Working on auth, cookies or CSRF. |
docs/money-decimal-and-migrations.md |
Adding a money column, or writing a migration. |
docs/rate-limiting-and-hardening-plan.md |
Changing rate limits or proxy trust. |
.env.example |
Configuring anything at all. |
- Branch off
master. - Keep the layer split: routes stay thin, logic goes in a service, the schema changes through a migration.
- If you change a model, generate a migration and read the generated file — autogenerate misses enum changes and server defaults.
- If you add or change an endpoint, update
app/static/openapi.yamlin the same change. - If you add configuration, document it in
.env.example, including what happens when it's wrong. - Money is
Decimal. Never introduce afloatcolumn or intermediate.
Current gap: there is no automated test suite. Changes are verified manually against a local
docker composestack and Swagger UI. Adding pytest coverage — starting with the fee maths and the ownership checks inwallet_service— is the highest-value contribution available.
MIT — see LICENSE. Copyright (c) 2025 Akinyemi Toluwalase.