> ## 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 Real-Time Holders tab with Balance and Bundle data

This is the advanced version of the holder table. The basic guide uses one stream. Here you merge three live streams into a single view, the holder stream for positions, the balance stream for each wallet native SOL balance, and the bundle stream to flag wallets that entered together in a coordinated bundle. This is what powers a full Axiom style holders tab.

<Card title="CabalSpy/kol-realtime-holder-table" icon="github" href="https://github.com/CabalSpy/kol-realtime-holder-table">
  The full styled demo is open source. This guide rebuilds its multi stream core in TypeScript.
</Card>

<Tip>
  If you only need positions, start with the basic holder table guide first. Add balance and bundles once that works, the merge below is layered on top of the same store.
</Tip>

## Why three streams

Each stream answers a different question about the same wallet, and they all key on the wallet address, which is what makes the merge clean.

| Stream  | What it adds to a row                                                         | Event                                  |
| ------- | ----------------------------------------------------------------------------- | -------------------------------------- |
| holder  | Position, bag percentage, unrealized PnL, remaining value                     | init, holder\_update, position\_update |
| balance | The wallet current native SOL balance and its previous value                  | init, balance\_update                  |
| bundle  | Whether the wallet entered in a coordinated bundle, and the bundle confidence | init, kol\_bundle                      |

The holder stream is the backbone, it decides which rows exist. The other two decorate those rows. A balance or bundle update for a wallet you are not holding is simply ignored.

## Architecture

Three subscriptions write into one store keyed by wallet. The render reads the merged rows.

```text theme={null}
  holder stream  --init / holder_update / position_update-->  position, bag, uPNL
  balance stream --init / balance_update-------------------->  native SOL balance
  bundle stream  --init / kol_bundle------------------------>  bundled? confidence
                                   |
                                   v
                     MergedStore  (Map keyed by wallet)
                                   |
                                   v
                         renderTable(rows[])
```

## Step 1, the merged row type

One row holds everything about a wallet. Position fields come from the holder stream, balance from the balance stream, and the bundle fields from the bundle stream. The balance and bundle fields are optional, a row is valid with position alone.

```typescript src/types.ts theme={null}
export interface Profile {
  name: string;
  image_url: string;
  twitter: string;
  telegram: string;
  type: string;
}

export interface Position {
  held: number;
  bag_pct: number;
  supply_pct: number;
  remaining_usd: number;
  unrealized_pnl_usd: number;
  unrealized_pnl_pct: number;
}

export interface MergedRow {
  wallet: string;
  wallet_type: string;
  profile: Profile;
  position: Position;

  // From the balance stream (optional until a balance arrives)
  balanceSol?: number;
  balanceUsd?: number;

  // From the bundle stream (optional until a bundle is detected)
  bundled?: boolean;
  bundleConfidence?: string;
}
```

## Step 2, the merged store

The store keeps one Map keyed by wallet. Each stream has its own writer, and every writer only touches the fields it owns. This is the key idea, the holder writer never overwrites a balance, and the balance writer never creates a row on its own.

