> ## Documentation Index
> Fetch the complete documentation index at: https://docs.0xinsider.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Portfolio tracker

> Read P&L and open positions for a wallet list while keeping missing and stale values visible.

Build a table of wallets with their grades, profit and loss (P\&L), and the timestamps behind those figures. Then read their open positions and calculate a subtotal only for wallets with known P\&L.

You need a [Pro API key](/authentication) and Python with `requests` installed. Run `python -m pip install requests` if needed. The snippets form one script and share the variables from step 1.

<span id="1-call-the-api-so-a-failure-is-a-failure" />

## 1. Set up the API helper

The helper stops on permanent failures, including a bad credential or lapsed subscription. It retries `408`, `429`, `502`, `503`, and `504` up to 4 attempts. A monthly quota failure stops immediately.

```python theme={null}
import os
import time
from decimal import Decimal

import requests

BASE = "https://api.0xinsider.com/api/v1"
H = {"Authorization": f"Bearer {os.environ['OXINSIDER_API_KEY']}"}
RETRYABLE = {408, 429, 502, 503, 504}


def call(method, path, **kwargs):
    for attempt in range(4):
        response = requests.request(method, f"{BASE}{path}", headers=H, timeout=35, **kwargs)
        if response.ok:
            return response.json(parse_float=Decimal)
        error = response.json()["error"]
        detail = f'{response.status_code} {error["code"]} {error.get("reason") or ""} {error["message"]}'
        if response.status_code not in RETRYABLE or error.get("reason") == "monthly_quota_exceeded":
            raise RuntimeError(detail)
        if attempt == 3:
            raise RuntimeError(f"{method} {path} still failing: {detail}")
        retry_after = response.headers.get("Retry-After")
        time.sleep(int(retry_after) if retry_after else 2**attempt)
```

`parse_float=Decimal` preserves decimal JSON values for the subtotal. Round only when printing. Connection failures raise from `requests`, so an unreachable API never looks like an empty portfolio.

Repeating `POST /traders/batch` after a timeout is safe because it only reads data. Do not reuse this retry helper for a write that lacks an idempotency key.

<span id="2-read-profit-and-loss-25-wallets-per-call" />

## 2. Read wallets in groups of 25

[Batch traders](/api-reference/endpoint/batch-get-traders) accepts up to 25 wallet addresses, usernames, or `trd_` IDs per request. Ask for `expand: ["trust"]` to receive the timestamps used below.

```python theme={null}
wallets = [
    "0x204f72f35326db932158cba6adff0b9a1da95e14",
    # Add other complete wallet addresses here.
]

book = []
for start in range(0, len(wallets), 25):
    batch = call("POST", "/traders/batch", json={"traders": wallets[start:start + 25], "expand": ["trust"]})
    for item in batch["data"]:
        if item["status"] != "ok":
            book.append({"wallet": item["input"], "error": item["error"]["message"]})
            continue
        t = item["data"]
        pnl = t["pnl"]
        freshness = t["trust"]["total_pnl"]["freshness"]
        book.append({
            "wallet": t["address"],
            "name": t.get("username"),
            "grade": t.get("grade"),
            "sync_status": t.get("sync_status"),
            "total": pnl.get("total"),
            "realized": pnl.get("realized"),
            "unrealized": pnl.get("unrealized"),
            "pnl_freshness": freshness["status"],
            "pnl_as_of": freshness.get("as_of"),
        })
```

Check each item's `status` before using its `data`. A failed input does not fail the other inputs. Optional fields are omitted from the JSON, so read them with `.get()`.

| Field | Meaning when missing |
| - | - |
| `pnl.total`, `pnl.unrealized` | The wallet has no synced value for that figure. |
| `pnl.realized` | No matching realized P\&L figure is available. Total P\&L is not a substitute. |
| `username`, `grade` | The name or grade is unavailable. |
| `trust.total_pnl.freshness.as_of` | The figure has no sync timestamp. |

Do not use `pnl.last_7d` or `pnl.last_30d`. They are deprecated and are not returned.

<span id="3-sum-only-what-is-known-and-say-what-is-not" />

## 3. Separate the subtotal from missing data

Calculate the subtotal from wallets with a known `pnl.total`. Keep a list of wallets with no figure and a separate list whose timestamp exceeds your freshness limit.

```python theme={null}
from datetime import datetime, timedelta, timezone

MAX_AGE = timedelta(hours=24)
now = datetime.now(timezone.utc)

known = [b for b in book if b.get("total") is not None]
missing = [b for b in book if b.get("total") is None]
stale = [
    b for b in known
    if b["pnl_as_of"] is None
    or now - datetime.fromisoformat(b["pnl_as_of"].replace("Z", "+00:00")) > MAX_AGE
]

subtotal = sum((b["total"] for b in known), Decimal("0"))
if missing:
    print(f"P&L for {len(known)} of {len(book)} wallets: ${subtotal:,.0f}. Book total unavailable.")
    for b in missing:
        print("  no P&L:", b["wallet"], b.get("error") or b.get("sync_status"))
else:
    print(f"Book total: ${subtotal:,.0f} across {len(book)} wallets.")
for b in stale:
    print("  older than 24h or unstamped:", b["wallet"], b["pnl_as_of"])
```

