Skip to main content
What you’ll build:
  • Fetch a wallet’s total PnL (realized, unrealized, fees, external flows)
  • Batch-query PnL for up to 100 tokens in one call, with per-token breakdowns
  • Compare the same token’s performance across different chains
  • Use time-range filters for period-specific PnL
  • Handle edge cases (503 bootstrap, airdrops)
=== PORTFOLIO SUMMARY ===
Realized Gain:     $1,245.67
Unrealized Gain:   $3,891.02
Net Invested:      $12,500.00
Fees:              $342.18
Received External: $500.00
Sent External:     $150.00

=== TOKEN BREAKDOWN ===
ETH
  Buy avg: $2450.85  ->  Sell avg: $3527.83
  Realized: $1076.98 (43.9%)
  Unrealized: $3214.50
  Invested: $7353.00  |  Fees: $245.12
UNI
  Buy avg: $6.42  ->  Sell avg: $8.15
  Realized: $168.69 (26.3%)
  Unrealized: $676.52
  Invested: $642.00  |  Fees: $84.56
Time: ~10 minutes

Prerequisites

  • A Zerion API key (get one here)
  • Node.js 18+ (for native fetch) or any HTTP client

How PnL is calculated

Zerion uses FIFO (First In, First Out) — earliest purchases are matched against earliest sales. This is the most common standard for tax reporting.

Response fields

FieldDescription
realized_gainProfit/loss from sold tokens
unrealized_gainPaper gains on tokens you still hold
total_feeCumulative transaction fees paid
net_investedTotal buys minus sale proceeds
received_externalValue of tokens received from external sources (transfers in)
sent_externalValue of tokens sent externally (transfers out)
sent_for_nftsValue spent purchasing NFTs
received_for_nftsValue received from selling NFTs
When you add token filters (fungible_ids or fungible_implementations), the response also includes a breakdown object with per-token stats like average_buy_price, average_sell_price, and gain percentages.
The resource type in the response is wallet_pnl (not pnl).

Steps

1
Portfolio-level PnL
2
Fetch overall PnL for a wallet across all tokens and chains.
4
const API_KEY = process.env.ZERION_API_KEY;
const BASE_URL = "https://api.zerion.io/v1";
const headers = {
  accept: "application/json",
  authorization: `Basic ${btoa(API_KEY + ":")}`,
};

async function getPortfolioPnL(address, currency = "usd") {
  const response = await fetch(
    `${BASE_URL}/wallets/${address}/pnl?currency=${currency}`,
    { headers }
  );
  const { data } = await response.json();
  return data.attributes;
}

const pnl = await getPortfolioPnL("0x42b9df65b219b3dd36ff330a4dd8f327a6ada990");
console.log(`Realized Gain:     $${pnl.realized_gain.toFixed(2)}`);
console.log(`Unrealized Gain:   $${pnl.unrealized_gain.toFixed(2)}`);
console.log(`Net Invested:      $${pnl.net_invested.toFixed(2)}`);
console.log(`Total Fees:        $${pnl.total_fee.toFixed(2)}`);
console.log(`Received External: $${pnl.received_external.toFixed(2)}`);
console.log(`Sent External:     $${pnl.sent_external.toFixed(2)}`);
5
Batch PnL for specific tokens
6
Pass up to 100 tokens in a single request to get a per-token breakdown. There are two filtering modes:
7
FilterUse caseFormatBreakdown keyfungible_idsCross-chain aggregate — PnL for a token across all chainsZerion token ID (eth, usdc)breakdown.by_idfungible_implementationsChain-specific — PnL for a token on a specific chainchain:contract_address pairbreakdown.by_implementation
8
Option A: fungible_ids (cross-chain)
9
Aggregates PnL for a token across all chains. Best for portfolio-level token views where you don’t care which chain the activity happened on.
10
const params = new URLSearchParams({
  currency: "usd",
  "filter[fungible_ids]": "eth,uni,wbtc",
});

const response = await fetch(
  `${BASE_URL}/wallets/${address}/pnl?${params}`,
  { headers }
);
const { data } = await response.json();
const breakdown = data.attributes.breakdown?.by_id;

