# Building a Trader Leaderboard with the Users API

Use the eToro Users API to search for traders, pull performance data, and rank them in a real-time leaderboard.

---


Ranked lists of traders are a staple of social investing products: search, score, sort, refresh. This deep dive focuses on the **Users API**—how to **discover** accounts, pull **performance** and **portfolio** snapshots, enrich rows with **Pro Investor (PI)** metadata, and expose a **sorted leaderboard** that you can poll or cache behind your own API.

Base URL for all snippets: `https://public-api.etoro.com/api/v1/`. Include `x-api-key` on every request, `x-user-key` when the endpoint is user-scoped, and a fresh `x-request-id` (UUID) for observability.

## Searching for traders

Most leaderboards start with a **search** or **discovery** call rather than a hard-coded ID list. Pass a query string, optional filters (region, asset class, max risk), and paginate with `page`/`pageSize` or cursor fields as your portal documents.

<!-- skip-test -->
```javascript
import { randomUUID } from "node:crypto";

const BASE = "https://public-api.etoro.com/api/v1";

function usersHeaders() {
  return {
    "x-api-key": process.env.ETORO_API_KEY,
    "x-user-key": process.env.ETORO_USER_KEY ?? "",
    "x-request-id": randomUUID(),
    accept: "application/json",
  };
}

async function searchTraders({ query, page = 1, pageSize = 25 }) {
  const qs = new URLSearchParams({
    q: query,
    page: String(page),
    pageSize: String(pageSize),
  });

  const res = await fetch(`${BASE}/users/search?${qs}`, { headers: usersHeaders() });
  if (!res.ok) throw new Error(`search failed: ${res.status}`);
  const data = await res.json();
  const payload = data.data ?? data;
  return payload.items.map((u) => ({ id: u.userId, username: u.username }));
}
```

De-duplicate results by `userId` before you fan out into heavier endpoints.

## Fetching performance metrics

For each candidate, request **performance** metrics for the windows you want to show (for example 12 months and all-time). Normalize percentages to numbers and keep raw payloads if you need to audit disputes later.

<!-- skip-test -->
```javascript
async function getPerformance(userId) {
  const res = await fetch(
    `${BASE}/users/${userId}/performance?windows=12m,all`,
    { headers: usersHeaders() }
  );
  if (!res.ok) throw new Error(`performance ${userId}: ${res.status}`);
  const data = await res.json();
  const payload = data.data ?? data;
  return {
    return12m: payload.windows["12m"].returnPercent,
    returnAll: payload.windows.all.returnPercent,
    maxDrawdown: payload.windows["12m"].maxDrawdownPercent,
    riskScore: payload.riskScore,
  };
}
```

## Retrieving portfolio summaries

A leaderboard entry is more credible when you show **diversification** or **top holdings**, not just return. Call a **portfolio** or **allocation** endpoint to get weights by instrument or sector. Keep the call optional if your UI tolerates missing data when the market is closed.

<!-- skip-test -->
```javascript
async function getPortfolioSummary(userId) {
  const res = await fetch(`${BASE}/users/${userId}/portfolio`, {
    headers: usersHeaders(),
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`portfolio ${userId}: ${res.status}`);
  const data = await res.json();
  const payload = data.data ?? data;
  return {
    topPositions: payload.positions.slice(0, 5).map((p) => ({
      symbol: p.symbol,
      weight: p.weightPercent,
    })),
  };
}
```

## Pro Investor (PI) data

Pro Investors often have **badges**, **copier counts**, and **program tier** fields. Fetch PI-specific metadata in one round trip if your API exposes something like `/users/{id}/popular-investor`, and merge it into the row for display chips (“PI”, tier, AUM cap).

<!-- skip-test -->
```javascript
async function getProInvestorProfile(userId) {
  const res = await fetch(`${BASE}/pi-data/copiers/${userId}`, {
    headers: usersHeaders(),
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`PI ${userId}: ${res.status}`);
  const data = await res.json();
  const payload = data.data ?? data;
  return {
    tier: payload.tier,
    copiers: payload.activeCopiers,
    maxCopiers: payload.maxCopiers,
    badge: payload.badges?.[0]?.label,
  };
}
```

## Composing the ranked leaderboard

Pull search hits (or a static watchlist), enrich each user in parallel with **bounded concurrency**, compute a **primary score** (here: 12-month return), and sort. Expose `lastUpdated` so clients know how fresh the ranking is.

<!-- skip-test -->
```javascript
async function mapLimit(items, limit, fn) {
  const out = [];
  for (let i = 0; i < items.length; i += limit) {
    const chunk = items.slice(i, i + limit);
    out.push(...(await Promise.all(chunk.map(fn))));
  }
  return out;
}

export async function buildTraderLeaderboard(seedQuery) {
  const traders = await searchTraders({ query: seedQuery, pageSize: 50 });

  const rows = await mapLimit(traders, 5, async (t) => {
    const [perf, pi, port] = await Promise.all([
      getPerformance(t.id),
      getProInvestorProfile(t.id),
      getPortfolioSummary(t.id),
    ]);

    return {
      userId: t.id,
      username: t.username,
      score: perf.return12m,
      metrics: perf,
      pi,
      portfolio: port,
    };
  });

  rows.sort((a, b) => b.score - a.score);

  return {
    lastUpdated: new Date().toISOString(),
    rows,
  };
}
```

## Real-time refresh without hammering the API

For a “live” feel, refresh the leaderboard on a **cron** (every few minutes) or when users open the screen, not on every keystroke. Store results in Redis or an edge cache and serve your UI from that layer. Log `x-request-id` from upstream errors so operations can trace spikes in 429 responses.

You now have an end-to-end Users API pipeline: **search → score → enrich → rank → cache**. Extend it with your own risk filters (for example exclude traders below a minimum copier count) before you display results to end users.
