Skip to content

adr: 0033 title: "Leaderboard ROI = cumulative per-trade realized ROI; single close-path writer" status: accepted implementation-status: implemented date: 2026-06-10 implemented-date: 2026-06-10 implemented-in: - ftl-backend bug/roi-rank-sum-mismatch - ftl-frontend bug/roi-rank-sum-mismatch deciders: [@amalkrsihna] affects-specs: [leaderboard] affects-code: - ftl-backend/internal/positions/service.go - ftl-backend/internal/redis/lua/position_close.lua - ftl-backend/internal/margin/service.go - ftl-backend/internal/margin/evaluator.go - ftl-backend/internal/redis/lua/trade_execute.lua - ftl-backend/internal/trade/service.go - ftl-backend/internal/handler/admin.go - ftl-backend/internal/leaderboard/service.go - ftl-frontend/src/pages/YourTeam.tsx - ftl-frontend/src/lib/api.ts supersedes: 0014 superseded-by: null


ADR-0033: Leaderboard ROI = cumulative per-trade realized ROI; single close-path writer

Context

The Your Team → Closed tab shows a per-trade "% ROI" for every closed CFD position — realized_pnl / margin_at_open × 100, where margin_at_open = open_price × lot_size × CONTRACT_SIZE/LEVERAGE = open_price × lot_size × 0.5. Users naturally sum those numbers and compare against their Ranks score. They never matched: a live account with 17 closed trades summed to +478.5% on the Closed tab while the rank showed +9.33% — a ~51× gap reported as a bug.

The mismatch was definitional, not arithmetic. Under ADR-0014 the rank score was whole-account equity ROI(equity − 10000) / 10000 × 100 — while the Closed tab shows leveraged return-on-margin per trade. A sum of per-trade percentages on small margin bases cannot equal a percentage of the fixed ₹10 000 base.

Worse, the ROI zset (leaderboard:roi:overall + district variants) had three writers with incompatible semantics and scales: the CFD close path (position_close.lua, absolute ZADD, equity-ROI in raw percent), the retired spot-SELL path (trade_execute.lua, ZINCRBY, per-exit ROI ×100), and the legacy share auto-sell (ExecuteSystemSell, ZINCRBY, ×100). The read side assumes raw percent, so the dormant ×100 writers were a latent corruption bug waiting for any legacy share position to liquidate.

The product owner decided on 2026-06-10 that the rank must equal the running sum of the per-trade ROIs the user can see — overriding PRD F-09's equity-ROI definition.

Decision

We will rank players by the cumulative sum of each closed CFD position's return-on-margin percent, written by a single code path — a full Postgres recompute + absolute ZADD after every close — because the rank must reconcile exactly with the per-trade ROIs the UI shows.

Concrete changes:

  • New positions.Service.recomputeROIBoard(ctx, userID, districtID): one indexed aggregate — 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 — then absolute ZADD to leaderboard:roi:overall and leaderboard:roi:district:<id>. Called at the end of Close() after the durable cfd_positions UPDATE; covers all close reasons (user, SL, TP, washout, FT auto-exit) because every system closer routes through Close.
  • position_close.lua no longer writes the ROI zsets (equity-ROI block and KEYS[10]/[11] removed).
  • The margin evaluator's ROI_FEED_ENABLED equity feed (writeROI) is removed — realized-sum ROI changes only on close, so a per-tick feed has no role.
  • The legacy ×100 ZINCRBY writers are retired: trade_execute.lua SELL (slots KEYS[11/12] kept positionally, unread) and ExecuteSystemSell (district lookup dropped). Their trade_stats bookkeeping stays.
  • AdminRebuildROI backfill recomputes the same SUM for every walleted user (LEFT JOIN, so no-trade users overwrite to 0) — run once post-deploy to flush stale equity-ROI scores.
  • Score units unchanged: raw percent in the zset; read paths, district averaging, and tiebreakers from ADR-0014 remain in force.

Consequences

  • Positive: Rank reconciles exactly with the Closed tab by construction (same formula, same rows); single writer kills the three-writer scale/semantics conflict; full recompute + absolute ZADD is idempotent across Lua replays, Redis evictions, and outbox-delayed rows — self-healing on every close.
  • Negative: The metric is a sum of leveraged per-trade returns — unbounded and gameable by many small high-leverage winning scalps that barely move account equity. Accepted knowingly; a per-trade cap or notional-weighting is a candidate follow-up. Open-position gains no longer move the rank until realized.
  • Neutral / new obligations: One extra indexed PG aggregate + 1–2 ZADDs per close (closes are not a hot path). Deploy runbook must run the backfill once; until then stale equity scores linger (each user also self-corrects on their next close). PRD F-09's leaderboard definition is superseded by this ADR.

Rollback: revert the code PR and re-run the old backfill — the zsets are fully derivable from durable state in either definition, so the switch is two-way.

Alternatives considered

Alternative A: Keep equity-ROI, relabel the UI

Keep the rank as whole-account equity ROI and rename the Closed-tab number to "Trade Return" with an explainer that they don't sum. Rejected: the product owner explicitly wants the rank to be the sum of the visible per-trade ROIs; an un-gameable metric that contradicts what users can verify on their own screen erodes trust in the board.

Alternative B: Redefine per-trade ROI as account-share (realized_pnl / 10000)

Change the Closed tab to show each trade's PnL as a share of the ₹10 000 start; those sum exactly to realized account ROI and stay un-gameable. Rejected by the product owner: per-trade numbers shrink (~+0.5% instead of +50%), deflating the leveraged feel of the product.

Alternative C: ZINCRBY the per-trade ROI on each close

Accumulate increments instead of recomputing. Rejected: position_close.lua replays on duplicate requests, increments double-count, Redis evictions lose the accumulator, and float drift compounds — the PG recompute is replay-safe and self-healing for sub-ms cost.