The Solana PnL leaderboard API is GET /v2/pnl/leaderboard/top on the Solana Tracker Data API, called as client.getPnlV2TopTraders() in @solana-tracker/data-api 0.5.0. It ranks wallets by realized PnL, volume, ROI, win rate, trades, tokens or active days over a rolling 1, 7, 30 or 90 day window, with filters that remove arbitrage bots and one-token wonders. For the tracked KOL roster, /v2/pnl/leaderboard/kols ranks all-time and /v2/pnl/leaderboard/kols/period ranks a chosen window.
You can see both rankings rendered live on the Solana PnL leaderboard and the KOL leaderboard. This guide is about ranking many wallets. To analyze one wallet's PnL history in depth, use the Solana wallet PnL API; for current holdings and value, use the wallet portfolio API.
Which Solana PnL leaderboard API endpoint to use
All leaderboard routes belong to PnL v2 on the Solana Data API. They differ by population and time scope.
| Question | SDK method | Endpoint | Population | Window |
|---|---|---|---|---|
| Who made the most in the last N days? | getPnlV2TopTraders | /v2/pnl/leaderboard/top | Top 500k wallets per period | 1, 7, 30 or 90 days |
| Which KOLs are up all-time? | getPnlV2KOLLeaderboard | /v2/pnl/leaderboard/kols | Tracked KOL roster | All-time |
| Which KOLs led this week or month? | getPnlV2KOLPeriodLeaderboard | /v2/pnl/leaderboard/kols/period | Tracked KOL roster | period or start/end |
| How did KOLs do each day this month? | getPnlV2KOLCalendar | /v2/pnl/leaderboard/kols/calendar | Aggregate | One month |
| Who did what on one date? | getPnlV2KOLByDate | /v2/pnl/leaderboard/kols/date | Tracked KOL roster | One day |
| Who profited on one token? | getPnlV2TokenTraders | /v2/pnl/tokens/{token}/traders | Wallets that traded the token | Token lifetime |
The top-trader board is not limited to KOLs. Coverage is capped at the top 500,000 wallets per period, so a wallet can trade and still be absent. Query it directly with the wallet endpoints in that case. For the earliest buyers of a token rather than its most profitable traders, see the pump.fun first buyers guide.
Parameters, sorting and time windows
getPnlV2TopTraders accepts:
| Parameter | Default | Values and meaning |
|---|---|---|
days | 90 | 1, 7, 30 or 90. Other values resolve to the nearest supported window |
sort | realized | realized, volume, days, roi, win_percentage, trades, tokens |
direction | desc | asc or desc |
limit | 100 | Rows per page |
cursor | none | pagination.nextCursor from the previous page, unchanged |
pnlMode | strict | strict, adjusted or raw |
excludeArbitrage | "true" | String "false" includes arbitrage wallets |
platform | none | axiom, axiom-flash (alias of axiom), bloom, photon; comma-separated |
minTrades | 20 | Minimum trades in the window |
minInvested | 1 | Minimum USD invested in the window |
minDays | 3 | Minimum active trading days |
minWinRate, minRoi, minClosedTokens | none | Floors on win rate %, ROI % and closed positions |
maxSingleTokenPct | none | Maximum share of PnL from one token |
The KOL routes are simpler. getPnlV2KOLLeaderboard takes sort (total, realized, unrealized, invested, proceeds, value, trades, tokens, roi, win_percentage, last_trade; default realized), direction, limit and cursor. getPnlV2KOLPeriodLeaderboard takes sort (realized, volume, days, ending_total), period (1d, 7d, 14d, 30d, 90d, all; default 30d) or explicit start and end dates in YYYY-MM-DD, which override period. Neither KOL route accepts pnlMode; they return a fixed server-side view.
Response shape
Each top-trader row is a PnlV2Top90dTrader. Trimmed from the documented example:
{
"traders": [
{
"wallet": "HkFGQsW8mr8DTC2AE2WcC7MzwSnynfEryGMQSht271nf",
"period": {
"realized": 29253686.75,
"realizedRaw": 29511824.12,
"volume": 53377270.16,
"tradingDays": 79,
"roi": 345.12,
"days": { "profitable": 58, "losing": 21, "maxSinglePnl": 891245.33, "winRate": 73.42 }
},
"pnlAdjustments": { "mode": "adjusted", "invalidPnl": 29511824.12, "adjustedCorrection": 258137.37 },
"ending": { "pnl": { "realized": 29642991.91, "total": 29684935.07 } },
"invested": 8476262.08,
"counts": { "buys": 13842, "sells": 27968, "trades": 41810, "tokensTraded": 4689 },
"tokens": { "profitable": 2441, "losing": 159, "closed": 2600 },
"winRate": 93.88,
"lastSnapshotDate": "2026-04-19",
"identity": { "name": null, "twitter": null, "avatar": "https://kol-avatar.solanatracker.io/HkFG…" }
}
],
"pagination": { "hasMore": true, "nextCursor": "eyJzb3J0VmFsdWUiOj…", "count": 2, "total": 2, "pnlMode": "adjusted" }
}
Read the fields carefully:
period.*is scoped to the window.period.realizedis mode-aware;period.realizedRawis the unfiltered figure.ending.pnlis the wallet's cumulative PnL as oflastSnapshotDate, not the window's PnL.- There are two win rates. Top-level
winRateis profitable closed tokens over closed tokens (2441 / 2600 = 93.88).period.days.winRateis profitable days over active days (58 / 79 = 73.42).period.daysis optional and absent on some rows. roiis a percentage, consistent with realized over invested in the example (29.25M / 8.48M is about 3.45x).identitycan benull, and a present identity can still havename: nullwith only an avatar.
KOL all-time rows carry lifetime pnl { realized, unrealized, total }, roi, winRate, openPositions and a non-optional identity that may be null. KOL period rows carry period { realized, volume, tradingDays }, ending.pnl and identity.
Build a leaderboard in TypeScript
Install the SDK and put your key from the Data API dashboard in .env:
npm init -y && npm pkg set type=module
npm install @solana-tracker/data-api@^0.5.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
# .env.example
ST_API_KEY=
LEADERBOARD_DAYS=30
LEADERBOARD_SORT=realized
PNL_MODE=strict
LIMIT=20
The script validates every input against the documented enums before spending a request, retries 429 and 5xx with backoff, and prints the top traders followed by the KOL board for the same window.
import { Client, DataApiError, RateLimitError, ValidationError } from '@solana-tracker/data-api';
import type { PnlMode, PnlV2Identity, PnlV2TopTradersParams } from '@solana-tracker/data-api';
const apiKey = process.env.ST_API_KEY?.trim();
if (!apiKey) { console.error('Missing ST_API_KEY'); process.exit(1); }
const WINDOWS = { 1: '1d', 7: '7d', 30: '30d', 90: '90d' } as const;
const SORTS = ['realized', 'volume', 'days', 'roi', 'win_percentage', 'trades', 'tokens'] as const;
const MODES = ['strict', 'adjusted', 'raw'] as const;
const days = Number(process.env.LEADERBOARD_DAYS ?? 30);
const sort = (process.env.LEADERBOARD_SORT ?? 'realized') as (typeof SORTS)[number];
const pnlMode = (process.env.PNL_MODE ?? 'strict') as PnlMode;
const limit = Number(process.env.LIMIT ?? 20);
if (!(days in WINDOWS)) { console.error('LEADERBOARD_DAYS must be 1, 7, 30 or 90'); process.exit(1); }
if (!SORTS.includes(sort)) { console.error(`LEADERBOARD_SORT must be one of ${SORTS.join(', ')}`); process.exit(1); }
if (!MODES.includes(pnlMode)) { console.error('PNL_MODE must be strict, adjusted or raw'); process.exit(1); }
if (!Number.isInteger(limit) || limit < 1 || limit > 100) { console.error('LIMIT must be 1-100'); process.exit(1); }
const client = new Client({ apiKey });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function withRetry<T>(label: string, fn: () => Promise<T>, attempts = 4): Promise<T> {
for (let i = 1; ; i++) {
let timer: NodeJS.Timeout | undefined;
try {
const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out`)), 15_000); });
return await Promise.race([fn(), timeout]);
} catch (err) {
const retryable = err instanceof RateLimitError
|| (err instanceof DataApiError && !(err instanceof ValidationError) && (err.status ?? 500) >= 500)
|| !(err instanceof DataApiError);
if (!retryable || i >= attempts) throw err;
const retryAfterMs = err instanceof RateLimitError && err.retryAfter ? err.retryAfter * 1000 : 0;
await sleep(Math.max(retryAfterMs, Math.min(8_000, 500 * 2 ** (i - 1))));
} finally {
clearTimeout(timer);
}
}
}
const label = (id: PnlV2Identity | null | undefined) => id?.name || id?.sns?.domain || id?.type || '';
const pct = (n: number | null | undefined) => (typeof n === 'number' ? `${n.toFixed(1)}%` : 'n/a');
const usd = (n: number | null | undefined) => (typeof n === 'number' ? `$${Math.round(n).toLocaleString('en-US')}` : 'n/a');
try {
const params: PnlV2TopTradersParams = { days, sort, direction: 'desc', limit, pnlMode, excludeArbitrage: 'true' };
const top = await withRetry('top traders', () => client.getPnlV2TopTraders(params));
console.log(`Top traders, ${days}d by ${sort}, mode ${top.pagination.pnlMode ?? pnlMode}`);
top.traders.forEach((t, i) => {
console.log(`${String(i + 1).padStart(3)} ${t.wallet.slice(0, 6)}… ${label(t.identity).padEnd(16)}`
+ ` realized ${usd(t.period.realized)} roi ${pct(t.period.roi)}`
+ ` token win ${pct(t.winRate)} day win ${pct(t.period.days?.winRate)} trades ${t.counts.trades}`);
});
if (top.pagination.hasMore) console.log(`next cursor: ${top.pagination.nextCursor}`);
const kols = await withRetry('kol period', () =>
client.getPnlV2KOLPeriodLeaderboard({ period: WINDOWS[days as keyof typeof WINDOWS], sort: 'realized', limit: 10 }));
console.log(`\nKOLs, ${WINDOWS[days as keyof typeof WINDOWS]}`);
kols.traders.forEach((k, i) => {
console.log(`${String(i + 1).padStart(3)} ${(label(k.identity) || k.wallet.slice(0, 6) + '…').padEnd(20)}`
+ ` realized ${usd(k.period.realized)} volume ${usd(k.period.volume)} lifetime ${usd(k.ending.pnl.total)}`);
});
} catch (err) {
console.error('Leaderboard request failed:', err instanceof DataApiError ? `HTTP ${err.status} ${err.message}` : (err as Error).message);
process.exitCode = 1;
}
The companion project adds the platform, trade-count, active-day and concentration filters, follows the cursor for several pages, and prints the all-time KOL table from getPnlV2KOLLeaderboard.
Pagination
Every leaderboard response includes pagination { hasMore, nextCursor, count, total }. Pass nextCursor back unchanged as cursor and keep every other parameter identical; treat the cursor as opaque. Changing sort, days or a filter between pages produces a list that is not one ranking. Stop when hasMore is false or nextCursor is null.
pnlMode on the leaderboard
Some positions have more recorded sells than recorded buys, for example when tokens arrived without an indexed buy, so their realized PnL rests on an incomplete cost basis. pnlMode decides how those positions count:
| Mode | Treatment | Use it for |
|---|---|---|
strict (default) | Flagged positions are excluded (zeroed) | Conservative, comparable rankings |
adjusted | Kept, with realized PnL recalculated on an adjusted cost basis | Showing wallets strict mode would understate |
raw | No filtering | Inspection and debugging |
The response echoes the mode in pagination.pnlMode, and rows expose period.realizedRaw and, where applied, pnlAdjustments. Store the mode with any cached ranking. A wallet's rank can change substantially between modes, so a table that does not state its mode is not reproducible.
Production pitfalls
State the window, sort and mode above the table. A 7-day realized ranking and a 90-day ROI ranking are different lists. Showing lifetime ending.pnl.total next to a 30-day period.realized without labels misleads readers.
Dollar profit, ROI and win rate each favor a different wallet. Dollar rankings favor large capital. ROI can be dominated by a few small positions. Token win rate hides payoff size, and many small wins can coexist with one large loss. Use minInvested, minClosedTokens and maxSingleTokenPct to remove degenerate cases.
Leaderboards are survivorship-selected. Ranking after the fact does not mean the same wallets were identifiable in advance. A follower also trades later, at worse prices, with different fees. Do not present a rank as a prediction.
Labels are presentation, not joins. Join on wallet. Treat identity as optional, and render the address when it is null or has no name. The wallet identity guide covers how labels resolve and why they can change.
Arbitrage is excluded by default. If your totals look low against another source, check excludeArbitrage before anything else.
Cache, do not hammer. Rows carry lastSnapshotDate and updatedAt. Cache each ranking per (days, sort, filters, pnlMode) and refresh on a schedule that matches how often those timestamps move, instead of on every page view. If you need sustained high request volumes, compare plans on the Data API page or talk to Enterprise.
FAQ
What time windows does the Solana PnL leaderboard API support?
The top-trader board supports rolling 1, 7, 30 and 90 day windows via days, defaulting to 90; other values resolve to the nearest window. KOL period rankings accept 1d, 7d, 14d, 30d, 90d or all, or explicit start and end dates.
Which win rate should I display?
Top-level winRate is the share of closed tokens that were profitable. period.days.winRate is the share of active days that were profitable. Label whichever you show.
Why is a wallet I know missing from the leaderboard?
The top-trader board covers the top 500,000 wallets per period, and default filters require at least 20 trades, 3 active days and $1 invested, and exclude arbitrage wallets. Query the wallet directly with the wallet PnL endpoints.
Does the KOL leaderboard accept pnlMode?
No. The KOL leaderboards return a fixed server-side view. pnlMode applies to the top-trader board, wallet summary, positions, single position and position batch.
Does PnL include SOL and stablecoins?
No. SOL and stablecoins are quote assets and are excluded from PnL positions. Every other SPL token is covered.
Is there a live version of these rankings?
Yes: the PnL leaderboard and KOL leaderboard pages render them in the browser.
References
- Solana traders leaderboard
- Get KOL leaderboard
- Get KOL leaderboard for a period
- Leaderboards guide
- How Solana wallet PnL works
- Track Solana KOL wallets
- @solana-tracker/data-api on npm
Companion project
The solana-pnl-leaderboard-api example ranks top traders with configurable window, sort, mode and filters, follows the cursor, and prints period and all-time KOL tables.
cp .env.example .env && npm install && npm start