Services¶
The backend ships as three binaries. Each is independently deployable. In production, Container Apps runs api-server × 2, ws-server × 3, flusher × 1.
api-server¶
File: cmd/api-server/main.go
Port: API_PORT (default 8080)
What it does: HTTP REST API, Google OAuth, trade execution, CFD positions, admin.
What breaks if it is down: All client requests fail. No trades execute. No auth.
Goroutines started at boot:
| Goroutine | Trigger / interval | Reads / writes |
|---|---|---|
googleVerifier.StartRefresh |
Every 6h | Fetches Google JWKS from https://www.googleapis.com/oauth2/v3/certs into memory |
scheduler.Run (Sportmonks) |
Loop; polls Sportmonks every 2s during live matches | Redis instrument hashes (including instrument:<id>.live_match_until epoch-s field stamped each poll to gate near-real-time noise for live-match players), price pub/sub, Postgres fixtures/match_scores |
runWeeklySpinner |
Boot + every Monday 00:05 UTC | Postgres spinner_results, leaderboard sorted sets |
runMarginReconnectListener |
Subscribes to user:reconnect channel |
Calls margin.ReplayLastSeen, writes position closes to Postgres |
runMarginBackstopSweeper |
Every 5 min; Redis lease lease:margin-backstop |
Queries cfd_positions WHERE closed_at IS NULL, replays each user |
positionsStopWatch.Run |
Subscribes to price:* pub/sub |
Calls position_close.lua on SL/TP breach; Redis lease lease:stop-watcher |
botEngine.Start (if BOT_ENABLED=1) |
Scheduler triggers on match start; dev mode starts immediately | Trades via trade_execute.lua |
flusher.Run (if FLUSHER_EMBEDDED=1) |
Every FLUSH_INTERVAL (default 100ms) |
Redis dirty sets → Postgres; see flusher section |
Decision: All goroutines run inside the api-server process. The only exception is flusher which ships as its own binary in production to keep the drain loop isolated from request-path failures.
ws-server¶
File: cmd/ws-server/main.go
Port: WS_PORT (default 8081)
What it does: Upgrades HTTP connections to WebSocket. Maintains a connection hub keyed by user ID and instrument index. Subscribes to Redis price:* and portfolio:* channels and fans out to connected clients.
What breaks if it is down: Clients receive no real-time price updates or portfolio pushes. Trades still execute via api-server; clients just do not see the result until they poll REST or reconnect.
Goroutines started at boot:
| Goroutine | Purpose |
|---|
| subscriber.Start | Subscribes to Redis pub/sub; routes price:{idx} to hub subscribers and portfolio:{userId} to the user's connections |
Reconnect/disconnect hooks:
- On 0→1 connection transition:
PUBLISH user:reconnect <userId>— api-server's margin listener replays offline price ticks. - On 1→0 transition:
SET last_seen:<userId> <ms> EX 24h— records the offline window start for the replayer.
Hub capacity: 20,000 concurrent connections per replica (set in ws.NewHub(20000)). Returns HTTP 503 when full.
Token validation: Clients supply a short-lived ws-token (typ=ws, 60s) as ?token= query parameter. The ws-server validates it using JWTPublicKey only — it never signs tokens.
flusher¶
File: cmd/flusher/main.go
Port: none (no HTTP listener)
What it does: Drains Redis hot state to Postgres durably.
What breaks if it is down: Redis accumulates dirty state. Postgres drifts from Redis. Trades are still accepted (outbox writes to Redis succeed). On restart the flusher replays the outbox, so no data is lost as long as Redis persists.
Goroutines started at boot:
| Goroutine | Interval | What it drains |
|---|---|---|
flusher.Run |
FLUSH_INTERVAL (default 100ms) |
dirty:instruments, dirty:wallets, dirty:positions sets → HGETALL each hash → UPDATE Postgres |
outboxDrainer.Run |
5s | trade_outbox table → retries failed INSERT INTO trades |
positionOutboxDrainer.Run |
5s | position_outbox table → retries failed INSERT/UPDATE cfd_positions |
Decision: Outbox drainers run at 5s instead of 100ms because outbox enqueues are rare (only when a synchronous Postgres write fails) and each retry hits Postgres. Spamming at 100ms on an empty table wastes connections.
Environment variables (api-server — noise and trade)¶
Three env vars were added or changed in ADR-0020/ADR-0021 and are relevant to api-server runtime behaviour:
| Variable | Default | Notes |
|---|---|---|
TRADE_SLIPPAGE_TOLERANCE_PCT |
0.025 (2.5%) |
Slippage tolerance for CFD position open/close. Was 0.5% prior to ADR-0020. Compared against last_published_price in the Lua scripts. |
NOISE_DELTA_SKIP_PCT |
0 |
Minimum price-change fraction required to publish a noise tick. Default 0 = publish every changed tick (near-real-time feel). Was 1% prior to ADR-0021. Raise to throttle Redis pub/sub fan-out. |
NOISE_MEANREVERT_MINUTES |
10 |
NEW (ADR-0021). Timescale (minutes) over which the accumulated per-instrument noise offset relaxes back toward 0. Set to 0 to disable mean-reversion entirely. |
Event + stat ripple (ADR-0031)¶
Each Sportmonks poll, processEvents (discrete events) and detectStatDeltas
(per-player stat increments) apply the actor's primary price bump AND fan a
role-weighted ripple across every player on both teams via applyRipple
(internal/sportmonks/event_propagation.go). Teammates move the same direction
as the actor, opponents the opposite; the role most responsible (opposing GK on
a goal) is hit hardest. When Sportmonks omits the actor's player_id,
redistributePrimary spreads the primary across the acting team by role so the
event still moves prices. A per-poll rosterCache queries each team's active
squad at most once per cycle. processPlayerStats is deliberately NOT rippled
(it would double-count the same stats and fan out per-player-per-poll).
Because bumpInstrumentEvent only writes the Redis hash, players moved only
by the ripple (the passive / opposing side) would otherwise have a moving price
but an empty chart and no live push. flushRippleTicks (end of PollLivescores)
publishes price:{id} + writes a price_ticks row for every ripple-moved
instrument that processPlayerStats didn't already tick.
The ripple matrices are baked in (propagation_matrix.go) and admin-tunable via
score_event_points.event_ripple_matrices. See the
Pricing Ripple Engine spec for the full
tables, magnitudes, and the marker-eligible stat list.
Instrument universe — squads + matchday lineups (ADR-0032)¶
The tradeable instrument set is built from Sportmonks squads/teams/{teamID},
which is incomplete for national teams (e.g. Trinidad & Tobago returns 14 of
a 21-man matchday squad). Two mechanisms close the gap so every player who can
generate events/stats is tradeable:
- Squad sync (
squadsync.gocollectSquads) unions each fixture'slineups.playerwithsquads/teams, so the full matchday squad is tradeable once lineups publish (~1h pre-kickoff). - Ticker auto-register (
ticker.goensureLineupInstruments, every poll) creates an instrument for any lineup player with nosportmonks_player:{id}mapping, then re-hydrates Redis once — self-healing late call-ups in real time.
Both rely on instruments.idx now being a persistent column (migration
000041), assigned MAX(idx)+1 on genuine insert and read by HydrateRedis /
GetAll instead of recomputed positionally — so adding a player never reshuffles
existing idx (which would make the ws-server map prices to the wrong player). See
ADR-0032.
Known issues (Sportmonks upstream)¶
Goal and card events are absent from the live feed for some fixtures. Per-player long-ball, aerial, and tackle stats are unavailable for U18 competitions — only fixture-level team totals are available. The Kazakhstan U18 squad returns 0 players due to a gap in Sportmonks coverage. These are upstream data limitations and not backend bugs.