```typescript src/store.ts theme={null}
import type { MergedRow, Profile, Position } from './types';

interface HolderInput {
  wallet: string;
  wallet_type: string;
  profile: Profile;
  position: Position;
}

export class MergedStore {
  private rows = new Map<string, MergedRow>();

  /** holder stream owns which rows exist. Replace the whole set on snapshot. */
  setHolders(holders: HolderInput[]): void {
    const next = new Map<string, MergedRow>();
    for (const h of holders) {
      const existing = this.rows.get(h.wallet);
      next.set(h.wallet, {
        wallet: h.wallet,
        wallet_type: h.wallet_type,
        profile: h.profile,
        position: h.position,
        // Preserve balance and bundle decorations across a snapshot replace
        balanceSol: existing?.balanceSol,
        balanceUsd: existing?.balanceUsd,
        bundled: existing?.bundled,
        bundleConfidence: existing?.bundleConfidence,
      });
    }
    this.rows = next;
  }

  /** holder stream, a single wallet traded. */
  upsertHolder(h: HolderInput): void {
    const existing = this.rows.get(h.wallet);
    this.rows.set(h.wallet, {
      ...existing,
      wallet: h.wallet,
      wallet_type: h.wallet_type,
      profile: h.profile,
      position: h.position,
    } as MergedRow);
  }

  /** balance stream, decorate an existing row only. */
  setBalance(wallet: string, sol: number, usd: number): void {
    const row = this.rows.get(wallet);
    if (!row) return; // not a holder we track, ignore
    row.balanceSol = sol;
    row.balanceUsd = usd;
  }

  /** bundle stream, flag the wallets that came in via a bundle. */
  setBundled(wallets: string[], confidence: string): void {
    for (const w of wallets) {
      const row = this.rows.get(w);
      if (row) { row.bundled = true; row.bundleConfidence = confidence; }
    }
  }

  list(): MergedRow[] {
    return [...this.rows.values()].sort((a, b) => b.position.remaining_usd - a.position.remaining_usd);
  }

  get count(): number { return this.rows.size; }
}
```

## Step 3, a small multi subscribe helper

You open one WebSocket and send several subscribe messages on it, one per stream. This helper opens the socket, sends every subscription on open, and routes each message to your handler.

```typescript src/stream.ts theme={null}
export interface Sub { stream: string; [key: string]: unknown; }

export function connectStreams(
  apiKey: string,
  subs: Sub[],
  onMessage: (msg: any) => void,
): () => void {
  const ws = new WebSocket(`wss://stream.cabalspy.xyz?apiKey=${encodeURIComponent(apiKey)}`);

  ws.onopen = () => {
    for (const sub of subs) ws.send(JSON.stringify({ op: 'subscribe', ...sub }));
  };
  ws.onmessage = (e) => {
    try { onMessage(JSON.parse(e.data)); } catch { /* ignore malformed frames */ }
  };

  return () => { try { ws.close(); } catch { /* already closing */ } };
}
```

## Step 4, route each stream to the store

Subscribe to all three streams for the same token, then send each event to the writer that owns it. Note the channel prefix on each message tells you which stream it came from, which is how you route balance vs holder vs bundle.

```typescript src/holders.ts theme={null}
import { MergedStore } from './store';
import { connectStreams } from './stream';
import type { MergedRow } from './types';

