> ## Documentation Index
> Fetch the complete documentation index at: https://developers.zerion.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a wallet PnL tracker

> Track realized and unrealized gains, cost basis, ROI, and per-token performance for any wallet across EVM chains and Solana with Zerion API PnL endpoints.

**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](https://dashboard.zerion.io))
* Node.js 18+ (for native `fetch`) or any HTTP client

## How PnL is calculated

Zerion uses **FIFO** (First In, First Out). It matches earliest purchases against earliest sales. This is the most common standard for tax reporting.

### Response fields

| Field | Description |
| - | - |
| `realized_gain` | Profit/loss from sold tokens |
| `unrealized_gain` | Paper gains on tokens you still hold |
| `total_fee` | Cumulative transaction fees paid |
| `net_invested` | Total buys minus sale proceeds |
| `received_external` | Value of tokens received from external sources (transfers in) |
| `sent_external` | Value of tokens sent externally (transfers out) |
| `sent_for_nfts` | Value spent purchasing NFTs |
| `received_for_nfts` | Value 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.

<Note>
  The resource type in the response is `wallet_pnl` (not `pnl`).
</Note>

## Steps

<Steps>
  <Step title="Portfolio-level PnL">
    Fetch overall PnL for a wallet across all tokens and chains.

    [`GET /v1/wallets/{address}/pnl`](/api-reference/wallets/get-wallet-pnl)`?currency=usd`

    ```javascript theme={null}
    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)}`);
    ```
  </Step>

  <Step title="Batch PnL for specific tokens">
    Pass up to **100 tokens** in a single request to get a per-token breakdown. There are two filtering modes:

    | Filter | Use case | Format | Breakdown key |
    | - | - | - | - |
    | `fungible_ids` | Cross-chain aggregate - PnL for a token across **all** chains | Zerion token ID (`eth`, `usdc`) | `breakdown.by_id` |
    | `fungible_implementations` | Chain-specific - PnL for a token on a **specific** chain | `chain:contract_address` pair | `breakdown.by_implementation` |

    **Option A: `fungible_ids` (cross-chain)**

    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.

    ```javascript theme={null}
    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)}%)`);
    }
    ```

    **Example response (`breakdown.by_id`):**

    ```json theme={null}
    {
      "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
          }
        }
      }
    }
    ```

    <Warning>
      When you filter by token, the top-level fields (`realized_gain`, `net_invested`, etc.) reflect **only the filtered tokens**, not your entire wallet.
    </Warning>

    **Option B: `fungible_implementations` (chain-specific)**

    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.

    ```javascript theme={null}
    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)}`);
    }
    ```

    <Note>
      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.
    </Note>

    **Per-token breakdown fields:**

    Each entry in the breakdown object (whether `by_id` or `by_implementation`) contains:

    | Field | Description |
    | - | - |
    | `average_buy_price` | Weighted average purchase price |
    | `average_sell_price` | Weighted average sale price |
    | `realized_gain` | Profit/loss from closed positions |
    | `unrealized_gain` | Paper gain on current holdings |
    | `relative_total_gain_percentage` | Total gain as % of invested |
    | `relative_realized_gain_percentage` | Realized gain as % of cost basis |
    | `relative_unrealized_gain_percentage` | Unrealized gain as % of current holdings cost |
    | `total_invested` | Sum of buys for this token |
    | `net_invested` | Buys minus sells |
    | `total_fee` | Fees paid on this token's transactions |
    | `received_external` | Value received from external transfers |
    | `sent_external` | Value sent externally |
    | `sent_for_nfts` | Value spent on NFTs (for this token) |
    | `received_for_nfts` | Value received from NFT sales |
  </Step>

  <Step title="Time-range PnL">
    Scope PnL calculations to a specific time window using the `since` and `till` parameters. Values are **Unix timestamps in milliseconds**.

    ```javascript theme={null}
    // 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 }
    );
    ```

    ```bash theme={null}
    # 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"
    ```

    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.

    You can combine `since`/`till` with token filters.

    <Warning>
      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.
    </Warning>
  </Step>

  <Step title="Filter by chain">
    Scope PnL to specific chains using `filter[chain_ids]`.

    [`GET /v1/wallets/{address}/pnl`](/api-reference/wallets/get-wallet-pnl)`?currency=usd&filter[chain_ids]=ethereum,base`

    ```bash theme={null}
    curl -g -u "YOUR_API_KEY:" \
      "https://api.zerion.io/v1/wallets/0x42b9df65b219b3dd36ff330a4dd8f327a6ada990/pnl?currency=usd&filter[chain_ids]=ethereum,base"
    ```

    Useful when your app is chain-specific (e.g., a Base-only wallet) or you want to compare PnL across different L2s.

    <Note>
      Zerion ignores `filter[chain_ids]` when you use `fungible_implementations`. The chain is already embedded in each implementation string.
    </Note>
  </Step>

  <Step title="Full working example">
    Save as `pnl-dashboard.mjs` and run with `node pnl-dashboard.mjs`:

    ```javascript theme={null}
    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",
    ]);
    ```
  </Step>
</Steps>

## Edge cases & operational notes

<AccordionGroup>
  <Accordion title="503 + Retry-After (cold wallets)">
    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.

    A `503` always carries a `Retry-After` header. If you get an error without one, retrying is not the answer. See the next two entries.
  </Accordion>

  <Accordion title="422 (wallets too large to calculate)">
    Wallets with more than 1 million actions are too large for PnL to be calculated, and return `422` with no `Retry-After` header:

    ```json theme={null}
    {
      "errors": [
        {
          "title": "This request is not supported",
          "detail": "This request cannot be processed, and retrying will not help. Please contact support if you need it enabled."
        }
      ]
    }
    ```

    Don't fold this into your `503` retry loop. The helper above only recurses on `503`, so a `422` surfaces to the caller instead. Don't cache it against the address either: the same wallet can return `503` again later if its cached state is rebuilt.
  </Accordion>

  <Accordion title="400 (addresses Zerion doesn't track)">
    Zerion only calculates PnL for addresses that behave like user wallets. Zerion rejects contract addresses that aren't recognized smart-contract wallets, burn addresses, and high-volume addresses such as exchange hot wallets before doing any work:

    ```json theme={null}
    {
      "errors": [
        {
          "title": "Malformed parameter was sent",
          "detail": "address 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 is not trackable"
        }
      ]
    }
    ```

    Smart-contract wallets are fine: Safe, Coinbase Smart Wallet and ERC-4337 accounts all work. See [Error handling](/error-handling) for the full rule.
  </Accordion>

  <Accordion title="Excluded tokens">
    Tokens without reliable price data are omitted from breakdown calculations rather than erroring. They won't appear in the `breakdown` object.
  </Accordion>

  <Accordion title="Airdrop cost basis">
    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.
  </Accordion>

  <Accordion title="Base asset format">
    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.
  </Accordion>

  <Accordion title="Supported currencies">
    16 options via the `currency` param: `usd`, `eur`, `gbp`, `btc`, `eth`, `krw`, `jpy`, `aud`, `cad`, `inr`, `nzd`, `try`, `zar`, `cny`, `chf`, `rub`.
  </Accordion>

  <Accordion title="Batch limits">
    Max 100 tokens per request for either `fungible_ids` or `fungible_implementations`. For larger portfolios, paginate with multiple calls.
  </Accordion>
</AccordionGroup>

## Next steps

* Combine with [wallet positions](/recipes/multi-chain-portfolio) to fetch current holdings, then batch those token IDs into PnL for a complete dashboard
* Add [balance charts](/recipes/multi-chain-portfolio) to visualize portfolio value over time alongside PnL
* Set up [webhooks](/recipes/wallet-activity-alerts) to trigger PnL recalculations when new transactions land


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.