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

# Build a Trader Leaderboard

> Build a ranked leaderboard of top KOL, Smart Money and Whale traders by PnL, win rate, trades or volume, with instant client side sorting. Step by step in TypeScript.

Learn how to build a ranked leaderboard of the best tracked wallets, sorted by PnL, win rate, trades or volume, across any chain. One call gets the data, then you sort and search it on the client so the UI feels instant. This guide walks it end to end in TypeScript, with no CSS.

<Card title="CabalSpy/Leaderboard-Demo" icon="github" href="https://github.com/CabalSpy/Leaderboard-Demo">
  The full styled demo is open source. This guide rebuilds its core logic from scratch.
</Card>

## What you will build

A leaderboard that loads the ranked wallets for a chain, wallet type and period, then lets the user re sort and search without another request. The key idea, fetch once, sort and filter in memory.

## The endpoint

One call returns the whole ranked list.

<Card title="GET /v1/wallet/leaderboard" href="/api-reference/wallets-leaderboard">
  Ranked wallets for a chain, wallet type and period, each with a profile, period stats and win rate.
</Card>

## Step 1, type the row

The response nests data under profile, period\_stats and a win rate object. It is easier to work with if you flatten each row into one flat object as it comes in.

```typescript src/types.ts theme={null}
export interface RawEntry {
  wallet?: string;
  profile?: { name?: string; image_url?: string; twitter?: string; currency?: string };
  period_stats?: {
    realized_pnl?: number; realized_pnl_usd?: number;
    buy_txn?: number; sell_txn?: number; total_txn?: number;
    total_volume?: number; total_volume_usd?: number;
    avg_hold_time_minutes?: number;
  };
  period_win_rate_distribution?: { win_rate_percentage?: number };
}

export interface Entry {
  wallet: string;
  name: string; image_url: string; twitter: string; currency: string;
  pnlUsd: number; pnlNative: number;
  winRate: number;
  trades: number; buys: number; sells: number;
  volumeUsd: number;
  avgHoldMinutes: number;
}

export type SortKey = 'pnl' | 'winRate' | 'trades' | 'volume' | 'hold';
```

## Step 2, fetch and flatten

Give it a chain, a wallet type and a period. Flatten each raw row into the clean Entry shape as you map over the list, so the rest of the app never touches the nested response.

```typescript src/leaderboard.ts theme={null}
import type { RawEntry, Entry } from './types';

const API_BASE = 'https://api.cabalspy.xyz/v1';
const n = (v: unknown) => Number(v) || 0;

function flatten(raw: RawEntry): Entry {
  const p = raw.profile ?? {};
  const s = raw.period_stats ?? {};
  const w = raw.period_win_rate_distribution ?? {};
  return {
    wallet: raw.wallet ?? '',
    name: p.name ?? '', image_url: p.image_url ?? '', twitter: p.twitter ?? '', currency: p.currency ?? 'SOL',
    pnlUsd: n(s.realized_pnl_usd), pnlNative: n(s.realized_pnl),
    winRate: n(w.win_rate_percentage),
    trades: n(s.total_txn), buys: n(s.buy_txn), sells: n(s.sell_txn),
    volumeUsd: n(s.total_volume_usd),
    avgHoldMinutes: n(s.avg_hold_time_minutes),
  };
}

export async function fetchLeaderboard(blockchain: string, type: string, period: string): Promise<Entry[]> {
  const params = new URLSearchParams({
    blockchain, type, period,
    api_key: import.meta.env.VITE_CABALSPY_KEY as string,
  });
  const res = await fetch(`${API_BASE}/wallet/leaderboard?${params}`);
  const body = await res.json();
  if (!body.success) throw new Error(body.error?.message || 'leaderboard failed');
  return (body.data.leaderboard ?? []).map(flatten);
}
```

## Step 3, sort and search in memory

This is what makes the leaderboard feel fast. Once you have the rows, sorting and searching never hit the network, they run on the array you already have.

