How leader and follower statistics are computed

Every figure on the leaderboard and the subscription pages is computed on demand from the trade table — the exact formulas, the four Redis caches and their TTLs, what currency a total is really in, and what Recalculate does.

9 min readUpdated 6 August 2026statistics, cache, redis, currency, leaderboard, roi

There is no stored statistics counter anywhere in this addon. copy_trading_leaders has no totalTrades, no winRate, no totalProfit column; neither does copy_trading_followers. Everything a screen shows is recomputed from copy_trading_trades — the single source of truth — and then held in Redis for a few minutes.

That matters for two questions operators get constantly. "Why does Recalculate change nothing?" — because there is no stored number for it to fix. And "is this leader's headline figure real?" — because it is arithmetic over real rows, but the arithmetic has a denominator and a denomination, and neither is what most people assume.

Which function answers which screen

Function Cached Key TTL Feeds
calculateLeaderStats / getLeaderStats Yes copy:leader:stats:v2:{leaderId} 300 s GET /api/copy-trading/leader/{id}, GET /api/copy-trading/leader/me
calculateBatchLeaderStats No The leaderboard, the landing page, platform stats, every WebSocket leader channel, and /admin/copy-trading/leader
calculateFollowerStats / getFollowerStats Yes copy:follower:stats:v3:{followerId} 300 s GET /api/copy-trading/follower, GET /api/admin/copy-trading/follower
getAllocationStats Yes copy:allocation:stats:{followerId}:{symbol} 180 s Nothing yet — the cache is invalidated on a close, but no shipped route reads it
getLeaderDailyStats Yes copy:leader:daily:{leaderId}:{YYYY-MM-DD} 3600 s Nothing yet — the daily-stats cron writes its own rows without going through this

"Stale" in copy trading is never an out-of-date counter. It is at most a five minute Redis entry on a leader profile or a subscription row, three minutes on a per-market allocation, and an hour on a per-day figure.

The public leaderboard is not cached at all — calculateBatchLeaderStats runs the query every request. So a leader's card and their own profile page can legitimately disagree for up to five minutes after a trade closes.

Closing a trade calls invalidateTradeRelatedCaches, which deletes the leader key, the follower key and the allocation key together. The :v2: and :v3: version markers in the key names are load-bearing — they exist so that a deploy cannot serve an older entry whose shape lacked a currency field.

What each figure actually is

All leader figures are computed over rows where isLeaderTrade = true and status = 'CLOSED'. Follower figures are computed over rows for that followerId with status = 'CLOSED'.

Figure Formula
totalTrades Row count
winRate Rows with profit > 0 ÷ row count × 100
totalProfit Sum of profit, bucketed by denomination first — see below
totalVolume (leader) Sum of cost, bucketed the same way
roi (leader) totalProfit ÷ totalVolume × 100
roi (follower) Realised profit in USD ÷ the USD value of the follower's active allocations × 100
totalFollowers Subscriptions whose status is not STOPPED

Two of these are routinely misread.

The denominator is the sum of cost over every closed trade, so a leader who turns the same money over fifty times has fifty trades' worth of notional in the denominator. It is not "this leader made 340% on their account".

Worse, cost is not one unit on a leader row. The trade listener writes cost = amount × price for a BUY and cost = amount — a plain base quantity — for a SELL. A leader who both buys and sells therefore has quote notionals and base quantities added together in the denominator, while the numerator is quote-denominated profit. On a book that is mostly sells, the ratio is not a percentage of anything.

Treat a leader's ROI as a ranking signal, not a return figure, and never repeat it to a customer as a rate of return.

On the leader eligibility gate, win rate comes from settled live binaryOrder rows (WIN, LOSS, DRAW, isDemo = false). A spot order has no per-order P/L, so spot history feeds the trade count and never the win rate.

The consequence is explicit in the code: when there is nothing judgeable, the requirement is treated as met. An applicant with no settled binary history passes copyTradingMinLeaderWinRate automatically, whatever you set it to. If you are running a spot-only venue, that setting is not a gate — the approval queue is.

