adr: 0014 title: Leaderboard ROI feed and read repoint — equity-based ROI% via evaluator + close path status: superseded implementation-status: in-progress date: 2026-06-03 deciders: [@amalkrsihna] affects-specs: [leaderboard] affects-code: - ftl-backend/internal/leaderboard/service.go - ftl-backend/internal/margin/evaluator.go - ftl-backend/internal/redis/lua/position_close.lua - ftl-frontend/src/pages/Ranks.tsx - ftl-frontend/src/lib/api.ts supersedes: null superseded-by: 0033
ADR-0014: Leaderboard ROI feed and read repoint — equity-based ROI% via evaluator + close path¶
Context¶
PRD F-09 specifies that the global leaderboard ranks players by equity-based ROI%, defined
as (balance + Σ open@curPrice − 10000) / 10000 × 100, and that districts are ranked by the
average member ROI (not total PnL). Neither property is correct in the current codebase.
The current ranking path has three separate problems:
-
Wrong metric. The primary read functions (
GetUserRank,GetUserRankContext,Snapshot,GetDistrictStandings) all read fromleaderboard:overall— a PnL-delta zset that accumulates closed-trade PnL increments. This zset ignores unrealised gains from open positions entirely, so a player holding a large winning position is ranked as if they had zero PnL on it until they close. -
Wrong district aggregation.
GetDistrictStandingsranks districts by the sum of member PnL. This introduces population bias — a district with 100 players each up 1% outranks a district with 2 players each up 40%. PRD requires average ROI per member. -
ROI zsets exist but are not used for ranking. The
leaderboard:roi:overallandleaderboard:roi:district:<code>zsets were seeded by the retired buy-and-holdtrade_execute.luaSELL path. That path is now gone (ADR-0012). The ROI zsets are stale.
Because ROI includes open-position equity, it moves on every price tick. A close-only feed is structurally wrong for any user who holds positions between ticks. The correct feed point is wherever per-user equity is already being computed.
Decision¶
We will feed the leaderboard:roi:* zsets from two write paths and repoint all ranking reads
to those zsets, guarded by a feature flag.
We will feed ROI zsets from the per-tick margin evaluator (10 s throttle) and from
position_close.lua(instant update on close), then repoint all ranking reads to those zsets, with district aggregation changed to average ROI, a tiebreaker chain of ROI% → win-rate → wins → balance → userID, and scores stored as raw percent (e.g. 15.34).
Concrete changes encoded by this decision:
-
position_close.luaZADDsleaderboard:roi:overallandleaderboard:roi:district:<id>after every user-initiated or system-initiated position close, using(equity − 10000) / 10000 × 100. This is the instant-update path; the evaluator write catches up within 10 seconds for users who do not close. -
margin/evaluator.go+leaderboard/service.go— afterevaluate()returnsd.State.Equity, the evaluator calls a thinROIWritercallback (injected to avoid a leaderboard↔margin import cycle) that ZADDs both ROI zsets. The write is gated on feature flagleaderboard.roi_feed_mode == "evaluator". District ID is resolved via a small per-user cache or aHGETagainstuser:{id}:district. -
GetUserRank+GetUserRankContextrepoint their ZREVRANK/ZSCORE reads fromleaderboard:overalltoleaderboard:roi:overall.RankContext.Scoreand gap values become ROI% figures. TheEntrystruct is enriched withroiPercentandwinRatefields to avoid a breaking return-type change. -
Snapshotcalls the ROI zset variants (GetROIGlobal/GetROIDistrict) instead of the PnL-delta zset, so post-crash recovery restores the ROI ranking. No DB migration is needed because the snapshot target is JSONB. -
GetDistrictStandingsreadsleaderboard:roi:district:<code>and ranks districts byΣscore / count(average member ROI), not by sum PnL. TheDistrictStandingstruct gains anAvgROI float64field with JSON tagavgROI; ties broken by higherActiveCount. -
Tiebreaker order in
getROIFromZsetbecomes: ROI% → win-rate (wins/sells) → wins → balance → userID. Absolute PnL is removed from the sort. -
Score convention is raw ROI percent (e.g. 15.34, not 1534). The
/100divisor ingetROIFromZsetis removed in the same commit that changes writers to store raw percent. -
Admin backfill endpoint
POST /api/admin/leaderboard/rebuild-roi(AdminOnly middleware): iterates all wallets, computesequity = GetSummary.totalPnL + 10000, and ZADDs both ROI zsets. Run once post-deploy to seed existing users; expected runtime ~5 s at 50 K users. -
Frontend (
Ranks.tsx,api.ts): gap labelpts→ROI%; sticky-me score display+(score/100)k→score.toFixed(1)%; synthesised backfill rows render—not+0.0%;DistrictStandingtype gainsavgROI;DistrictCard,HeadToHead, andAllDistrictsListuseavgROIinstead oftotalPnl. -
PnL-delta zsets (
leaderboard:overall,leaderboard:pnl:district:*) are retained but stop being the source for ranking reads. They remain available for analytics queries.
Resolved product decisions (founder-approved):
- Score convention: raw ROI percent (e.g. 15.34). Writers and readers change together.
- No-trade users: appear at 0% ROI. The backfill endpoint seeds all users; new accounts are ZADD'd at 0% on registration.
- Negative-ROI users: included, ranked at the bottom of the global board.
- District average when all members are negative: average is negative, which ranks that district low. Follows directly from decision 3.
Consequences¶
Positive¶
- Leaderboard now matches PRD F-09: rank by equity-ROI%, districts by average not sum.
- Open-position equity is reflected within 10 seconds for any active user — no stale ranking for holders.
- Close path gives instant score update on exit, so a user who closes a big win sees their rank move immediately.
- District comparison is fair across different district sizes.
- Win-rate tiebreaker rewards consistent traders over lucky single-trade high-ROI outliers.
- Feature flag allows zero-downtime cutover: deploy → verify backfill → flip flag → online users refresh within one evaluator cycle.
Negative¶
- Two write paths (evaluator + close) must stay consistent. A bug in either creates a stale score for some users.
- The evaluator callback adds ~4 K Redis ops/s at 50 K active users (~5 % of Redis Basic C0 capacity). Acceptable at current scale; revisit if C0 saturates.
- Win-rate requires
winsandsellscounters per user; these must be kept accurate as CFD closes accumulate. Any backfill must also backfill these counters fromcfd_positions.
Neutral / new obligations¶
- The PnL-delta zsets are now orphaned for ranking purposes. They remain in Redis until an explicit cleanup ADR decides whether to drop them or repurpose them for analytics.
- The admin backfill endpoint must be run once after every fresh staging reset or production
deploy. Add it to the deployment runbook (
wiki/14-deployment-guide.md). - Frontend label change (
pts→ROI%) is a visible UX change; coordinate with any active UI design work.
Alternatives considered¶
Alternative A: Close-only feed¶
Update the ROI zsets only when a position closes. No evaluator integration needed.
Rejected. Structurally wrong for ROI. A player with ₹3 000 of unrealised gains on a position they intend to hold until FT would rank at 0% for the entire match. This contradicts the equity-based definition in the PRD and creates a perverse incentive to close positions early just to "post" a score.
Alternative B: Compute-on-read¶
Remove all ROI zsets. On every GetUserRank or GetDistrictStandings call, fetch all open
positions and wallets, compute equity, and rank dynamically.
Rejected. At 50 K concurrent users each polling the leaderboard every 30 seconds, this
generates roughly 50 000 × (N_open_positions + 1) Redis reads per 30 s window. With an
average of 2 open positions per active user, that is ~850 K Redis ops/s from rank reads alone
— 20× the total capacity of Redis Basic C0 (~40 K ops/s). Even a cached variant would require
a per-user invalidation system that is equivalent in complexity to the evaluator feed.
Alternative C: Hybrid — snapshot ROI on close, compute-on-read for open¶
Cache the last-closed-position ROI for each user; supplement with a live compute for users with open positions.
Rejected. Adds significant read-path complexity (two code paths per read; cache invalidation when positions open or close) for a marginal latency saving over the evaluator feed. The evaluator already runs per-user every 10 seconds; piggybacking a ZADD is essentially free compared to the hybrid cache management overhead.
Alternative D: Separate ROI worker process¶
Run a dedicated goroutine that sweeps all users periodically and writes ROI scores.
Rejected. Duplicates the equity computation that margin/evaluator.go already performs
correctly and safely. A second sweep risks computing equity with a slightly different price
snapshot than the margin evaluator uses, creating transient inconsistencies between the
displayed ROI and the displayed equity in the wallet panel. Piggyback on the evaluator to
guarantee they use the same numbers.