CRIUS
PortfolioWallet analyticsPnLTutorial

Build a Crypto Portfolio Dashboard Using the Crius API

Build a portfolio dashboard for Robinhood Chain wallets: holdings, live valuation in one batch call, realized and unrealized PnL, gas and confidence.

Crius 7 min read

Short answer: for each wallet, call GET /v1/robinhood/wallet/{address} to get its token holdings, ETH balance and PnL; merge positions across wallets, then value them all at the same block with one POST /v1/robinhood/prices call for up to 100 tokens. In our test this valued three wallets and 16 positions ($114,660 in total) in four requests.

This guide builds a multi-wallet portfolio view in Node.js and covers the details that make portfolio numbers trustworthy: consistent pricing, dust filtering, cost-basis coverage and price confidence. It ran against the live API in October 2026.

The wallet endpoint

Terminal window
curl "https://api.crius.sh/v1/robinhood/wallet/0x48e2e6dd7ad6d9ac1f090172662d5389b4fa8c1c?window=30d&min_value_usd=1" \
-H "x-api-key: $CRIUS_API_KEY"
Parameter Values
window 1d, 7d, 30d (default) or all: the period for PnL statistics
min_value_usd drop holdings worth less than this (airdropped dust, spam tokens)

The response has four parts:

{
"address": "0xc56e22b48c421e08d26bad1bd0676832412a8b41",
"native_balance": 1.72e-7, "native_value_usd": 0.0004,
"holdings": [
{ "token": { "address": "0x1319…7ccb", "symbol": "MEMECHAN" }, "balance": 1129.87,
"value_usd": 0.0373, "value_eth": 1.58e-5, "price_confidence": "low",
"pnl": null }
],
"holdings_value_usd": 0.0532,
"pnl": {
"window": "30d", "trades": 42, "volume_usd": 40570.8,
"realized_usd": 30511.4, "unrealized_usd": 0, "total_usd": 30511.4,
"win_rate_pct": 100, "avg_hold_time_s": 496984, "gas_spent_usd": 0.343,
"tokens_traded": 3, "tokens_with_known_cost": 2,
"best_trade": { "symbol": "GIGATENEV", "pnl_usd": 30132.7 },
"worst_trade": { "symbol": "SI", "pnl_usd": 378.649 }
},
"profile": {
"wallet_age_days": 50.8, "tx_count": 1043, "fresh": false,
"funded_by": { "address": "0x776c…5ade", "amount_eth": 0.0322 }
}
}

That wallet is a real one: the top trader of GIGATENEV, which turned about $1,500 into $31,600 and then sold out. Its holdings are now worth five cents.

How PnL is calculated

Crius reconstructs every position from the wallet’s on-chain trades and transfers:

  • Cost basis is the USD value of each buy at the block it happened. Average entry is the cost-weighted price of the tokens still held.
  • Realized PnL comes from sells: proceeds minus the cost basis of the tokens sold.
  • Unrealized PnL is the current value of what’s still held, minus its cost basis.
  • Gas is the total fee for every transaction the wallet sent in the window, reported separately so you can choose whether to net it out.

A position’s cost is only known if the wallet acquired the tokens by trading. Tokens received by transfer (an airdrop, or a move from another of your wallets) have no purchase price, so their pnl is null, like the MEMECHAN dust above. When the cost is known, a holding’s pnl has realized_usd, unrealized_usd and avg_entry_usd. tokens_with_known_cost vs tokens_traded tells you how complete the PnL picture is.

Building the dashboard

const API = "https://api.crius.sh/v1/robinhood";
const KEY = process.env.CRIUS_API_KEY;
const WALLETS = [
"0x48e2e6dd7ad6d9ac1f090172662d5389b4fa8c1c",
"0x2c10013678aafca9d90e914ba02f087bf449446e",
"0xc56e22b48c421e08d26bad1bd0676832412a8b41",
];
const req = async (path, body) => {
const r = await fetch(`${API}${path}`, {
method: body ? "POST" : "GET",
headers: { "x-api-key": KEY, ...(body && { "content-type": "application/json" }) },
body: body && JSON.stringify(body),
});
if (!r.ok) throw new Error(`${path}: ${r.status} ${await r.text()}`);
return r.json();
};
// 1. Holdings and 30-day PnL for every wallet, positions under $1 dropped server-side.
const wallets = await Promise.all(WALLETS.map((w) => req(`/wallet/${w}?window=30d&min_value_usd=1`)));
// 2. Merge positions by token.
const positions = new Map();
let native = 0;
for (const { data: w } of wallets) {
native += w.native_value_usd ?? 0;
for (const h of w.holdings) {
const p = positions.get(h.token.address) ?? { symbol: h.token.symbol, balance: 0, realized: 0, unrealized: 0, costKnown: true };
p.balance += h.balance;
if (h.pnl) { p.realized += h.pnl.realized_usd; p.unrealized += h.pnl.unrealized_usd; } else p.costKnown = false;
positions.set(h.token.address, p);
}
}
// 3. One batch call prices every position at the same block.
const tokens = [...positions.keys()];
const prices = await req("/prices", { tokens: tokens.slice(0, 100) });
let total = native;
const table = tokens.map((t) => {
const p = positions.get(t), q = prices.data[t];
const value = q ? p.balance * q.usd : 0;
total += value;
return {
symbol: p.symbol, balance: +p.balance.toPrecision(6), price: q?.usd ?? null,
value_usd: Math.round(value * 100) / 100, chg_24h: q?.change_24h_pct?.toFixed(1) ?? "",
confidence: q?.confidence ?? "none", pnl_usd: p.costKnown ? Math.round(p.realized + p.unrealized) : "n/a",
};
}).sort((a, b) => b.value_usd - a.value_usd);
const pnl = wallets.reduce((s, { data: w }) => ({
realized: s.realized + (w.pnl?.realized_usd ?? 0),
unrealized: s.unrealized + (w.pnl?.unrealized_usd ?? 0),
gas: s.gas + (w.pnl?.gas_spent_usd ?? 0),
}), { realized: 0, unrealized: 0, gas: 0 });
console.log(`${WALLETS.length} wallets · ${tokens.length} positions · value $${total.toFixed(2)} · priced at block ${prices.meta.block}`);
console.log(`30d PnL: realized $${pnl.realized.toFixed(0)}, unrealized $${pnl.unrealized.toFixed(0)}, gas $${pnl.gas.toFixed(2)}`);
console.table(table.slice(0, 8));

