All Resources
Solana Wallet PnL API: History, Currency and pnlMode
Data APITypeScript12 min readSolana Tracker

Solana Wallet PnL API: History, Currency and pnlMode

Query the Solana wallet PnL API for realized and unrealized PnL, daily history and drawdown in USD, SOL or EUR, with pnlMode and queued-wallet polling.

  • pnl,
  • wallets,
  • data-api,
  • analytics,
  • typescript

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.

PanelSDK methodEndpointpnlModecurrencyWindow
Lifetime summarygetPnlV2WalletOverview/v2/pnl/wallets/{w}YesYes (current spot)Lifetime
Daily calendargetPnlV2WalletHistory/v2/pnl/wallets/{w}/historyNoYes (daily rate)period or start/end
Streaks and drawdowngetPnlV2WalletPerformance/v2/pnl/wallets/{w}/performanceNoYes (daily rate)period or days
Per-token positionsgetPnlV2WalletPositions/v2/pnl/wallets/{w}/positionsYesNoOptional period
One tokengetPnlV2WalletTokenPosition/v2/pnl/wallets/{w}/tokens/{t}YesNoLifetime
Chart pointsgetPnlV2WalletChart/v2/pnl/wallets/{w}/chartNoNotime_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.

EndpointConversion rateResponse marker
SummaryCurrent spot"currency": "sol" or "eur"
HistoryEach day's historical reference rateSame
PerformanceEach day's historical reference rateSame

Rules that matter when you render the numbers:

  • Counts, percentages, timestamps and ROI are never converted.
  • Field names do not change. activity.volume.costUsd holds SOL when you asked for SOL. Pick the unit label from the top-level currency, 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:

ModeBehavior
strict (default)Excludes those positions from realized PnL
adjustedKeeps them, recalculating realized PnL with an adjusted cost basis
rawNo 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

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

Runnable Node.js project — clone from GitHub, add keys to .env, then npm start. Use a private local environment for credentials. Check the companion package version before running.

Build on this example

Ask for changes, a different stack or the next feature. Answers use the Data API SDK and link the docs.