Skip to content

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:overall and leaderboard:roi:district:<code> that drive all ranking reads.
  • Evaluatormargin/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-ratewins / sells, where wins = closes with positive realised PnL and sells = 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-trade ZINCRBY paths are retired. No feature flag remains.
  • Backfill — Admin endpoint POST /api/admin/leaderboard/rebuild-roi that recomputes every walleted user's cumulative per-trade ROI from cfd_positions and 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)

ROI% = Σ over closed positions of:
         realized_pnl / (open_price × lot_size × 0.5) × 100
  • realized_pnl — the position's realized profit/loss, persisted to cfd_positions.realized_pnl when 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 (SQL NULLIF guard, mirroring the FE's marginAtOpen > 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.34 for 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:

  1. ROI% — higher is better (primary sort; ascending zset score → descending rank).
  2. Win-ratewins / sells; higher is better. Rewards consistent skill.
  3. Win count — absolute number of winning closes; higher is better.
  4. Balance — current cash balance; higher is better.
  5. 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 getROIFromZset must 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 retainedleaderboard:overall and leaderboard: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, Snapshot restores the ROI zsets from the JSONB snapshot in Postgres; each user then self-corrects on their next close (full recompute from cfd_positions). The admin backfill restores everyone at once.

  • Backfill idempotency — The backfill recomputes from cfd_positions (the durable source of truth) and overwrites with plain ZADD, 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.gorecomputeROIBoard (the single ROI writer)
  • its call site at the end of Close.
  • ftl-backend/internal/leaderboard/service.goGetUserRank, GetUserRankContext, Snapshot, GetDistrictStandings, getROIFromZset (tiebreaker sort).
  • ftl-backend/internal/handler/admin.goAdminRebuildROI one-shot backfill (same SUM aggregate, paged over wallets).
  • ftl-frontend/src/pages/Ranks.tsx — renders global + district leaderboard; uses avgROI for district cards; displays score as score.toFixed(1)%.
  • ftl-frontend/src/lib/api.tsDistrictStanding type; leaderboard API client functions.
  • 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; totalROI becomes the sort key + headline, avgROI stays 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.