The winRate on the leaderboard is a different number: wins over closed copy-trading trade rows, which do have a signed profit.

Denomination: profitCurrency is not decoration

A trade settles in the quote asset of its own market, and the row records that in profitCurrency. A leader running BTC/USDT and NEO/ETH has two kinds of money in one column, so the calculator buckets per denomination before it adds anything.

  • One denomination — the ordinary case. The total is that currency's bucket, profitCurrency is that ticker, and no rate is fetched at all. This is deliberate: making the common case depend on a populated rate table would replace a correct figure with a zero on an ecosystem-only install.
  • More than one — rates are fetched, everything is converted, profitCurrency becomes the literal string USD, and anything with no usable rate is listed in unpricedCurrencies (leaders) or unpricedProfitCurrencies (followers) and left out of the total, making the figure a stated lower bound. A missing rate is never treated as zero.

Totals are rounded to eight decimal places, not two, because rounding a BTC-quoted result to cents destroys it outright.

For a follower, roiAvailable is a separate flag and false means "not known", not "zero". When a denomination cannot be priced, its profit leaves the numerator while its allocation stays in the denominator, so the ratio would read low with nothing on screen saying why. In that case roi is 0 and roiAvailable is false — render a dash.

The landing endpoint builds each featured leader without carrying profitCurrency through, and the featured-leader card prints a hard-coded $ in front of totalProfit. The landing page's headline "total profit generated" is worse: it adds every leader's totalProfit together with no conversion at all, so a USDT leader and a BTC leader are summed as if they were the same money.

This is your shop window, it is public, and it is not a dollar figure. Before you quote a leaderboard number to anyone — a customer, a marketing page, an investor — open the leader's own profile at /api/copy-trading/leader/{id}, which does carry profitCurrency and unpricedCurrencies.

Where conversion happens, and what happens when it fails

Two places convert, and they behave differently.

The statistics calculator (leaderboard, profiles, subscriptions) uses the platform rate table. A currency with no rate drops out of both the profit and the volume halves together, keeping ROI a ratio over one population, and is reported as unpriced.

The cron jobs (updateCopyTradingLeaderDailyStats, aggregateCopyTradingWeeklyAnalytics, checkCopyTradingDailyLossLimits) use convertToUSDT, which prices through the ecosystem matching engine's ticker with a 60-second Redis cache. When that throws, the handler logs Currency conversion failed for {currency} at warn level and adds the raw, unconverted value instead.

The fallback is silent to anyone reading the figure. A leader trading BTC/USDT and NEO/ETH whose ETH ticker is unavailable gets a copy_trading_leader_stats row whose profit column is "some USDT plus some raw ETH", stored and labelled as USDT.

The only signal is the warn line in the backend log. If a daily figure looks impossible, grep the log for that message before you go looking for a bug in the trade rows.

The two statistics cron jobs

Both are in Admin → System → Cron under the copy_trading category.

Job Runs Does
updateCopyTradingLeaderDailyStats every 5 minutes Writes one copy_trading_leader_stats row per ACTIVE leader per UTC day — trades, winningTrades, losingTrades, profit, volume, fees, all converted to USDT
aggregateCopyTradingWeeklyAnalytics every 7 days Reads the last 7 days of closed leader trades and logs the aggregate. It writes no row and clears no cache

The daily job upserts on (leaderId, date) with the date key taken from UTC midnight, the same clock the window start uses. It only walks leaders whose status is ACTIVE, and it counts trades created that day.

Two consequences worth planning around:

  • A missing day stays missing. The job only ever writes today's row. If the scheduler was down for a day, nothing backfills it — the 14-day sparklines on the landing page simply have a gap, and the row will not appear later.
  • A leader who is suspended mid-day stops accruing rows. Their historical rows survive; no new one is written while they are not ACTIVE.