export function startAdvancedHolderTable(
  apiKey: string,
  mint: string,
  render: (rows: MergedRow[], count: number) => void,
): () => void {
  const store = new MergedStore();

  const close = connectStreams(apiKey, [
    { stream: 'holder', token: mint, wallet_types: ['kol', 'smart', 'whale'], mode: 'full' },
    { stream: 'balance', wallet: '*', wallet_types: ['kol', 'smart', 'whale'] },
    { stream: 'bundle', token: mint, mode: 'full' },
  ], (msg) => {
    const channel: string = msg.channel || '';

    // ── holder stream ──
    if (channel.startsWith('holder')) {
      if (msg.event === 'init' || msg.event === 'position_update') {
        if (Array.isArray(msg.data.holders)) store.setHolders(msg.data.holders);
      } else if (msg.event === 'holder_update' && msg.data.holder) {
        store.upsertHolder(msg.data.holder);
      }
    }

    // ── balance stream ──
    else if (channel.startsWith('balance')) {
      if (msg.event === 'balance_update') {
        store.setBalance(msg.data.wallet, msg.data.balance.amount, msg.data.balance.amount_usd);
      } else if (msg.event === 'init' && Array.isArray(msg.data.balances)) {
        for (const b of msg.data.balances) store.setBalance(b.wallet, b.balance.amount, b.balance.amount_usd);
      }
    }

    // ── bundle stream ──
    else if (channel.startsWith('bundle')) {
      if (msg.event === 'init' || msg.event === 'kol_bundle') {
        for (const bundle of msg.data.bundles ?? []) {
          const wallets = (bundle.bundle_wallets ?? []).map((w: any) => w.address);
          store.setBundled(wallets, bundle.confidence);
        }
      }
    }

    render(store.list(), store.count);
  });

  return close;
}
```

## Step 5, render the merged row

Now each row can show the position from the holder stream, the native SOL balance from the balance stream, and a bundle flag from the bundle stream, all live.

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

const usd = (v?: number) => '$' + (Number(v) || 0).toLocaleString('en-US', { maximumFractionDigits: 2 });
const pct = (v?: number) => (Number(v) || 0).toFixed(2) + '%';

export function renderTable(rows: MergedRow[], count: number): void {
  const countEl = document.querySelector('#holder-count');
  if (countEl) countEl.textContent = String(count);

  const body = document.querySelector('#holder-body');
  if (!body) return;

  body.innerHTML = rows.map((r) => {
    const up = r.position.unrealized_pnl_usd >= 0;
    const bundleTag = r.bundled ? ` [bundle ${r.bundleConfidence ?? ''}]` : '';
    const balance = r.balanceSol !== undefined ? `${r.balanceSol.toFixed(2)} SOL` : '-';
    return `
      <tr>
        <td>${r.profile.name || r.wallet.slice(0, 4)}${bundleTag}</td>
        <td>${r.wallet_type}</td>
        <td>${usd(r.position.remaining_usd)}</td>
        <td>${pct(r.position.bag_pct)}</td>
        <td>${balance}</td>
        <td style="color:${up ? 'green' : 'red'}">${usd(r.position.unrealized_pnl_usd)} (${pct(r.position.unrealized_pnl_pct)})</td>
      </tr>`;
  }).join('');
}
```

## Step 6, wire it together

```typescript src/main.ts theme={null}
import { startAdvancedHolderTable } from './holders';
import { renderTable } from './render';

const API_KEY = import.meta.env.VITE_CABALSPY_KEY as string;
const MINT = '3Pkrq4MmLvDXyn1fa3sz5MekKRkZkc1iLDcz5AzWpump';

const stop = startAdvancedHolderTable(API_KEY, MINT, renderTable);
// Call stop() when leaving the page to close the connection.
```

```html index.html theme={null}
<h2>Holders (<span id="holder-count">0</span>)</h2>
<table>
  <thead>
    <tr><th>Trader</th><th>Type</th><th>Value</th><th>Bag</th><th>Balance</th><th>Unrealized PnL</th></tr>
  </thead>
  <tbody id="holder-body"></tbody>
</table>
```

## How the merge stays correct

A few rules keep the three streams from fighting each other.

The holder stream owns the row set. Only holder events create or remove rows. Balance and bundle events only decorate a row that already exists, so a balance update for some unrelated wallet is harmless.

Each writer owns its own fields. The holder writer sets position, the balance writer sets balance, the bundle writer sets the bundle flag. Because they never write each other fields, the order the events arrive in does not matter.

Decorations survive a snapshot. When a holder init or position\_update replaces the row set, the store carries the existing balance and bundle values forward, so a market cap tick does not wipe the balance you already received.

## Billing note

You are now subscribed to three channels, so you receive three kinds of events, and each delivered event counts against your plan. On a busy token, use mode events on the holder and bundle streams, or set mc\_interval, to keep the market cap driven updates from using credits quickly.

## Reference

<CardGroup cols={3}>
  <Card title="Holder stream" href="/websockets/holder">
    Positions, bag percentage and unrealized PnL.
  </Card>

  <Card title="Balance stream" href="/websockets/balance">
    Native SOL balance per wallet.
  </Card>

  <Card title="Bundle stream" href="/websockets/bundle">
    Coordinated bundle detection per token.
  </Card>
</CardGroup>