The 24-hour maximum age is an application choice. On this trader field, `freshness.status: "fresh"` says a sync timestamp exists; it does not impose an age limit. Check `as_of` yourself.

A subtotal can include old figures, so print the stale list beside it. If any wallet lacks P\&L, the script reports a covered subtotal rather than claiming a total for the whole portfolio.

<span id="a-wallet-with-sync_status-unknown" />

### Handle an untracked wallet

`sync_status: "unknown"` means 0xinsider does not track the wallet. Reading it does not start tracking it. Wallets enter the tracked set through 0xinsider's own large-trade, large-position, and leaderboard data.

Keep an untracked wallet in the missing list and check it again on the next scheduled run. Faster polling does not change that status.

<span id="4-read-the-open-positions" />

## 4. Read open positions

[Positions](/api-reference/endpoint/get-positions) accepts up to 25 repeated `wallet` parameters. The example fetches every page for each group.

```python theme={null}
def positions_for_book(min_size=0):
    held = []
    for start in range(0, len(wallets), 25):
        cursor = None
        while True:
            params = {"wallet": wallets[start:start + 25], "limit": 100, "min_size": min_size}
            if cursor:
                params["cursor"] = cursor
            page = call("GET", "/positions", params=params)
            held += page["data"]
            if not page["has_more"]:
                break
            cursor = page["next_cursor"]
    return held


positions = positions_for_book()
if not positions:
    print(f"No open position in this book of {len(wallets)}.")
for p in positions:
    print(
        p["wallet"][:10], p["market"]["title"], p["side"],
        p["market"].get("outcome_label"), p["shares"], p.get("avg_price"),
        f'${p["current_value_usd"]:,.0f}', p.get("cash_pnl"), p["freshness"], p.get("last_reconciled_at"),
    )
```

`requests` sends a list value as repeated query parameters, so `wallets[start:start + 25]` becomes one `wallet=` per address.

| Parameter | What it changes |
| - | - |
| `wallet` | Selects up to 25 addresses, usernames, or `trd_` IDs. |
| `min_size` | Sets a current USD value floor. It defaults to 0 when `wallet` is supplied. |
| `min_grade` | Keeps the chosen grade or better. Omit it to retain ungraded wallets in your list. |
| `side` | Filters by `yes` or `no`, in lowercase. |
| `limit` | Sets up to 100 positions per page. |

An unseen address returns any recorded rows or an empty list. An unresolved username or `trd_` ID returns `404` with `error.param: "wallet"`; fix the input rather than retrying it.

| Response field | What it tells you |
| - | - |
| `side` | `YES` or `NO`, in uppercase. |
| `market.outcome_label` | Polymarket's outcome label, when available. |
| `shares`, `current_value_usd` | The share count and valued position size. Unvalued positions are excluded. |
| `freshness` | `fresh`, `refreshing`, `stale`, or `unknown`, based on `last_reconciled_at`. |
| `avg_price`, `cash_pnl`, `realized_pnl`, `initial_value_usd`, `last_reconciled_at` | Optional fields available after reconciliation with Polymarket. |
| `trader.username`, `trader.grade` | Optional identity and grade fields. |

Show `freshness` and `last_reconciled_at` alongside each position value. Missing reconciliation fields should remain visibly unavailable.

<span id="5-set-the-refresh-interval" />

## 5. Choose a refresh interval

The account has a 100-request-per-minute limit and an included quota of 500,000 requests per UTC month on Pro or 2,000,000 on Max. Batch requests also use a 2,500-item-per-minute limit. All keys on the account share these budgets; see [Rate limits](/rate-limits).

Each group of 25 wallets costs 1 batch request, plus 1 positions request per page. The positions page contains up to 100 rows. A group with no positions still needs its first request.

Refresh every 5 or 15 minutes unless your application needs another interval. P\&L changes when a new sync is available, and the timestamp tells you when that happened. Raise `min_size` if you want fewer small positions and fewer pages.

The positions route supports `ETag`. If you add conditional requests, handle `304` before parsing the body because it has no body; the helper above is for full responses.

<span id="what-this-does-not-do" />

## Limits

* P\&L is the last synced figure, not a live wallet balance.
* Requests do not start tracking a wallet or move funds.
* Positions cover open, valued positions with `YES` and `NO` legs. They exclude closed positions and markets with more than 2 outcomes.
* An empty positions result means no matching valued positions were returned. It does not establish that the wallet holds nothing.
* A portfolio with missing P\&L has a covered subtotal, not a complete total.

Use [Position timeline](/api-reference/endpoint/get-position-timeline) to inspect stored fills for one position.


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