for (const [tokenId, stats] of Object.entries(breakdown)) {
  console.log(`${tokenId}: $${stats.total_gain?.toFixed(2)} gain (${stats.relative_total_gain_percentage?.toFixed(1)}%)`);
}
11
Example response (breakdown.by_id):
12
{
  "breakdown": {
    "by_id": {
      "eth": {
        "average_buy_price": 3450.85,
        "average_sell_price": 3527.83,
        "realized_gain": -5.48,
        "unrealized_gain": -1.97,
        "total_fee": 1.88,
        "net_invested": 38.24,
        "received_external": 52.18,
        "sent_external": 0.13,
        "sent_for_nfts": 5.00,
        "received_for_nfts": 0
      }
    }
  }
}
13
When you filter by token, the top-level fields (realized_gain, net_invested, etc.) reflect only the filtered tokens, not your entire wallet.
14
Option B: fungible_implementations (chain-specific)
15
Tracks PnL per chain deployment. Use chain:contract_address pairs. Best for comparing the same token across L1s and L2s, or when you need chain-level precision.
16
const params = new URLSearchParams({
  currency: "usd",
  "filter[fungible_implementations]": [
    "ethereum:",                                             // ETH on Ethereum (native asset)
    "base:",                                                 // ETH on Base (native asset)
    "ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",  // USDC on Ethereum
    "base:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",      // USDC on Base
  ].join(","),
});

const response = await fetch(
  `${BASE_URL}/wallets/${address}/pnl?${params}`,
  { headers }
);
const { data } = await response.json();
const breakdown = data.attributes.breakdown?.by_implementation;

for (const [impl, stats] of Object.entries(breakdown)) {
  const [chain, addr] = impl.split(":");
  const label = addr ? `${chain}:${addr.slice(0, 10)}…` : `${chain} (native)`;
  console.log(`${label}: gain $${stats.realized_gain?.toFixed(2)}, unrealized $${stats.unrealized_gain?.toFixed(2)}`);
}
17
Native chain tokens use chain: with an empty address after the colon: ethereum: for ETH, base: for Base ETH, polygon: for MATIC, solana: for SOL.
18
Per-token breakdown fields:
19
Each entry in the breakdown object (whether by_id or by_implementation) contains:
20
FieldDescriptionaverage_buy_priceWeighted average purchase priceaverage_sell_priceWeighted average sale pricerealized_gainProfit/loss from closed positionsunrealized_gainPaper gain on current holdingsrelative_total_gain_percentageTotal gain as % of investedrelative_realized_gain_percentageRealized gain as % of cost basisrelative_unrealized_gain_percentageUnrealized gain as % of current holdings costtotal_investedSum of buys for this tokennet_investedBuys minus sellstotal_feeFees paid on this token’s transactionsreceived_externalValue received from external transferssent_externalValue sent externallysent_for_nftsValue spent on NFTs (for this token)received_for_nftsValue received from NFT sales
21
Time-range PnL
22
Scope PnL calculations to a specific time window using the since and till parameters. Values are Unix timestamps in milliseconds.
23
// PnL for calendar year 2025
const since = new Date("2025-01-01T00:00:00Z").getTime(); // 1735689600000
const till = new Date("2026-01-01T00:00:00Z").getTime();  // 1767225600000

const params = new URLSearchParams({
  currency: "usd",
  since: since.toString(),
  till: till.toString(),
});

const response = await fetch(
  `${BASE_URL}/wallets/${address}/pnl?${params}`,
  { headers }
);
24
# PnL for calendar year 2025 (annual recap)
curl -u YOUR_API_KEY: \
  -H "accept: application/json" \
  "https://api.zerion.io/v1/wallets/{address}/pnl?currency=usd&since=1735689600000&till=1767225600000"
25
Standard time ranges (1 day, 1 week, 1 month, 1 year, and annual recap from the beginning of the current year) benefit from pre-computed snapshots and return in under 200 ms at p99. Custom ranges may take longer depending on wallet history size.
26
You can combine since/till with token filters.
27
Unrealized gains are always calculated using current market prices, regardless of the till parameter. If you set till to a past date, realized gains reflect that period, but unrealized gains use today’s prices.
28
Filter by chain
29
Scope PnL to specific chains using filter[chain_ids].
30
GET /v1/wallets/{address}/pnl?currency=usd&filter[chain_ids]=ethereum,base
31
curl -u "YOUR_API_KEY:" \
  "https://api.zerion.io/v1/wallets/0x42b9df65b219b3dd36ff330a4dd8f327a6ada990/pnl?currency=usd&filter[chain_ids]=ethereum,base"
32
Useful when your app is chain-specific (e.g., a Base-only wallet) or you want to compare PnL across different L2s.
33
filter[chain_ids] is ignored when you use fungible_implementations — the chain is already embedded in each implementation string.
34
Full working example
35
Save as pnl-dashboard.mjs and run with node pnl-dashboard.mjs:
36
const API_KEY = process.env.ZERION_API_KEY;
const BASE_URL = "https://api.zerion.io/v1";
const headers = {
  accept: "application/json",
  authorization: `Basic ${btoa(API_KEY + ":")}`,
};

