The Solana wallet PnL API is PnL v2 on the Solana Tracker Data API: GET /v2/pnl/wallets/{wallet} returns a wallet's lifetime realized and unrealized PnL, /history returns daily snapshots, and /performance returns streaks, drawdown and best and worst days. In @solana-tracker/data-api 0.5.0 they are getPnlV2WalletOverview, getPnlV2WalletHistory and getPnlV2WalletPerformance; all three take a currency option (USD, SOL or EUR), the summary also takes pnlMode, and all three are typed to possibly return a queued placeholder that you must check before reading analytics.
This guide covers one wallet's trading profit over time. To rank many wallets, use the Solana PnL leaderboard API. For what a wallet holds right now and its market value, use the wallet portfolio API; value and PnL answer different questions and should stay on separate panels.
Solana wallet PnL API endpoints
PnL v2 on the Solana Data API splits wallet analytics into focused routes. Pick by the panel you are building and check which options each accepts.
| Panel | SDK method | Endpoint | pnlMode | currency | Window |
|---|---|---|---|---|---|
| Lifetime summary | getPnlV2WalletOverview | /v2/pnl/wallets/{w} | Yes | Yes (current spot) | Lifetime |
| Daily calendar | getPnlV2WalletHistory | /v2/pnl/wallets/{w}/history | No | Yes (daily rate) | period or start/end |
| Streaks and drawdown | getPnlV2WalletPerformance | /v2/pnl/wallets/{w}/performance | No | Yes (daily rate) | period or days |
| Per-token positions | getPnlV2WalletPositions | /v2/pnl/wallets/{w}/positions | Yes | No | Optional period |
| One token | getPnlV2WalletTokenPosition | /v2/pnl/wallets/{w}/tokens/{t} | Yes | No | Lifetime |
| Chart points | getPnlV2WalletChart | /v2/pnl/wallets/{w}/chart | No | No | time_from/time_to (unix seconds) |
Coverage is every SPL token except stablecoins and SOL, which act as quote assets, for all Solana wallets from December 2023 onward. If you want to compute PnL yourself from raw transactions instead, you would pull signatures and transactions from Solana RPC and build cost basis on your own; the endpoints above exist so you do not have to.
History takes period (1d, 7d, 14d, 30d, 90d, all) or explicit start and end dates (YYYY-MM-DD, defaulting to the last 30 days), plus limit (default 365, max 1000; the most recent days win when more exist). Performance takes period or days (default 30, max 365). When start/end are set on history, period is ignored.
Response shape
The summary, trimmed from the documented example for the sample wallet:
{
"wallet": "CyaE1VxvBrahnPWkqm5VsdCvyS2QmNht2UFrKJHga54o",
"pnlMode": "strict",
"identity": { "name": "Cented", "type": "kol", "tags": ["kol", "axiom", "bloom"] },
"summary": {
"pnl": { "realized": 10705447.32, "unrealized": 1395.74, "total": 10706843.06 },
"invested": 49779099.94,
"proceeds": 60484547.26,
"openPositions": { "cost": 5118.14, "value": 7855.98 },
"counts": { "buys": 196215, "sells": 138588, "trades": 334803, "tokensTraded": 78676, "tokensHeldEver": 78553 },
"roi": 21.51,
"timing": { "firstTrade": 1738348560665, "lastTrade": 1777057718629, "avgHoldTimeSecs": 20391 }
},
"analysis": { "winRate": 61.17, "tokens": { "closed": 78518, "winning": 48029, "losing": 30375 } },
"tags": { "isArbitrage": false, "platforms": ["axiom-flash", "bloom"] },
"updatedAt": "2026-04-24T19:08:38.649Z"
}
analysis.winRate is winning closed tokens over closed tokens (48029 / 78518 = 61.17%). roi is a percentage (realized over invested is about 21.5%). Timestamps are Unix milliseconds.
Each history day separates what happened that day from the running total:
interface PnlV2Snapshot {
date: string; // YYYY-MM-DD
cumulative: {
pnl: { realized: number | null; unrealized: number | null; total: number | null };
cost: number | null;
proceeds: number | null;
openPositions: { cost: number | null; value: number | null };
counts: { buys: number; sells: number; tokensTraded: number };
};
activity: {
pnl: { realized: number | null };
counts: { buys: number; sells: number };
volume: { costUsd: number | null; total: number | null };
averages: { buy: number | null; sell: number | null; realizedPnl: number | null; holdTimeSecs: number | null };
};
}
The history response adds summary { days { trading, positive, negative, breakEven }, totals { realizedPnl, volume }, winRate, totalDays, deltas }, where winRate is the share of trading days that were positive. Performance returns window, totals { realizedPnl, volume, trades }, bestDay, worstDay, streaks { positive, negative, currentPositive, currentNegative }, drawdown { amount, percent } and a per-day array. Performance trades counts daily buys plus sells inside the window, not lifetime totals.
Currency: USD, SOL or EUR
Pass currency: 'sol' or currency: 'eur' to convert monetary fields at read time. USD is the default and omits the currency field from the response.
| Endpoint | Conversion rate | Response marker |
|---|---|---|
| Summary | Current spot | "currency": "sol" or "eur" |
| History | Each day's historical reference rate | Same |
| Performance | Each day's historical reference rate | Same |
Rules that matter when you render the numbers:
- Counts, percentages, timestamps and ROI are never converted.
- Field names do not change.
activity.volume.costUsdholds SOL when you asked for SOL. Pick the unit label from the top-levelcurrency, never from a field name. - An invalid value returns HTTP 400 with
{"error":"Invalid currency. Options: usd, sol, eur"}. The SDK types restrict it to the three values. - Converted daily values are approximate and cumulative values do not chain perfectly from one day to the next, because each day uses its own rate. Converting an entire USD history with today's SOL price gives a different series.
currency: 'sol'changes the unit, not the coverage. SOL itself is still not a PnL position.
pnlMode: strict, adjusted, raw
Positions whose recorded sells exceed recorded buys get special treatment, controlled by pnlMode on the summary, positions, single-position, top-trader and position-batch endpoints:
| Mode | Behavior |
|---|---|
strict (default) | Excludes those positions from realized PnL |
adjusted | Keeps them, recalculating realized PnL with an adjusted cost basis |
raw | No filtering; realized PnL exactly as calculated |
Supported endpoints echo pnlMode, and position rows carry pnl.realizedRaw alongside the mode-aware pnl.realized. History, chart, performance, highlights and risk do not accept pnlMode; they return a fixed server-side view. A summary in adjusted mode next to a history chart can therefore disagree slightly. Keep the summary and positions panels on the same mode, and label it.
Queued responses
The SDK types the summary, history and performance methods as returning either the analytics or PnlV2WalletQueued:
interface PnlV2WalletQueued { indexed: false; queued: true; message: string }
The SDK README says wallet queries can return this while indexing and to check queued === true before reading analytics. The public docs describe coverage as all wallets from December 2023 and say wallets are not indexed on request, so treat a queued result as a transient state rather than a normal path: show "pending", poll with backoff and a deadline, and never render it as zero PnL. The example below waits 5, 10, 20, 40 and then 60 seconds between checks. Transport failures such as 429 and 5xx are retried separately, so a queued wallet never burns your retry budget.
Build it in TypeScript
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=
WALLET_ADDRESS=CyaE1VxvBrahnPWkqm5VsdCvyS2QmNht2UFrKJHga54o
PNL_CURRENCY=usd
PNL_PERIOD=30d
PNL_MODE=strict
PNL_QUEUE_MAX_WAIT_SEC=120
Get the key from the Data API dashboard.
import { Client, DataApiError, RateLimitError, ValidationError } from '@solana-tracker/data-api';
import type { PnlMode, PnlV2WalletHistoryParams, PnlV2WalletQueued } 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 wallet = process.env.WALLET_ADDRESS?.trim() || 'CyaE1VxvBrahnPWkqm5VsdCvyS2QmNht2UFrKJHga54o';
const currency = (process.env.PNL_CURRENCY ?? 'usd') as 'usd' | 'sol' | 'eur';
const period = (process.env.PNL_PERIOD ?? '30d') as NonNullable<PnlV2WalletHistoryParams['period']>;
const pnlMode = (process.env.PNL_MODE ?? 'strict') as PnlMode;
const maxWaitMs = Number(process.env.PNL_QUEUE_MAX_WAIT_SEC ?? 120) * 1000;
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(wallet)) { console.error('WALLET_ADDRESS is not base58'); process.exit(1); }
if (!['usd', 'sol', 'eur'].includes(currency)) { console.error('PNL_CURRENCY must be usd, sol or eur'); process.exit(1); }
if (!['1d', '7d', '14d', '30d', '90d', 'all'].includes(period)) { console.error('PNL_PERIOD is invalid'); process.exit(1); }
if (!['strict', 'adjusted', 'raw'].includes(pnlMode)) { console.error('PNL_MODE is invalid'); process.exit(1); }
const client = new Client({ apiKey });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function withRetry<T>(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('timeout')), 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 isQueued = (v: object): v is PnlV2WalletQueued => 'queued' in v && (v as PnlV2WalletQueued).queued === true;
async function pollUntilReady<T extends object>(label: string, call: () => Promise<T | PnlV2WalletQueued>): Promise<T | null> {
const deadline = Date.now() + maxWaitMs;
for (let attempt = 0; ; attempt++) {
const result = await withRetry(call);
if (!isQueued(result)) return result;
const delay = Math.min(60_000, 5_000 * 2 ** attempt);
if (Date.now() + delay > deadline) return null;
console.log(`${label}: queued (${result.message}), retrying in ${delay / 1000}s`);
await sleep(delay);
}
}
const fmt = (n: number | null | undefined, cur: string) =>
typeof n !== 'number' ? 'n/a' : cur === 'sol' ? `${n.toFixed(2)} SOL` : `${n.toFixed(2)} ${cur.toUpperCase()}`;
try {
const overview = await pollUntilReady('summary', () => client.getPnlV2WalletOverview(wallet, { pnlMode, currency }));
if (!overview) { console.log('Still queued, try again later'); process.exit(0); }
const cur = overview.currency ?? 'usd';
const { pnl, roi } = overview.summary;
console.log(`Summary (${overview.pnlMode ?? pnlMode}, ${cur}): realized ${fmt(pnl.realized, cur)}, unrealized ${fmt(pnl.unrealized, cur)}, total ${fmt(pnl.total, cur)}, ROI ${roi ?? 'n/a'}%, win rate ${overview.analysis.winRate ?? 'n/a'}%`);
const history = await pollUntilReady('history', () => client.getPnlV2WalletHistory(wallet, { period, currency }));
if (history) {
const hc = history.currency ?? 'usd';
for (const day of history.days.slice(-7)) {
console.log(day.date, 'day', fmt(day.activity.pnl.realized, hc), 'cumulative', fmt(day.cumulative.pnl.total, hc), `${day.activity.counts.buys}b/${day.activity.counts.sells}s`);
}
}
const perf = await pollUntilReady('performance', () => client.getPnlV2WalletPerformance(wallet, { period, currency }));
if (perf) {
const pc = perf.currency ?? 'usd';
console.log(`Best ${perf.bestDay?.date} ${fmt(perf.bestDay?.realizedPnl, pc)}, worst ${perf.worstDay?.date} ${fmt(perf.worstDay?.realizedPnl, pc)}, longest win streak ${perf.streaks.positive} days, max drawdown ${fmt(perf.drawdown.amount, pc)}`);
}
} catch (err) {
console.error('PnL request failed:', err instanceof DataApiError ? `HTTP ${err.status} ${err.message}` : (err as Error).message);
process.exitCode = 1;
}
The companion project formats the same three calls as tables, with locale-aware USD, EUR and SOL output, and moves the polling into a reusable pollUntilReady helper.
For live updates instead of polling, Datastream exposes PnL rooms such as subscribe.pnl.summary(wallet) on Premium, Business and Enterprise plans. Refresh the REST summary after any reconnect, since missed messages are not replayed.
Production pitfalls
Do not sum cumulative fields. cumulative.* already includes every earlier day. Summing it across a window counts old trades many times. Sum activity.* for a window, or subtract the first day's cumulative value from the last.
Key caches on everything that changes the answer. Wallet, endpoint, period or dates, currency and pnlMode all belong in the cache key. A late response for the previous wallet must never paint the current chart.
Label the denomination. A SOL-denominated chart can trend down while the USD chart trends up for the same wallet, because the rate moves. Show the unit next to every axis.
Summary and history use different rates. The summary converts at current spot; history converts each day at that day's rate. Their totals will not match in SOL or EUR, and that is expected.
Two platform spellings. The raw tags.platforms on the summary can contain axiom-flash, while identity tags normalize it to axiom. Normalize before grouping.
Value is not PnL. Open positions are marked at current prices in unrealized, but a thin pool cannot absorb a large sale at that price. Show openPositions.value with care, and link to the holdings view for the full picture. Identity labels on the response are covered in the wallet identity guide.
FAQ
Does currency=sol compute PnL on SOL itself?
No. It denominates the same analytics in SOL. SOL and stablecoins remain quote assets outside PnL coverage.
Why do the SOL summary and SOL history totals differ?
The summary converts at the current spot rate; history and performance convert each day at its own historical rate. The difference is the rate path, not an error.
Which endpoints accept pnlMode?
Wallet summary, positions, single position, the top-trader leaderboard and position batch. History, chart, performance, highlights, risk and the KOL leaderboards do not.
What should I do when the response says queued?
Treat it as pending. Poll with increasing delays up to a deadline, show a pending state, and never display zeros. Check queued === true before reading any field.
How far back does wallet PnL go?
PnL v2 covers Solana wallets from December 2023 onward. History limit caps at 1000 days per request and performance days at 365.
References
- Get wallet summary
- Get wallet PnL calendar (history)
- Get wallet performance
- How Solana wallet PnL works
- Wallet analysis guide
- Stream wallet and position PnL
- @solana-tracker/data-api on npm
Companion project
The solana-pnl-history-currency-api example prints a wallet's PnL summary, daily history and performance in USD, SOL or EUR, and polls queued wallets with backoff.
cp .env.example .env && npm install && npm start