```typescript src/view.ts theme={null}
import type { Entry, SortKey } from './types';

function sortValue(e: Entry, key: SortKey): number {
  switch (key) {
    case 'pnl': return e.pnlUsd;
    case 'winRate': return e.winRate;
    case 'trades': return e.trades;
    case 'volume': return e.volumeUsd;
    case 'hold': return e.avgHoldMinutes;
  }
}

export function applyView(
  rows: Entry[],
  sortBy: SortKey,
  order: 'asc' | 'desc',
  search: string,
): Entry[] {
  const q = search.trim().toLowerCase();
  const filtered = q
    ? rows.filter((e) => e.name.toLowerCase().includes(q) || e.wallet.toLowerCase().includes(q))
    : rows;

  const dir = order === 'desc' ? -1 : 1;
  return [...filtered].sort((a, b) => (sortValue(a, sortBy) - sortValue(b, sortBy)) * dir);
}
```

## Step 4, render the ranked table

Rendering is a map over the sorted rows. The rank is just the index plus one.

```typescript src/render.ts theme={null}
import type { Entry } from './types';

const usd = (v: number) => '$' + v.toLocaleString('en-US', { maximumFractionDigits: 0 });

export function renderTable(el: HTMLElement, rows: Entry[]): void {
  el.innerHTML = `
    <table>
      <thead><tr><th>#</th><th>Trader</th><th>PnL</th><th>Win Rate</th><th>Trades</th><th>Volume</th></tr></thead>
      <tbody>
        ${rows.map((e, i) => `
          <tr>
            <td>${i + 1}</td>
            <td>${e.name || e.wallet.slice(0, 4)}</td>
            <td>${usd(e.pnlUsd)}</td>
            <td>${e.winRate.toFixed(2)}%</td>
            <td>${e.trades} (${e.buys}/${e.sells})</td>
            <td>${usd(e.volumeUsd)}</td>
          </tr>`).join('')}
      </tbody>
    </table>`;
}
```

## Step 5, wire it together

Hold the loaded rows and the current view options in a little state object. Loading a new chain, type or period refetches. Changing the sort or the search only re renders, no request.

```typescript src/main.ts theme={null}
import { fetchLeaderboard } from './leaderboard';
import { applyView } from './view';
import { renderTable } from './render';
import type { Entry, SortKey } from './types';

const state = {
  chain: 'solana', type: 'kol', period: '1d',
  sortBy: 'pnl' as SortKey, order: 'desc' as 'asc' | 'desc', search: '',
  rows: [] as Entry[],
};

const table = document.querySelector('#leaderboard') as HTMLElement;

function draw() {
  renderTable(table, applyView(state.rows, state.sortBy, state.order, state.search));
}

async function load() {
  state.rows = await fetchLeaderboard(state.chain, state.type, state.period);
  draw();
}

// Sorting and search are instant, they only redraw.
document.querySelector('#sort')!.addEventListener('change', (e) => {
  state.sortBy = (e.target as HTMLSelectElement).value as SortKey;
  draw();
});
document.querySelector('#search')!.addEventListener('input', (e) => {
  state.search = (e.target as HTMLInputElement).value;
  draw();
});

load();
```

## Wallet types per chain

Not every chain has every wallet type, so match your type control to the chain. Solana has KOL, Smart Money and Whale. BNB and Base have KOL and Smart Money. ETH has KOL only. Passing a type a chain does not support returns an error, so hide the ones that do not apply.

```typescript src/chain-types.ts theme={null}
export const CHAIN_TYPES: Record<string, string[]> = {
  solana: ['kol', 'smart', 'whale'],
  bnb: ['kol', 'smart'],
  base: ['kol', 'smart'],
  eth: ['kol'],
};
```

## Lessons worth knowing

Two things make this clean.

Flatten on the way in. The response nests fields under profile and period\_stats. Flatten each row once in the fetch, and every other function works with a simple flat object.

Fetch once, view many times. Sorting and search run on the array you already loaded, so they are instant. Only a new chain, type or period needs another request. This is the difference between a snappy leaderboard and one that reloads on every click.

## Reference

<Card title="GET /v1/wallet/leaderboard" href="/api-reference/wallets-leaderboard">
  Ranked wallets for a chain, wallet type and period.
</Card>