async function fetchPnL(address, params = {}, retries = 3) {
  const query = new URLSearchParams({ currency: "usd", ...params });
  const res = await fetch(`${BASE_URL}/wallets/${address}/pnl?${query}`, { headers });

  // Handle bootstrap: new/cold wallets may return 503 with Retry-After
  if (res.status === 503) {
    if (retries <= 0) throw new Error("Wallet PnL bootstrap timed out after retries");
    const retryAfter = res.headers.get("Retry-After") || "5";
    console.log(`Wallet bootstrapping... retrying in ${retryAfter}s (${retries} retries left)`);
    await new Promise((r) => setTimeout(r, parseInt(retryAfter) * 1000));
    return fetchPnL(address, params, retries - 1);
  }

  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return res.json();
}

async function buildDashboard(address, tokenIds) {
  // 1. Portfolio-level PnL
  const overall = await fetchPnL(address);
  const pnl = overall.data.attributes;

  console.log("=== PORTFOLIO SUMMARY ===");
  console.log(`Realized Gain:     $${pnl.realized_gain.toFixed(2)}`);
  console.log(`Unrealized Gain:   $${pnl.unrealized_gain.toFixed(2)}`);
  console.log(`Net Invested:      $${pnl.net_invested.toFixed(2)}`);
  console.log(`Fees:              $${pnl.total_fee.toFixed(2)}`);
  console.log(`Received External: $${pnl.received_external.toFixed(2)}`);
  console.log(`Sent External:     $${pnl.sent_external.toFixed(2)}`);

  // 2. Per-token breakdown
  if (tokenIds?.length) {
    const batch = await fetchPnL(address, {
      "filter[fungible_ids]": tokenIds.join(","),
    });
    const breakdown = batch.data.attributes.breakdown?.by_id || {};

    console.log("\n=== TOKEN BREAKDOWN ===");
    for (const [id, s] of Object.entries(breakdown)) {
      console.log(`${id.toUpperCase()}`);
      console.log(`  Buy avg: $${s.average_buy_price?.toFixed(2)}  ->  Sell avg: $${s.average_sell_price?.toFixed(2)}`);
      console.log(`  Realized: $${s.realized_gain?.toFixed(2)} (${s.relative_realized_gain_percentage?.toFixed(1)}%)`);
      console.log(`  Unrealized: $${s.unrealized_gain?.toFixed(2)}`);
      console.log(`  Invested: $${s.total_invested?.toFixed(2)}  |  Fees: $${s.total_fee?.toFixed(2)}`);
    }
  }
}

buildDashboard("0x42b9df65b219b3dd36ff330a4dd8f327a6ada990", [
  "eth", "uni", "wbtc",
]);

Edge cases & operational notes

For wallets that haven’t been queried before, or wallets with long transaction histories, the first request may return a 503 with a Retry-After header while PnL is being bootstrapped. This is non-billable. Retry after the indicated delay (usually a few seconds). Once bootstrapped, subsequent requests are fast (under 200 ms for basic PnL queries without a breakdown; building a breakdown adds 150–300 ms depending on the number of tokens). Snapshots are retained for ~1 month; after that, a re-bootstrap may be triggered.
Tokens without reliable price data are omitted from breakdown calculations rather than erroring. They simply won’t appear in the breakdown object.
Tokens received as “pure mints” (from the zero address or a token contract with no payment beyond gas) are assigned zero cost basis. However, many airdrops are actually distributed from a non-zero address, which causes them to be misclassified as purchases at the market price at the time of receipt. Keep this in mind when reviewing cost basis data for airdropped tokens.
Native chain tokens use chain: with an empty address after the colon: ethereum: for ETH, base: for Base ETH, polygon: for MATIC, solana: for SOL.
16 options via the currency param: usd, eur, gbp, btc, eth, krw, jpy, aud, cad, inr, nzd, try, zar, cny, chf, rub.
Max 100 tokens per request for either fungible_ids or fungible_implementations. For larger portfolios, paginate with multiple calls.

Next steps

  • Combine with wallet positions to fetch current holdings, then batch those token IDs into PnL for a complete dashboard
  • Add balance charts to visualize portfolio value over time alongside PnL
  • Set up webhooks to trigger PnL recalculations when new transactions land