Architecture
Version 1 · Extracted and expanded architecture detail out of the overview page into its own document
Architecture
Single-file FastAPI app (trainer_stats_api.py) — no package/module split. Everything below is
one process: routing, SQL, calculation, and persistence.
Data flow
Source DB (MySQL/MariaDB, read-only: race, run, courses)
│ fetch_trainer_rows() / fetch_jockey_rows() [365-day lookback, CTE-scoped to
│ entities running at the target meeting]
▼
build_entity_payload(rows, target_date, entity_key, entity_name_key)
│ pure Python, no DB dependency — same function drives both trainers and
│ jockeys by swapping entity_key/entity_name_key ("trainer_id"/"trainer" vs
│ "jockey_id"/"jockey")
│ calls: calc_wps, calc_meeting_averages, calc_price_band_ivs, top-horses logic
▼
JSON payload (List[Dict])
│
├─→ returned directly to caller (GET /trainer-stats, /jockey-stats, /trainerStats, ...)
│
└─→ POST /save-stats → persist_meeting_payload() → Target DB: meeting_stats_payloads
(CREATE TABLE IF NOT EXISTS, then
INSERT ... ON DUPLICATE KEY UPDATE)
build_entity_payload is the key seam: it only needs a flat list of race+run row dicts with a
known shape, so it can in principle be fed rows assembled from something other than the run
table (e.g. a JSON payload), though nothing in the current code does that yet.
External API bypass
If config.json → external_api.enabled is true, the three "public" endpoints
(/trainerStats, /jockeyStats, /meetingStats) call fetch_meeting_stats_from_external_api()
instead of the local DB/calculation path:
_get_ext_api_config()reads theexternal_apiblock; returnsNone(→ local path) if disabled or missing a URL._get_ext_api_token()logs in to{url}/auth/loginwith the configured username/password, decodes the returned JWT'sexpclaim (without signature verification) to know when to refresh, and caches the token in a module-level dict (_ext_token_cache) — lost on restart.GET {url}/stats/meeting?date=...&courseId=...is called withAuthorization: Bearer <token>. On a 401, the token cache is cleared and the login + request is retried exactly once.- The external response (camelCase field names, e.g.
performance365d,winPct) is converted to this API's snake_case shape by_convert_ext_entity/_convert_wpsbefore being returned — so callers of/trainerStatsetc. see the same JSON shape regardless of which path served the request.
When external_api.enabled is false or absent (the default), these three endpoints fall back
to querying the local DB and running build_entity_payload themselves — identical to what
/trainer-stats and /jockey-stats always do.
Two DB connections
db_source_connection()— read-only in practice (only everSELECTs), againstrace,run,courses.db_target_connection()— read+write, againstmeeting_stats_payloads(and readscoursesto resolve acourse_idto a name for/save-stats//saved-stats).- Both use the same
get_db_config()(host/port/user/password come from one shared block); onlysource_db/target_dbdiffer. They can point at the same physical database. - Neither connection is pooled —
pymysql.connect(...)is opened and closed (via@contextmanager) on every request.
See database for schema and query detail.
Endpoint naming — two parallel implementations
The API exposes trainer/jockey stats twice, under different casing, with different behavior — not just a REST-style alias:
/trainer-stats, /jockey-stats |
/trainerStats, /jockeyStats, /meetingStats |
|
|---|---|---|
| Casing | kebab-case | camelCase |
| Course filter | courseName or courseId |
courseId only |
| External API bypass | never | yes, if external_api.enabled |
USER_CONTEXT side effect |
no | yes (records {date, course_id} for the caller's IP) |
| Intended caller | internal/manual use | external integrators (documented at /api-docs) |
Both implementations independently call fetch_*_rows + build_entity_payload for their local
path, so a change to the calculation logic (e.g. calc_price_band_ivs) must be correct for both
call sites — there's no shared "compute trainer stats for a meeting" function above the
row-fetch level.
Middleware
CORSMiddleware:allow_origins=["*"],allow_credentials=True, all methods/headers allowed.GZipMiddleware: compresses responses ≥ 1000 bytes.
Server-side state
USER_CONTEXT is a plain in-process dict, {ip: {"date", "course_id", "expires_at"}}, written by
the three camelCase endpoints and read by GET /api/user-context (5-minute TTL, checked at read
time, never actively swept). It exists purely so the dashboard can restore "last meeting queried"
across page loads. It is not persisted, not shared across processes, and holds no
auth/authorization meaning. See overview for the auth model itself.