---
title: "FIFO cost-basis PnL with daily curve"
method: GET
path: "/wallet/{address}/pnl"
tags: ["Wallet"]
---

# FIFO cost-basis PnL with daily curve

`GET /wallet/{address}/pnl`

## Request

- Base URL: `https://madeonsol.com/api/v1`
- URL: `https://madeonsol.com/api/v1/wallet/{address}/pnl`
- Auth: HTTP bearer

Full per-wallet PnL: realized + unrealized SOL, profit factor, max drawdown, avg + median hold minutes, daily UTC PnL curve, closed positions sorted by pnl desc (with ROI %, hold minutes, win/loss/breakeven), and open positions hydrated with current prices from market-cap tracker.

**Caching**: stored in `wallet_analyses` with dynamic TTL — 5 min for wallets active in last 24h, 1h for 1-7d old, 24h for dormant. Every hit is validated against the wallet's trade head (audit F16): a wallet that traded after the cached analysis is recomputed (`cache_invalidated: new_activity`), so a resumed dormant wallet never gets a day-old answer. `cache_age_seconds` + `computed_at` carry the source age on a hit. Cache hits don't count against your daily quota.

**Honesty note**: two limits, both disclosed in the response. (1) Time — cost basis is only observable inside our 90-day data window, and capture itself starts 2026-04-12; a wallet that sold tokens bought before that has the overflow silently discarded rather than fabricated (`notes.cost_basis_observable_from` surfaces the cutoff). (2) Token universe — persisted trades are gated to the launchpad pipeline (pump.fun, LaunchLab/bonk, bags), so a wallet trading outside it is not partially covered but uncovered. The `coverage` object reports `in_scope`; when it is false, zero and empty results mean 'not captured', not 'no activity'. PRO+.

## Path parameters

- `address` string, required

## Response `200`

OK

