Background Jobs¶
All goroutines below start inside cmd/api-server/main.go unless noted.
Sportmonks scheduler¶
Started by: go scheduler.Run(ctx) — only when SPORTMONKS_API_KEY is set.
Lives in: internal/sportmonks/
The scheduler polls Sportmonks every 2 seconds during live matches. Between matches it polls less frequently (using Sportmonks' own fixture-state transitions).
What it does per tick:
- Fetches live fixture state and player events for every configured league.
- Updates instrument live_base_price in Redis based on player performance (scoring bumps via sportmonks.SetEventBumps).
- Writes match events to Redis events:{sportmonksId} list.
- Publishes price:{instrumentId} for each changed price.
- Stamps live_match_until = now+30s (epoch s) on each live-match player's instrument hash (field live_match_until). The noise generator reads this to determine whether to tick the player every cycle regardless of trade activity (ADR-0021).
- On FT (full time): calls ProcessCompletedFixture which:
1. Fires marginReplayerAdapter.ReplayUsersAtFT (ADR-0006 pre-FT washouts).
2. Calls autoSellerAdapter.ExecuteSystemSell for every legacy open position.
3. Calls cfdCloserAdapter.CloseAtFTSnapshot for every open CFD position.
4. Sets HSET instrument:{id} frozen 1 during liquidation, clears after.
What breaks if not running: No live price updates during matches. FT auto-close of positions does not fire (users keep positions open past match end).
Squad syncer: sportmonks.NewSquadSyncer runs inside the scheduler. It syncs player rosters from Sportmonks into Postgres squad_players, triggering instrumentSvc.HydrateRedis to keep instrument state fresh; HydrateRedis now calls InvalidateCache after each sync so the cached instrument list (cache:instruments) does not serve stale names or prices.
Gotcha: Sportmonks 50-int filter cap¶
Sportmonks v3 rejects filters=*Types: lists with more than 50 integers (HTTP 400, error code 5010, "The types filter should be a comma seperated list of maximum 50 integers"). We hit this once on livescores/latest when the implicit lineupDetailTypes: filter accumulated 55 IDs — every poll silently failed, no price_ticks written, Stadium event feed dark. See ADR-0013. Resolution: the request is now sent without the filter; consumers filter client-side.
If you ever re-add a filters=...Types: value, keep it ≤ 50 integers, or better — generate it from the consumer code (scoring.go keys ∪ statDeltaTypes keys) and assert the cap at build time.
Noise generator¶
The NoiseGenerator (lives in internal/sportmonks/noise.go) runs alongside the scheduler goroutine. It publishes synthetic price:{instrumentId} ticks every ~2 s (configurable via NOISE_INTERVAL_MS, default 2000 ms) to keep the chart moving between real Sportmonks events. Key behaviours after ADR-0021:
- Instruments in a live match (
live_match_until > now) publish every cycle regardless of trade activity. - The noise offset carries across pattern windows —
loadOrSelectPatternseeds new patterns with the persisted offset rather than resetting to 0. Global mean-reversion applies each tick:offset -= offset / tauTickswheretauTicks = NOISE_MEANREVERT_MINUTES × (60000 / intervalMs). DefaultNOISE_MEANREVERT_MINUTES= 10; set to 0 to disable reversion. NOISE_DELTA_SKIP_PCT(default 0) means every tick whose rounded price differs from the last published price is broadcast — the prior default was 1%.noiseOffsetClampis ±0.08 (fraction of AMM base price).
Environment variables for the noise generator and trade slippage:
| Variable | Default | Notes |
|---|---|---|
NOISE_INTERVAL_MS |
2000 |
Tick interval for the noise generator in milliseconds. |
NOISE_MEANREVERT_MINUTES |
10 |
Mean-reversion time constant. 0 disables reversion. (ADR-0021, new) |
NOISE_DELTA_SKIP_PCT |
0 |
Minimum fractional price-change required to publish a tick. Default 0 = publish every changed tick. Was 0.01 (1%). |
TRADE_SLIPPAGE_TOLERANCE_PCT |
0.025 |
CFD slippage tolerance (2.5%). Was 0.005 (0.5%). (ADR-0020) |
Cost note:
NOISE_DELTA_SKIP_PCT=0combined withlive_match_untilgating means every live-match player publishes a price tick approximately every 2 s during an active match. During a live fixture with 22+ players this substantially increases Redis pub/sub operations compared to the previous activity-gated behaviour. Assess fan-out cost before disabling the skip threshold in production at scale.
Weekly spinner¶
Started by: go runWeeklySpinner(ctx, spinnerSvc).
Lives in: internal/spinner/
Runs once at boot (to catch any missed Monday trigger after a restart) then every Monday at 00:05 UTC.
Calls spinnerSvc.SpinFor(ctx, time.Now()). Uses ON CONFLICT DO NOTHING in Postgres so multiple api-server replicas spinning simultaneously produce exactly one winner row.
What breaks if not running: No weekly tiebreaker winner selected. Leaderboard still functions; the spinner result is a display feature.
Margin reconnect listener¶
Started by: go runMarginReconnectListener(ctx, redisClient.RDB(), marginSvc).
Lives in: cmd/api-server/main.go
Subscribes to the Redis user:reconnect channel. ws-server publishes a message each time a user transitions from 0 to 1 active connections.
For each received userId, spawns a goroutine calling marginSvc.ReplayLastSeen(ctx, uid). The replay reads the price_ticks buffer since last_seen:{userId} and closes any positions that breached their margin or SL/TP at the breach price — not the current price.
What breaks if not running: Offline users' breached positions accumulate until the 5-min backstop sweeper catches them. No data loss, but breach-price settlement is delayed.
Margin backstop sweeper¶
Started by: go runMarginBackstopSweeper(ctx, pool, redisClient.RDB(), marginSvc).
Interval: Every 5 minutes.
Redis lease: lease:margin-backstop (TTL 30s, acquire via SETNX).
Queries SELECT DISTINCT user_id FROM cfd_positions WHERE closed_at IS NULL. For each user, calls marginSvc.ReplayLastSeen. Logs total users swept and washouts fired.
Only one api-server replica runs the sweep at a time; others skip the tick if they cannot acquire the lease.
What breaks if not running: Stranded offline users whose WebSocket never reconnects accumulate undetected washouts. The reconnect listener handles online users; this sweeper handles the permanently-offline case.
StopWatch (SL/TP enforcement)¶
Started by: go positionsStopWatch.Run(ctx).
Lives in: internal/positions/stopwatch.go
Redis lease: lease:stop-watcher.
Subscribes to price:* pub/sub. On each tick:
1. Reads all open positions from Postgres (cached in memory, refreshed every 5s).
2. For any position where the new price breaches stop_loss or take_profit, calls position_close.lua.
3. Also calls marginTickAdapter.EvaluateAndApplyForTick(ctx, userID) for the margin wash-out check.
Single-instance across replicas via the Redis lease.
What breaks if not running: SL/TP orders are not enforced in real time. The 5-min backstop sweeper provides a fallback, but with up to 5 minutes of drift.
Bot engine¶
Started by: go botEngine.Start(ctx) — only when BOT_ENABLED=1.
Lives in: internal/bot/
Simulates synthetic traders (bot personas) buying and selling instruments. Each persona has a wallet funded from BOT_WALLET. Bots provide liquidity when no real users are active.
The scheduler starts the bot engine when a match goes live. When SPORTMONKS_API_KEY is not set (dev mode without live matches), the engine starts immediately at boot.
What breaks if not running: No synthetic liquidity. Price does not move without real user trades.
Embedded flusher¶
Started by: go flushSvc.Run(ctx) — only when FLUSHER_EMBEDDED=1.
Lives in: internal/flusher/
Runs the flusher loop and both outbox drainers inside the api-server process. Used in single-binary local dev. In production, the standalone cmd/flusher binary is deployed instead.
See Services — flusher for the drain details.
Google JWKS refresh¶
Started by: go googleVerifier.StartRefresh(ctx).
Interval: Every 6 hours.
Refetches https://www.googleapis.com/oauth2/v3/certs into the in-memory keys map (RW mutex protected). A failure is logged but does not crash the process; the previous keys continue to work until a Google key rotation causes a verification failure, at which point the next scheduled refresh corrects it.
What breaks if not running: After a Google key rotation (rare; Google rotates keys roughly weekly), new Google logins fail until the next refresh.
Known issues (Sportmonks upstream)¶
The following are upstream data gaps, not backend bugs:
- Goal and card event feed empty for some fixtures. The Sportmonks feed omits goal/card events for certain fixtures; the
events:{sportmonksId}Redis list is populated but contains no goal or card entries for those games. - Per-player long-ball / aerial-won / tackle stats absent for U18 competitions. Only fixture-level team-aggregate stats are available; per-player stat deltas for these categories do not arrive from the Sportmonks API for U18 leagues.
- Kazakhstan U18 squad returns 0 players. Instruments are created from the fixture list but the squad endpoint returns an empty roster for this team on the current data plan.