Welcome to API Academy

Module 13 · Data · ~26 min

PnL analysis.

By the end of this module, you can score any strategy as harshly as a real trading desk would, Sharpe, drawdown, and which trades actually made the money. Gut feel out, evidence in.

To get there, you’ll compute Sharpe, drawdown, and honest attribution. Replace gut feel with a one-page strategy report you can defend, and find out which edge is actually paying.

Data tier · Reference card
Quick answer

How do you analyze a trading bot’s PnL?

Pull your PnL series from GET /portfolio/pnl-chart (each point is a timestamp in milliseconds plus a USD value), turn it into an equity curve, and compute three numbers: annualised Sharpe, Sortino, and max drawdown. Then attribute it: fetch GET /portfolio/trades and group fills by market slug, Buy/Sell action, YES/NO side, and hour-of-day, summing outcomeTokenNetCost per bucket to see where the money actually came from. Track realised and unrealised PnL separately, the API exposes both per CLOB position as realisedPnl and unrealizedPnl, because conflating them is the fastest way to convince yourself a strategy works when it doesn’t. Finally, plot the equity curve and actually look at it: a Sharpe above 1.0 is respectable, and above 2.0 is suspicious until you’ve tripled your out-of-sample evidence.

Endpoints verified 2026-06-10 against the OpenAPI spec.

Authenticated calls carry the three HMAC headers (lmts-api-key + lmts-timestamp + lmts-signature) from Module 03; the legacy X-API-Key is deprecated.

Section 01

Realised vs unrealised.

Every position has two PnL components. Limitless exposes both for every CLOB position. Track them separately, conflating them is the fastest way to convince yourself a strategy works when it doesn’t.

Realised

Locked in. A fill closed a position, money moved, the number is final. This is the only PnL you can spend.

GET /portfolio/positions → clob[].positions.yes.realisedPnl

Unrealised

Paper. Marked-to-market on your open positions using the current mid. It will move between now and the close, possibly a lot.

GET /portfolio/positions → clob[].positions.yes.unrealizedPnl

For a realised PnL time series use GET /portfolio/pnl-chart?timeframe=7d, it returns {data: [{timestamp, value}], currentValue, previousValue, percentChange, current}. The current snapshot breaks down realised + unrealised + total.

Section 02

Sharpe, Sortino, & max drawdown.

Three numbers that every serious strategy report must include. Sharpe says how smooth the ride is, Sortino ignores upside volatility, max drawdown says how bad the worst stretch actually was. All three are computed directly from the data[] array returned by GET /portfolio/pnl-chart, each entry is {timestamp (ms), value (USD)}.

How to run this

  1. Set LMTS_TOKEN_ID + LMTS_TOKEN_SECRET in your environment, /portfolio/pnl-chart is authenticated. Default timeframe is 30d; swap in 7d, 90d, or all as needed.
  2. Save the snippet above as analyze-pnl.ts, then run npx tsx analyze-pnl.ts.
  3. Four lines print: Samples: N, Sharpe, Sortino, and Max DD %. Sharpe above 1.0 is respectable, above 2.0 is suspicious until you’ve tripled your out-of-sample sample. A flat zero means the data[] series was empty, place a few trades first.
// Module 13, Sharpe / Sortino / Max Drawdown
// Source: GET /portfolio/pnl-chart?timeframe=30d
// Response: { timeframe, data: [{timestamp (ms), value (USD)}],
//             currentValue, previousValue, percentChange, current }

import { createHmac } from 'node:crypto';

const BASE = 'https://api.limitless.exchange';
const PERIODS_PER_YEAR = 365;

function signedHeaders(method: string, pathWithQuery: string, body = ''): Record<string, string> {
  const ts  = new Date().toISOString();
  const msg = `${ts}\n${method}\n${pathWithQuery}\n${body}`;
  const sig = createHmac('sha256', Buffer.from(process.env.LMTS_TOKEN_SECRET!, 'base64'))
    .update(msg)
    .digest('base64');
  return {
    'lmts-api-key':   process.env.LMTS_TOKEN_ID!,
    'lmts-timestamp': ts,
    'lmts-signature': sig,
  };
}

type PnLPoint    = { timestamp: number; value: number };
type PnLResponse = {
  timeframe:     string;
  data:          PnLPoint[];
  currentValue:  number;
  previousValue: number;
  percentChange: number;
  current:       object | null;
};