Output from our run:

3 wallets · 16 positions · value $114659.58 (incl. $87.70 ETH) · priced at block 96715657
30d PnL: realized $12485, unrealized $19287, gas $4.48
symbol balance price value_usd chg_24h confidence pnl_usd
CREORA 18780700 0.00135048 25362.95 23.3 high 6087
GIGATENEV 21548700 0.00115246 24833.98 5.4 high 5174
HYDX 11949700 0.00186791 22320.91 -14.9 high 3760
BORRO 34066500 0.000566472 19297.72 7.1 high 4872
FTR 4444 2.82991 12576.12 low n/a
HEDGE 5290540 0.000936389 4954 -5.6 high 7525
LOUD 22592200 0.000156109 3526.85 2281.4 high 232
Parabol 56010700 0.0000224101 1255.21 -4.2 high -2950

Why price everything in one call

The wallet endpoint already returns value_usd per holding, so why call /prices at all? Two reasons:

  1. Consistency. Three wallet calls run at slightly different moments. One /prices call values every position at the same block, so the total is a true snapshot and the response’s meta.block tells you which.
  2. Refreshing. Holdings change only when the wallet trades; prices change every block. Fetch holdings every few minutes, or update them from the wallet:<address> stream, and refresh prices every few seconds with a single cheap request.

Read the confidence field

Look at FTR in the table above: $12,576, the fifth-largest position, priced with low confidence and no 24-hour change. Confidence reflects how much real liquidity stands behind the price:

Confidence Meaning What to do
high Deep, actively traded pool Use as is
medium Usable but thinner liquidity Show, maybe with a marker
low Thin or stale pool; the last trade may not reflect what you could sell for Show separately, exclude from the headline total, or value at zero

A portfolio tool that adds low-confidence positions into its headline number overstates wealth; that FTR position could not be sold for anything near $12,500. A good default is two totals: “verified value” from high and medium confidence, and “including unverified” with everything.

Also watch for symbol collisions. One of the wallets in our test held a token with the symbol USDG that is not the real USDG stablecoin; always key positions by contract address, never by symbol.

Live updates and alerts

Polling every wallet every few seconds is wasteful when holdings change only when the wallet acts. Subscribe to wallet:<address> on the WebSocket API for each wallet in the portfolio. Every trade and token transfer arrives as it happens, so you can update the position in memory and refetch the wallet’s PnL only when something actually changed.

The same stream drives alerts:

  • Large moves out. A transfer of more than a set share of a position to an address that isn’t one of yours.
  • New tokens in. Unsolicited tokens are often spam or phishing bait; flag them instead of adding them to the total.
  • Price thresholds. Combine price:<token> subscriptions for your largest positions with a simple threshold check.
ws.send(JSON.stringify({ op: "subscribe", channels: WALLETS.map((w) => `wallet:${w}`) }));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.ch.startsWith("wallet:") && !m.data.removed) refreshWallet(m.ch.slice(7)); // debounce in practice
};

Add a wallet’s history

For a performance chart, the wallet endpoint’s pnl gives you totals for 1d, 7d, 30d or all. For a trade list, use GET /wallet/{address}/trades, which returns each trade with its USD value at the time, paginated:

Terminal window
curl "https://api.crius.sh/v1/robinhood/wallet/0x48e2e6dd7ad6d9ac1f090172662d5389b4fa8c1c/trades?limit=20" \
-H "x-api-key: $CRIUS_API_KEY"

Wallet analytics are part of the Starter plan and above; the free plan covers prices, trades and token data.

Frequently asked questions

Does this work for any wallet? Yes, any address on Robinhood Chain. No permission is needed because all of this is public on-chain data.

How do I include tokens received by transfer in PnL? They have no on-chain purchase price. Let users enter a cost basis manually, or value them at their price on the day they arrived using daily candles.

How often should I refresh? Prices: every few seconds with /prices, or live with price:<token> streams. Holdings: every few minutes, or instantly with the wallet:<address> stream.

What does it cost? Each wallet call is 5 credits and a /prices call is 1 credit. A three-wallet portfolio refreshed every minute uses about 23,000 credits a day.

Can I track tokenized stocks too? Yes. Tokenized stocks like TSLA and SPY are ordinary tokens on Robinhood Chain and appear in holdings with their prices.

Next steps

Questions go to support@crius.sh.

Build with Crius

Robinhood Chain data in one API

Free plan with 1M credits a month. Your key in under a minute.

Related APIs

More from the blog