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.
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,
profitCurrencyis 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,
profitCurrencybecomes the literal stringUSD, and anything with no usable rate is listed inunpricedCurrencies(leaders) orunpricedProfitCurrencies(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
The Recalculate Stats button on the leader detail page
/admin/copy-trading/leader/{id} does exactly three things:
- Reads the current statistics so the response can show you a "before".
- 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.
- 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.