The weekly job's name promises more than it does. In its current form it computes weekly totals, broadcasts them to the cron log, and returns — statistics moved to on-demand computation and this job was not removed. Nothing downstream reads a weekly aggregate, so a failed run costs you a log line.

What Recalculate does

Forces a recomputation of one leader's statistics

The Recalculate Stats button on the leader detail page /admin/copy-trading/leader/{id} does exactly three things:

  1. Reads the current statistics so the response can show you a "before".
  2. Re-queries that leader's closed trades and returns the recomputed figures in the response body, along with follower-copy totals, total allocated, max drawdown and average trade duration.
  3. Deletes one Redis key — copy:leader:stats:v2:{id}.

It writes nothing to any leader, trade or statistics row.

The leaderboard, the landing page, platform stats and the admin leader list all go through calculateBatchLeaderStats, which has no cache to bust. The only two surfaces the invalidation reaches are the public leader detail page and the leader's own profile — and those recompute by themselves five minutes later anyway.

It does not touch the follower cache, the allocation cache, the daily rows, or the copy_trading_leader_stats table. It does not rewrite historical rows. If a figure is wrong because the underlying trade row is wrong, Recalculate will faithfully reproduce the wrong figure.

Use it to prove a number is current, and to get the drill-down in the response body. Do not reach for it as a repair tool.

One caveat on the response itself: the newStats block is computed with bare sums over profit and cost with no currency bucketing, while oldStats comes from the currency-aware calculator. On a single-quote leader the two agree. On a multi-quote leader the changes block is comparing two different kinds of number and should be ignored.

Every recalculation writes a RECALCULATE_STATS audit row against entity type LEADER, with both figure sets and the acting admin.

The historic SELL-copy return defect

profitPercent on a follower copy is the trade's return, and for a SELL copy it was divided by trade.cost — which on a sell row holds the base quantity, not a quote notional. A 1% move on a $60,000 asset therefore rendered as 60,000%.

The close path now divides by entryPrice × amount on the sell side, so every trade closed since is correct.

The fix is forward-only. Historical copy_trading_trades.profitPercent values on SELL copies are still wrong by a factor of the entry price, and nothing in the product rewrites them — including Recalculate.

Where that shows up: the percentage printed beside the P&L in the admin trade table at /admin/copy-trading/trade, the recent-trade list on a leader's public profile, and the landing page's live activity feed. profit itself — the money — was never affected, so P&L, wallet balances and profit share are all sound.

It does not reach the follower's own trade history at /copy-trading/trade, which prints profit in its own currency and never the percentage. Nor does it reach GET /api/copy-trading/analytics: every figure in that response — totalAllocated, totalProfit, overallROI, winRate, byLeader, profitChart and tradeDistribution — is derived from trade.profit and from USD-priced allocations. It carries no return series and no risk metric at all (no volatility, no Sharpe, no drawdown), so this defect cannot surface there.

A percentage that looks absurd is therefore always one stored row being displayed. Check whether that row is a SELL closed before the fix before treating it as a live defect.

What the admin dashboard does not get from this path

GET /api/admin/copy-trading computes its money figures itself, server-side, in one grouped pass over copy_trading_follower_allocations joined to the subscription. None of it comes from the cached statistics path, so nothing on the capital meter is ever up to five minutes old — it is as of generatedAt in the response.

That pass produces the four capital bands (Being copied, Paused, Dormant, Stranded), the at-risk leader table, totalAllocated, inUse, and the underwater-follower count. Because it is one scan, the four bands sum to totalAllocated exactly. capital.currency is sent only when every open allocation shares one quote asset and is null otherwise, with capital.quoteAssets giving the count. The dormancy threshold is sent as capital.dormantDays (7) so the page states the definition it was computed with.

Platform revenue on the same response is grouped by currency for the same reason, and revenueCurrency is null when profit share has been booked in more than one asset.

Everything else on that page — leader and follower counts, today's trades, volume, failure rate, the seven-day timeline and the sparklines — is a plain count or sum, not a statistics-calculator figure.

See Admin console for how to read the bands, and Settings for the thresholds named above.