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

# Trading terminal

> A token page with the live thesis wall and each author's track record.

**For:** trading terminals and token screeners.

A trader opening a token sees why other traders bought it: the written thesis,
the size of the trade, the author's position PnL, and the author's realized
profit over the last 30 days. New theses land on the page as traders publish
them.

## How it works

```mermaid theme={null}
flowchart LR
  A[FomoScan API] -- REST snapshot --> B[Your backend]
  A -- /v2/ws theses --> B
  B -- Server-sent events --> C[Token page]
```

1. **Snapshot.** When a user opens a token, the backend reads
   `GET /v2/thesis/token/{tokenAddress}` for the newest page.
2. **Live.** One backend socket on `/v2/ws` subscribes to the tokens users
   have open, up to 50, and fans each `thesis` frame out to their browsers.
3. **Track record.** The backend asks `POST /v2/user/handles/pnl` for the
   authors on screen, in one call, and caches the answer.

The key stays on the backend. Browsers cannot send the `Authorization` header
on a WebSocket.

## Starter code

Node 18 or later, with `express` and `ws`.

```js server.js theme={null}
import express from "express";
import WebSocket from "ws";

const API = "https://api.fomoscan.sh";
const headers = { Authorization: `Bearer ${process.env.FOMOSCAN_KEY}` };
const viewers = new Map();
const pnlCache = new Map();
let socket;

async function api(path, init = {}) {
  const res = await fetch(API + path, { ...init, headers: { ...headers, ...init.headers } });
  if (!res.ok) throw new Error(`${res.status} ${path}`);
  return res.json();
}

function subscribe() {
  if (socket?.readyState !== WebSocket.OPEN) return;
  const tokens = [...viewers.keys()].slice(0, 50);
  const frame = tokens.length
    ? { type: "subscribe", subscription: { type: "tokens", tokens } }
    : { type: "unsubscribe" };
  socket.send(JSON.stringify(frame));
}

function connect() {
  socket = new WebSocket(`${API.replace("https", "wss")}/v2/ws`, { headers });
  socket.on("open", subscribe);
  socket.on("message", (raw) => {
    const frame = JSON.parse(raw.toString());
    if (frame.type !== "thesis") return;
    for (const res of viewers.get(frame.data.tokenAddress) ?? []) {
      res.write(`data: ${JSON.stringify(frame.data)}\n\n`);
    }
  });
  socket.on("error", (err) => console.error(err.message));
  socket.on("close", (code) => {
    if (code !== 1008) setTimeout(connect, 2000);
  });
}

const app = express();
app.use(express.json());
app.use(express.static("public"));

app.get("/token/:address", async (req, res) => {
  const page = await api(`/v2/thesis/token/${req.params.address}?limit=50`);
  res.json(page);
});

app.get("/token/:address/live", (req, res) => {
  const key = req.query.key;
  res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  res.flushHeaders();
  if (!viewers.has(key)) viewers.set(key, new Set());
  viewers.get(key).add(res);
  subscribe();
  req.on("close", () => {
    viewers.get(key).delete(res);
    if (viewers.get(key).size === 0) viewers.delete(key);
    subscribe();
  });
});

app.post("/authors/pnl", async (req, res) => {
  const fresh = Date.now() - 10 * 60_000;
  const cached = (h) => pnlCache.get(h.toLowerCase());
  const wanted = req.body.handles.filter((h) => !(cached(h)?.at > fresh)).slice(0, 100);
  if (wanted.length) {
    const { entries } = await api("/v2/user/handles/pnl", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ handles: wanted }),
    });
    for (const e of entries) {
      if (e.status === "ready") pnlCache.set(e.handle.toLowerCase(), { at: Date.now(), entry: e });
    }
  }
  res.json(Object.fromEntries(req.body.handles.map((h) => [h, cached(h)?.entry ?? null])));
});

connect();
app.listen(3000);
```

```html public/index.html theme={null}
<input id="token" placeholder="Token address" />
<ul id="wall"></ul>
<script>
  const seen = new Set();
  const wall = document.getElementById("wall");
  let live;

  function render(t, top) {
    const key = t.thesisHash ?? t.id;
    if (seen.has(key)) return;
    seen.add(key);
    const li = document.createElement("li");
    li.dataset.author = t.authorHandle;
    li.textContent = `@${t.authorHandle}: ${t.thesis}`;
    top ? wall.prepend(li) : wall.append(li);
  }

  async function open(address) {
    wall.innerHTML = "";
    seen.clear();
    const page = await (await fetch(`/token/${address}`)).json();
    page.items.forEach((t) => render(t, false));
    live?.close();
    live = new EventSource(`/token/${address}/live?key=${page.tokenAddress}`);
    live.onmessage = (e) => render(JSON.parse(e.data), true);
    const handles = [...new Set(page.items.map((t) => t.authorHandle).filter(Boolean))];
    const pnl = await (await fetch("/authors/pnl", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ handles }),
    })).json();
    for (const li of wall.children) {
      const net = pnl[li.dataset.author]?.windows?.["30d"]?.netUsd;
      if (net != null) li.textContent += ` (30d: $${Math.round(net).toLocaleString()})`;
    }
  }

  document.getElementById("token").onchange = (e) => open(e.target.value.trim());
</script>
```

## Details that matter

* **Dedupe on `thesisHash`**, falling back to `id`. The same thesis can be
  reissued under a new id.
* **Key the socket by `tokenAddress` from the REST page.** The API lower-cases
  EVM addresses, so the page's value matches the frames.
* **Cache author PnL.** A ready entry costs 30,000 CU. Ten minutes of cache
  per author keeps a busy token page affordable. The starter code caches only
  `ready` entries, so a `pending` author is asked again on the next open.
* **Size the snapshot.** A page is priced per started block of 20 requested
  rows, so `limit=50` costs 600 CU.
* **After a drop, close the gap.** On reconnect, read the newest page again and
  let the dedupe set drop what the user already has.

> **Build it with us**
>
> We build these projects together with the teams that ship them. Tell us what
> you are building on [Telegram](https://t.me/fomoscan_support) or at
> [fomoscan-support@proton.me](mailto:fomoscan-support@proton.me) and we will
> help you scope it, size the credits and get it live.


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