async function fetchPnl(timeframe = '30d'): Promise<PnLResponse> {
  const path = `/portfolio/pnl-chart?timeframe=${timeframe}`;
  const res  = await fetch(`${BASE}${path}`, {
    headers: signedHeaders('GET', path),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

const mean   = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
const stddev = (xs: number[]) => {
  const m = mean(xs);
  return Math.sqrt(xs.reduce((a, b) => a + (b - m) ** 2, 0) / xs.length);
};

function sharpe(returns: number[], rf = 0): number {
  const excess = returns.map(r => r - rf / PERIODS_PER_YEAR);
  return (mean(excess) / stddev(excess)) * Math.sqrt(PERIODS_PER_YEAR);
}

function sortino(returns: number[], rf = 0): number {
  const excess   = returns.map(r => r - rf / PERIODS_PER_YEAR);
  const downside = excess.filter(r => r < 0);
  const dd       = Math.sqrt(downside.reduce((a, r) => a + r * r, 0) / returns.length);
  return (mean(excess) / dd) * Math.sqrt(PERIODS_PER_YEAR);
}

function maxDrawdown(equity: number[]): number {
  let peak = equity[0], mdd = 0;
  for (const v of equity) {
    if (v > peak) peak = v;
    mdd = Math.min(mdd, (v - peak) / peak);
  }
  return mdd;
}

(async () => {
  const pnl = await fetchPnl('30d');

  // Turn cumulative realised PnL (USD) into an equity curve and per-period returns.
  const equity  = pnl.data.map(p => 1 + p.value / 1_000); // normalise to base 1.0
  const returns = equity.slice(1).map((v, i) => v / equity[i] - 1);

  console.log('Samples:', pnl.data.length);
  console.log('Sharpe: ', sharpe(returns).toFixed(2));
  console.log('Sortino:', sortino(returns).toFixed(2));
  console.log('Max DD: ', (maxDrawdown(equity) * 100).toFixed(2), '%');
})();

Section 03

Attribution.

Total PnL is a single number. Attribution is why. Fetch GET /portfolio/trades and group by market.slug, strategy (Buy/Sell), outcomeIndex (YES/NO), and hour-of-day from blockTimestamp. Sum outcomeTokenNetCost per bucket, the buckets where the money actually came from are almost never the ones you assume.

// Module 13, Attribution: split PnL by dimension.
// Source: GET /portfolio/trades (auth, no pagination).
// Each trade: { blockTimestamp (ISO), outcomeIndex (0=YES/1=NO),
//               outcomeTokenNetCost (string), outcomeTokenPrice (string),
//               strategy ('Buy'|'Sell'), market: {slug, title, ...} }

import { createHmac } from 'node:crypto';

const BASE = 'https://api.limitless.exchange';

function signedHeaders(method: string, pathWithQuery: string, body = ''): Record<string, string> {
  const ts  = new Date().toISOString();
  const msg = `${ts}\n${method}\n${pathWithQuery}\n${body}`;
  const sig = createHmac('sha256', Buffer.from(process.env.LMTS_TOKEN_SECRET!, 'base64'))
    .update(msg)
    .digest('base64');
  return {
    'lmts-api-key':   process.env.LMTS_TOKEN_ID!,
    'lmts-timestamp': ts,
    'lmts-signature': sig,
  };
}

type Trade = {
  blockTimestamp:      string;
  outcomeIndex:        number;
  outcomeTokenNetCost: string;
  outcomeTokenPrice:   string;
  strategy:            'Buy' | 'Sell';
  market: { slug: string; title: string };
};

async function fetchTrades(): Promise<Trade[]> {
  const res = await fetch(`${BASE}/portfolio/trades`, {
    headers: signedHeaders('GET', '/portfolio/trades'),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

// Sell-side is realised cash in; buy-side is cash out.
// netCost is positive for buys and negative for sells on Limitless,
// so flipping sells gives a per-trade "contribution" to realised PnL.
function contribution(t: Trade): number {
  const net = parseFloat(t.outcomeTokenNetCost);
  return t.strategy === 'Sell' ? -net : net;
}

function groupBy<K extends string>(
  trades: Trade[],
  key: (t: Trade) => K,
): Record<K, number> {
  const out = {} as Record<K, number>;
  for (const t of trades) {
    const k = key(t);
    out[k] = (out[k] ?? 0) + contribution(t);
  }
  return out;
}

(async () => {
  const trades = await fetchTrades();
  console.log(`Fetched ${trades.length} trades`);

  console.log('by market :', groupBy(trades, t => t.market.slug));
  console.log('by side   :', groupBy(trades, t => t.outcomeIndex === 0 ? 'YES' : 'NO'));
  console.log('by action :', groupBy(trades, t => t.strategy));
  console.log('by hour   :', groupBy(trades, t =>
    String(new Date(t.blockTimestamp).getUTCHours()).padStart(2, '0')));
})();

How to run this

  1. Same LMTS_TOKEN_ID + LMTS_TOKEN_SECRET pair as above, /portfolio/trades returns your own AMM fills as a plain array. You need at least a handful of fills for the buckets to tell you anything; use Module 05 first if your history is empty.
  2. Save the snippet above as attribute-pnl.ts, then run npx tsx attribute-pnl.ts.
  3. Five lines print: the trade count, then four by market / by side / by action / by hour maps. Expect one or two buckets to dominate, the distribution is rarely uniform, and the asymmetry is the whole point of running this.

Section 04

Equity curve visualisation.

Before you render anything in a browser, render it in your terminal. A 30-line ASCII equity curve catches problems your metrics table will never surface, gaps, runaway wins, fake smoothness.

equity.txt · 30 sessions

1.20 |                                    ●●●●
1.15 |                                ●●●●
1.10 |                           ●●●●●
1.05 |                   ●●●●●●●●
1.00 |●●●●●●●
0.95 |        ●●●
0.90 |           ●●
     +──────────────────────────────────────────
      1    5    10   15   20   25   30  session

Good

Steady upward slope, small drawdowns that recover quickly, no single jump that dominates the line.

Suspicious

Flat for months then a single spike. Usually means one lucky market paid for everything, fragile by definition.

Broken

Too smooth to be true. If the line has no wobble, your fill model is ignoring spread, fees, or both.

Wire P&L into your control panel.

The Chart.js P&L curve in Module 02 was rendering against seed data. Now you have real Sharpe, drawdown, and attribution numbers from this module, append {kind:"equity",ts,equity} events to $ACADEMY_DATA_DIR/audit.ndjson on every loop iteration. The panel’s /api/pnl endpoint already filters for kind === "equity", the chart updates on the next poll. The metrics you computed today (Sharpe, drawdown) belong in a small stats card next to the chart; one row each, no decoration.

Common questions

PnL analysis: what people ask

Each answer also ships invisibly as schema.org FAQ data for search engines and AI assistants. Tap a question to expand.

  1. What is the difference between realised and unrealised PnL?
    Realised PnL is locked in: a fill closed the position, money moved, the number is final, and it’s the only PnL you can spend. Unrealised PnL is paper: open positions marked-to-market at the current mid, and it can move a lot before the close. Limitless exposes both per CLOB position via GET /portfolio/positions, as clob[].positions.yes.realisedPnl and unrealizedPnl. Track them separately, never as one lump sum.
  2. What does the Limitless pnl-chart endpoint return?
    GET /portfolio/pnl-chart?timeframe=30d (also 7d, 90d, or all) returns { timeframe, data, currentValue, previousValue, percentChange, current }. The data array holds {timestamp, value} points, timestamps in milliseconds, values in USD: your realised PnL time series. The current snapshot breaks down realised + unrealised + total. It’s authenticated, and it’s the source series for Sharpe, Sortino, and max drawdown.
  3. How do you annualise a Sharpe ratio correctly?
    Match the annualisation factor to your return frequency: daily returns use sqrt(252), hourly returns use sqrt(252×24), and minute bars in 24/7 markets use sqrt(525600). Mismatches are a classic bug: hourly returns annualised with sqrt(252) understate Sharpe by roughly 5×, and daily returns annualised with sqrt(8760) inflate it about 6×, exactly the suspicious number that gets a strategy promoted at the wrong size. Print the factor next to the Sharpe in every report.
  4. What is PnL attribution and why does it matter?
    Total PnL is one number; attribution is why. Fetch GET /portfolio/trades and group by market.slug, strategy (Buy/Sell), outcomeIndex (YES/NO), and hour-of-day from blockTimestamp, summing outcomeTokenNetCost per bucket (flip the sign on sells). Expect one or two buckets to dominate: the buckets where the money actually came from are almost never the ones you assume, and they tell you which edge to double down on before you size up.
  5. What does a healthy equity curve look like?
    A steady upward slope with small drawdowns that recover quickly and no single jump dominating the line. Two red flags: flat for months then one spike usually means a single lucky market paid for everything, fragile by definition; a line that’s too smooth to be true means your fill model is ignoring spread, fees, or both. Render it in the terminal first, a 30-line ASCII curve catches problems a metrics table never surfaces.

Section 05

Module checklist.

Tick each item once you’ve actually done it. The Continue button unlocks at 5/5.

Module 13 complete

Numbers don’t lie.

You can defend a strategy with numbers, not vibes. When your bot puts up a result, you’ll know whether the P&L came from a real edge, three lucky trades, or a bug in the fill model, and you’ll have the chart to back it up.

Concretely, you can score any strategy honestly and show your work. Here’s what you walk away with:

01

An analyze-pnl script that pulls /portfolio/pnl-chart and emits annualised Sharpe, Sortino, and max-drawdown, the three numbers every serious strategy report has to include.

02

A companion attribute-pnl script that buckets /portfolio/trades by market slug, YES/NO side, Buy/Sell action, and hour-of-day, so you can see which edge is actually paying you.

03

An eyeball test for equity curves, smooth monotonic climbs are a red flag, and you can now tell an honest drawdown from a broken fill model at a glance.

Next up: making sure you stay alive long enough to enjoy the score, position limits, correlation, capped half-Kelly, and Monte Carlo stress tests.

Complete the checklist above to unlock