spec: leaderboard status: active last-updated: 2026-06-10 owners: [@amalkrsihna] code-refs: - ftl-backend/internal/positions/service.go - ftl-backend/internal/leaderboard/service.go - ftl-backend/internal/handler/admin.go - ftl-frontend/src/pages/YourTeam.tsx - ftl-frontend/src/pages/Ranks.tsx - ftl-frontend/src/lib/api.ts decisions: [0014, 0033, 0034]
Leaderboard¶
TL;DR for an intern¶
The leaderboard ranks every player by the SUM of the ROI of every trade they have closed — the same per-trade "% ROI" numbers shown on the Your Team → Closed tab, added up. Open positions don't move your rank until you close them. Districts — groups of players in the same region — are ranked by the TOTAL of their members' scores (sum, ADR-0034). Scores update the moment a position closes (any close reason: user, stop-loss, take-profit, washout, or full-time auto-exit).
Glossary (defined before first use)¶
- ROI% — Return on Investment as a percentage. For ranking purposes this is the
cumulative per-trade realized ROI: the sum over every closed CFD position of
realized_pnl / margin_at_open × 100. - Margin-at-open — The margin a trade locked when it opened:
open_price × lot_size × CONTRACT_SIZE / LEVERAGE=open_price × lot_size × 0.5(CONTRACT_SIZE = 5, LEVERAGE = 10). - Equity — Total current value of a player's account:
balance + Σ (unrealised PnL on all open positions valued at current_price). Used for margin enforcement, not for ranking (ranking by equity was the superseded ADR-0014 model). - PnL-delta — The realised profit/loss recorded when a position closes. Used for wallet accounting; not the ranking metric.
- zset — Redis sorted set. Stores a score per member; supports O(log N) rank queries.
- ROI zset — The sorted sets
leaderboard:roi:overallandleaderboard:roi:district:<code>that drive all ranking reads. - Evaluator —
margin/evaluator.go. Runs per active user on a 10 s throttle, computing current equity and checking margin levels (washouts / margin calls). It no longer writes ROI scores — washouts route through the close path, which owns the ROI write. - Win-rate —
wins / sells, wherewins= closes with positive realised PnL andsells= total user-initiated closes (excludes system-initiated stop-loss / FT auto-exits for the purposes of skill assessment). - Single-writer rule — Only
positions.Service.recomputeROIBoard(the CFD close path) writes the ROI zsets. The former evaluator feed (ROI_FEED_ENABLED) and the legacy spot-tradeZINCRBYpaths are retired. No feature flag remains. - Backfill — Admin endpoint
POST /api/admin/leaderboard/rebuild-roithat recomputes every walleted user's cumulative per-trade ROI fromcfd_positionsand overwrites the zsets (0 for users with no closed positions). - District — A named group of players (e.g. a city or region). Each user belongs to exactly one district.
Formula / algorithm¶
ROI% (the ranking score)¶
realized_pnl— the position's realized profit/loss, persisted tocfd_positions.realized_pnlwhen the close commits.open_price × lot_size × 0.5— the margin posted at open (CONTRACT_SIZE/LEVERAGE = 5/10 = 0.5). Zero-margin rows are skipped (SQLNULLIFguard, mirroring the FE'smarginAtOpen > 0).- Each term is exactly the per-trade "% ROI" the FE renders on the Your Team → Closed tab
(
YourTeam.tsx), so the rank score equals the sum of the trade ROIs the user can see. - Open positions contribute nothing until closed. (Equity-based ROI — the ADR-0014 model — is superseded; see ADR-0033.)
- Score stored in the zset is the raw percent value, e.g.
15.34for a 15.34% ROI. It is NOT multiplied by 100.
Write path (single writer)¶
positions.Service.Close (Go) — after the durable cfd_positions UPDATE
→ recomputeROIBoard(userId, districtId)
→ SELECT COALESCE(SUM(realized_pnl / NULLIF(open_price*lot_size*0.5,0) * 100), 0)
FROM cfd_positions WHERE user_id = $1 AND closed_at IS NOT NULL
→ ZADD leaderboard:roi:overall sum userId (absolute overwrite)
→ ZADD leaderboard:roi:district:<id> sum userId (when district known)
Fires on every close reason (user, stop_loss, take_profit, washout, auto_exit_ft) —
the SL/TP worker, washout evaluator and FT processor all route through Close. It is a FULL
recompute + absolute ZADD, not an increment: replayed closes, Redis evictions and
outbox-delayed PG rows can never skew the score — the next close rewrites the complete,
correct sum. position_close.lua no longer touches the ROI zsets, the margin evaluator's
ROI_FEED_ENABLED feed is removed, and the legacy spot-trade ZINCRBY writers
(trade_execute.lua SELL, ExecuteSystemSell) are retired.
Read paths¶
All ranking reads now target the ROI zsets:
| Read function | Was | Now |
|---|---|---|
GetUserRank |
leaderboard:overall |
leaderboard:roi:overall |
GetUserRankContext |
leaderboard:overall |
leaderboard:roi:overall |
Snapshot |
PnL-delta zset | leaderboard:roi:overall + leaderboard:roi:district:<code> |
GetDistrictStandings |
leaderboard:overall (sum PnL) |
leaderboard:roi:district:<code> (total ROI) |
Tiebreaker order¶
When two players have identical ROI% (to float64 precision), ranks are broken in this order:
- ROI% — higher is better (primary sort; ascending zset score → descending rank).
- Win-rate —
wins / sells; higher is better. Rewards consistent skill. - Win count — absolute number of winning closes; higher is better.
- Balance — current cash balance; higher is better.
- UserID — ascending lexicographic; deterministic tie-break of last resort.
District ranking¶
District score = TotalROI = Σ member_roi_scores — the SUM of every member's cumulative
per-trade realized ROI (ADR-0034). avgROI (mean per active member) is still computed and
shipped as a secondary display stat (head-to-head "tale of the tape").
- Users who have never traded contribute 0 to the sum (seeded at 0 by the backfill).
- Users with negative ROI are included; a negative member pulls the district total down.
- Tie between two districts on TotalROI → higher
ActiveCount(players with at least one ROI entry) wins. - Population weight is intentional: more active members = more potential points. This reverses ADR-0014's average-based de-biasing — see ADR-0034 for the trade-off.
- District total can be negative if members have lost money overall; such a district ranks at the bottom.
Edge cases¶
-
No-trade users — Appear at ROI% = 0.0. The backfill endpoint seeds all registered users at 0%. New accounts are ZADD'd at 0% on registration. They rank below any player with a positive score, above any player with a negative score. (ADR-0014)
-
Negative-ROI users — Included in the global board and in their district's average. A user who has lost money from their starting ₹10 000 will have a negative ROI% and rank at the bottom. (ADR-0014)
-
Score convention: raw percent, not ×100 — The score stored in Redis is 15.34, not 1534. Any code reading
getROIFromZsetmust NOT divide by 100. The single writer (recomputeROIBoard) and the backfill both store raw percent. (ADR-0014, ADR-0033) -
Stale scores after deploy — Scores written under the superseded equity-ROI convention remain in the zsets until overwritten. Run the admin backfill once right after deploy; any user also self-corrects on their next close (full recompute). Open-position gains no longer move the rank — only closes do.
-
PnL-delta zsets retained —
leaderboard:overallandleaderboard:pnl:district:*still exist and are still written by the close path. They are not read for ranking and can be used for analytics. A future ADR will decide whether to drop them. -
Snapshot recovery — After a Redis eviction or restart,
Snapshotrestores the ROI zsets from the JSONB snapshot in Postgres; each user then self-corrects on their next close (full recompute fromcfd_positions). The admin backfill restores everyone at once. -
Backfill idempotency — The backfill recomputes from
cfd_positions(the durable source of truth) and overwrites with plainZADD, so repeated runs always converge on the same correct sums — there is no snapshot-vs-live ordering hazard.
Code references¶
ftl-backend/internal/positions/service.go—recomputeROIBoard(the single ROI writer)- its call site at the end of
Close. ftl-backend/internal/leaderboard/service.go—GetUserRank,GetUserRankContext,Snapshot,GetDistrictStandings,getROIFromZset(tiebreaker sort).ftl-backend/internal/handler/admin.go—AdminRebuildROIone-shot backfill (same SUM aggregate, paged over wallets).ftl-frontend/src/pages/Ranks.tsx— renders global + district leaderboard; usesavgROIfor district cards; displays score asscore.toFixed(1)%.ftl-frontend/src/lib/api.ts—DistrictStandingtype; leaderboard API client functions.
Related decisions¶
- ADR-0033 — Rank = cumulative per-trade realized ROI; single close-path writer (PG recompute + absolute ZADD); retires the evaluator feed and legacy ZINCRBY writers. Supersedes 0014's scoring definition.
- ADR-0034 — District
standings rank by TOTAL member ROI (sum) instead of the average;
totalROIbecomes the sort key + headline,avgROIstays as a secondary stat. - ADR-0014 (superseded) — Equity-based ROI feed via evaluator + close path; repoint all ranking reads; district avg ROI; win-rate tiebreaker; raw-percent score convention. The read repoint and tiebreakers remain in force; the scoring formula and write paths are replaced by 0033, and the district-average aggregation by 0034.
How to verify¶
# 1. Confirm ROI zsets are being written after a position close
redis-cli ZSCORE leaderboard:roi:overall <userId>
# 2. Confirm district ROI zset exists and has members
redis-cli ZREVRANGE leaderboard:roi:district:<districtCode> 0 4 WITHSCORES
# 3. Close a position, then confirm the score equals the sum of that user's closed-trade ROIs
# SELECT SUM(realized_pnl / NULLIF(open_price*lot_size*0.5,0) * 100)
# FROM cfd_positions WHERE user_id = '<userId>' AND closed_at IS NOT NULL;
redis-cli ZSCORE leaderboard:roi:overall <userId>
# 4. Confirm no-trade users appear at 0%
redis-cli ZSCORE leaderboard:roi:overall <userId-with-no-trades>
# expected: "0"
# 5. Run unit tests for the tiebreaker sort
go test ./internal/leaderboard/...
# 6. Run the admin backfill and verify all users seeded
curl -X POST -H "Authorization: Bearer <admin-jwt>" \
https://<host>/api/admin/leaderboard/rebuild-roi
In the UI, navigate to the Ranks page (/ranks). Gap labels should read ROI% (not pts).
The sticky-me row should show a score like +15.3%. District cards should display Avg ROI
with a percentage value. Negative-ROI users should appear at the bottom of the global board.