- WalletPnl
  - `address` string
  - `window_days` integer
  - `completeness` 'complete' | 'incomplete': incomplete when at least one disposal's realized PnL is unknown (a sell whose proceeds provably went to another wallet, price_status proceeds_unattributed; or a trade with unknown consideration). Such a disposal consumes the FIFO lots (holdings are right) but is excluded from realized_sol, wins/losses, win_rate, profit_factor and best/worst — never booked as 0 proceeds. Absent on analyses cached before this field existed. This is realized-PnL completeness. It is NOT `coverage.completeness` (source-coverage verification: verified / not_verified / ...), which says whether the trade capture for this subject is proven complete.
  - `incomplete_reasons` string[]: Empty when complete.
  - `summary` object
    - `realized_sol` number: Known realizations only; see unknown_realized_cost_basis_sol and completeness.
    - `unrealized_sol` number
    - `total_pnl_sol` number
    - `total_bought_sol` number
    - `total_sold_sol` number
    - `unknown_realized_cost_basis_sol` number: Cost basis of FIFO lots consumed by disposals whose realization is unknown. It left the open positions and is NOT in realized_sol (neither a loss nor a gain). 0 when complete.
    - `wins` integer
    - `losses` integer
    - `win_rate` number, nullable
    - `profit_factor` number, nullable: Gross wins / gross losses. null if no losses (undefined math).
    - `avg_hold_minutes` integer, nullable
    - `median_hold_minutes` integer, nullable
    - `max_drawdown_sol` number
    - `open_positions_count` integer
    - `closed_positions_count` integer
    - `total_tokens_traded` integer
    - `best_realized` object, nullable
      - `token_mint` string
      - `realized_sol` number
    - `worst_realized` object, nullable
      - `token_mint` string
      - `realized_sol` number
  - `pnl_curve` object[]: Sparse daily UTC buckets — only days with at least one realized event are returned.
    - `date` string: YYYY-MM-DD UTC
    - `day_pnl` number
    - `cumulative_pnl` number
    - `trades` integer
  - `closed_positions` WalletClosedPosition[]: Tokens where the wallet's position has been fully closed AND there was an observable buy in window. Sorted by pnl_sol DESC (winners first).
    - `token_mint` string
    - `buy_count` integer
    - `sell_count` integer
    - `bought_sol` number
    - `sold_sol` number
    - `pnl_sol` number
    - `roi_pct` number, nullable: realized_sol / total_bought_sol × 100; null when realized_status is not known
    - `hold_minutes` integer, nullable: first buy to last sell
    - `result` 'win' | 'loss' | 'breakeven' | 'unknown': unknown = at least one disposal's realization is unknown (proceeds_unattributed / unknown consideration): not a win, not a loss, not counted in win_rate or profit_factor; sorted after the known positions
    - `realized_status` 'known' | 'partial' | 'unknown': known = every disposal realized; partial = some unknown (pnl_sol is the known part); unknown = only unknown disposals
    - `unknown_realized_cost_basis_sol` number: Cost basis of the lots consumed by unknown disposals of this token; not in pnl_sol.
    - `first_trade` string, date-time, nullable
    - `last_trade` string, date-time, nullable
  - `open_positions` WalletOpenPosition[]
    - `token_mint` string
    - `token_amount` number
    - `cost_basis_sol` number
    - `avg_entry_price_sol` number
    - `current_price_sol` number, nullable: Live from market-cap tracker. null if the mint has no current price (e.g. delisted).
    - `current_value_sol` number, nullable
    - `unrealized_sol` number, nullable
    - `unrealized_pct` number, nullable
    - `first_buy_at` string, date-time, nullable
    - `buys_in_position` integer
    - `position_basis` 'swap_derived': Positions are FIFO-open DEX buys from token_trades (trade position), not a proven holding.
    - `holding_status` 'verified' | 'unverified'
    - `holding_unverified_reason` 'not_checked' | 'no_fresh_snapshot' | 'snapshot_unreadable' | 'snapshot_older_than_trades' | 'decimals_mismatch' | 'balance_invalid', nullable
    - `cost_basis_status` 'known' | 'partial': partial = a verified balance exceeds the trade position; the excess arrived without a swap and has no cost basis.
    - `holding` object, nullable
      - `status` 'HELD' | 'PARTIALLY_REDUCED' | 'TRANSFERRED_OR_DISPOSED' | 'EXTERNAL_INFLOW'
      - `onchain_balance` number
      - `held_known_amount` number
      - `external_inflow_amount` number
      - `cost_basis_held_sol` number
      - `unrealized_known_sol` number, nullable
      - `cost_basis_not_held_sol` number: Cost of FIFO lots no longer in the wallet (transfer out / unindexed disposal). Outcome unknown: neither realized nor unrealized.
      - `verified_at` string, date-time
      - `source` 'wallet_holdings_cache'
  - `notes` object
    - `cost_basis_observable_from` string, date-time: Cost basis can only be reconstructed from trades inside our data window. Overflow sells (no matching buy in window) are silently discarded rather than fabricated.
    - `trades_through` string, date-time: Newest trade included — the cache watermark (a later trade invalidates the cached analysis)
    - `trades_through_block_time` integer: trades_through as epoch seconds
    - `trades_through_same_second` integer: Trades at exactly trades_through_block_time that were included (block_time is second-granular; used to detect a same-second trade ingested after the compute)
    - `partial` boolean: Present (true) only when the wallet exceeded the trade analysis cap and the summary was computed from the most recent trades only.
    - `analyzed_trades` integer: Present only when partial is true — the number of most-recent trades the summary was computed from (the cap).
    - `partial_reason` string: Present only when partial is true — human-readable explanation of what the cap excludes (realized PnL for positions opened before cost_basis_observable_from; open positions limited to the analyzed window).
    - `sells_without_cost_basis` object
      - `tokens` integer: Tokens with sells beyond in-window buys; excluded from realized PnL.
      - `cost_basis` 'unknown'
    - `unknown_realizations` object: Disposals whose realized PnL is unknown (see completeness). Their consumed cost basis is reported here and excluded from every realized figure.
      - `proceeds_unattributed_sells` integer: Sells with price_status proceeds_unattributed (proceeds went to another wallet or reached nobody we can prove).
      - `tokens` integer: Tokens with at least one unknown disposal (realized_status partial or unknown).
      - `unknown_realized_cost_basis_sol` number
      - `realized_pnl` 'excluded'
  - `holding_check` object: COV-21: summary of the proven-holding check over the returned open positions (SOLANA_POSITION_HOLDING; mode off = every position unverified/not_checked).
    - `mode` 'off' | 'cache'
    - `source` 'wallet_holdings_cache', nullable
    - `verified_at` string, date-time, nullable
    - `verified` integer
    - `unverified` integer
    - `held` integer
    - `partially_reduced` integer
    - `transferred_or_disposed` integer
    - `external_inflow` integer
    - `note` string
  - `cache_hit` boolean: true when served from wallet_analyses cache
  - `computed_at` string, date-time: Always returned — when the analysis was computed (the fresh compute time on a miss, the cached row's analyzed_at on a hit)
  - `cache_age_seconds` integer: Seconds since computed_at — the source age survives a cache hit (0 on a fresh compute)
  - `cache_validation` 'head_checked' | 'unverified': Cache hits only (audit F16): head_checked = the wallet has no persisted trade newer than the analysis watermark, so the cached analysis is current; unverified = the trade-head probe failed and the unexpired cache was served flagged. A hit whose wallet traded after the watermark is never served — it is recomputed.
  - `cache_invalidated` 'new_activity': Present on a recompute that happened because a cached analysis existed but the wallet traded after it
  - `ttl_seconds` integer: Only present on cache misses — TTL for this row in wallet_analyses (5min / 1h / 24h based on activity)

## Other responses

- `400`: Invalid wallet address
- `401`: Missing or invalid API key, or an expired free-tier key. Authentication-stage error: no `X-RateLimit-*` headers, no `_rid`.
- `403`: Forbidden. One of: the key's tier is below this endpoint's or parameter's gate (`tier_required` with an `upgrade` block); the public demo key used outside its allow-list (`demo_key_endpoint_not_allowed`); an invalid or revoked key; an origin-restricted key called from a disallowed origin (`code` `origin_not_allowed` / `origin_required`). `X-RateLimit-*` headers are present only on `tier_required`.
- `404`: Wallet has no trades in the data window
- `429`: Too many requests. Every 429 carries `Retry-After`. A quota 429 (`rate_limit_exceeded`, with `X-RateLimit-*`), a heavy-query concurrency 429 (`error_kind` `concurrency`/`capacity`), or the per-IP authentication-failure block (30 failed auths/min; plain `error` + `retry_after_seconds`, no `X-RateLimit-*`).
- `500`: Unexpected server error from the route (`Internal server error`, with `_rid`). Safe to retry with backoff. A failed API-key lookup is a 503 `auth_backend_unavailable`, never a 500.
- `503`: The API-key or subscription lookup could not be answered (authentication stage). Retryable and not your fault: it is never counted as a failed authentication. Honour `Retry-After`; no `X-RateLimit-*`, no `_rid`.

---

[API](https://skmtc.dev/madeonsol/apis/madeonsol-api.md) · [All operations](https://skmtc.dev/madeonsol/apis/madeonsol-api/llms.txt) · [OpenAPI document](https://skmtc.dev/madeonsol/apis/madeonsol-api/revisions/96ce84657f55?raw)
