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 cardHow 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
- 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.
- Save the snippet above as analyze-pnl.ts, then run npx tsx analyze-pnl.ts.
- Save the snippet above as analyze_pnl.py, then run python analyze_pnl.py (requires numpy and httpx).
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- 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), '%');
})();
# 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 asyncio
import base64
import datetime
import hashlib
import hmac
import os
import numpy as np
import httpx
BASE = "https://api.limitless.exchange"
PERIODS_PER_YEAR = 365
def signed_headers(method: str, path_with_query: str, body: str = "") -> dict[str, str]:
now = datetime.datetime.now(datetime.timezone.utc)
ts = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
msg = f"{ts}\n{method}\n{path_with_query}\n{body}"
sig = base64.b64encode(
hmac.new(base64.b64decode(os.environ["LMTS_TOKEN_SECRET"]),
msg.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
return {
"lmts-api-key": os.environ["LMTS_TOKEN_ID"],
"lmts-timestamp": ts,
"lmts-signature": sig,
}
async def fetch_pnl(timeframe: str = "30d") -> dict:
path = f"/portfolio/pnl-chart?timeframe={timeframe}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{BASE}{path}", headers=signed_headers("GET", path)
)
resp.raise_for_status()
return resp.json()
def sharpe(returns: np.ndarray, rf: float = 0.0) -> float:
excess = returns - rf / PERIODS_PER_YEAR
return float(excess.mean() / excess.std(ddof=0) * np.sqrt(PERIODS_PER_YEAR))
def sortino(returns: np.ndarray, rf: float = 0.0) -> float:
excess = returns - rf / PERIODS_PER_YEAR
downside = excess[excess < 0]
dd = np.sqrt(np.sum(downside ** 2) / len(returns))
return float(excess.mean() / dd * np.sqrt(PERIODS_PER_YEAR))
def max_drawdown(equity: np.ndarray) -> float:
running_max = np.maximum.accumulate(equity)
return float(((equity - running_max) / running_max).min())
async def main() -> None:
pnl = await fetch_pnl("30d")
values = np.array([p["value"] for p in pnl["data"]], dtype=float)
# Cumulative realised PnL (USD) -> base-1 equity curve -> per-period returns.
equity = 1.0 + values / 1_000.0
returns = np.diff(equity) / equity[:-1]
print(f"Samples: {len(pnl['data'])}")
print(f"Sharpe: {sharpe(returns):.2f}")
print(f"Sortino: {sortino(returns):.2f}")
print(f"Max DD: {max_drawdown(equity) * 100:.2f} %")
if __name__ == "__main__":
asyncio.run(main())
// Module 13, Sharpe / Sortino / Max Drawdown
// Source: GET /portfolio/pnl-chart?timeframe=30d
// Response: { timeframe, data: [{timestamp (ms), value (USD)}],
// currentValue, previousValue, percentChange, current }
// Uses stdlib math only, swap for gonum/stat on large series.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"os"
"time"
)
const (
base = "https://api.limitless.exchange"
periodsPerYear = 365.0
)
func signedHeaders(method, pathWithQuery, body string) map[string]string {
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
msg := ts + "\n" + method + "\n" + pathWithQuery + "\n" + body
secret, _ := base64.StdEncoding.DecodeString(os.Getenv("LMTS_TOKEN_SECRET"))
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(msg))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return map[string]string{
"lmts-api-key": os.Getenv("LMTS_TOKEN_ID"),
"lmts-timestamp": ts,
"lmts-signature": sig,
}
}
type PnLPoint struct {
Timestamp int64 `json:"timestamp"` // ms
Value float64 `json:"value"` // USD
}
type PnLResponse struct {
Timeframe string `json:"timeframe"`
Data []PnLPoint `json:"data"`
CurrentValue float64 `json:"currentValue"`
PreviousValue float64 `json:"previousValue"`
PercentChange float64 `json:"percentChange"`
}
func fetchPnl(timeframe string) (*PnLResponse, error) {
path := fmt.Sprintf("/portfolio/pnl-chart?timeframe=%s", timeframe)
req, err := http.NewRequest(http.MethodGet, base+path, nil)
if err != nil {
return nil, err
}
for k, v := range signedHeaders("GET", path, "") {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out PnLResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
func mean(xs []float64) float64 {
var s float64
for _, v := range xs {
s += v
}
return s / float64(len(xs))
}
func stddev(xs []float64) float64 {
m := mean(xs)
var v float64
for _, x := range xs {
v += (x - m) * (x - m)
}
return math.Sqrt(v / float64(len(xs)))
}
func sharpe(returns []float64) float64 {
return (mean(returns) / stddev(returns)) * math.Sqrt(periodsPerYear)
}
func sortino(returns []float64) float64 {
var dd float64
for _, r := range returns {
if r < 0 {
dd += r * r
}
}
ddStd := math.Sqrt(dd / float64(len(returns)))
return (mean(returns) / ddStd) * math.Sqrt(periodsPerYear)
}
func maxDrawdown(equity []float64) float64 {
peak, mdd := equity[0], 0.0
for _, v := range equity {
if v > peak {
peak = v
}
if d := (v - peak) / peak; d < mdd {
mdd = d
}
}
return mdd
}
func main() {
pnl, err := fetchPnl("30d")
if err != nil {
log.Fatal(err)
}
// Cumulative realised PnL (USD) -> base-1 equity -> per-period returns.
equity := make([]float64, len(pnl.Data))
for i, p := range pnl.Data {
equity[i] = 1.0 + p.Value/1_000.0
}
returns := make([]float64, 0, len(equity)-1)
for i := 1; i < len(equity); i++ {
returns = append(returns, equity[i]/equity[i-1]-1)
}
fmt.Printf("Samples: %d\n", len(pnl.Data))
fmt.Printf("Sharpe: %.2f\n", sharpe(returns))
fmt.Printf("Sortino: %.2f\n", sortino(returns))
fmt.Printf("Max DD: %.2f %%\n", maxDrawdown(equity)*100)
}
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')));
})();
# 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 asyncio
import base64
import datetime as _dt
import hashlib
import hmac
import os
from collections import defaultdict
from datetime import datetime
from typing import Callable
import httpx
BASE = "https://api.limitless.exchange"
def signed_headers(method: str, path_with_query: str, body: str = "") -> dict[str, str]:
now = _dt.datetime.now(_dt.timezone.utc)
ts = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
msg = f"{ts}\n{method}\n{path_with_query}\n{body}"
sig = base64.b64encode(
hmac.new(base64.b64decode(os.environ["LMTS_TOKEN_SECRET"]),
msg.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
return {
"lmts-api-key": os.environ["LMTS_TOKEN_ID"],
"lmts-timestamp": ts,
"lmts-signature": sig,
}
async def fetch_trades() -> list[dict]:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{BASE}/portfolio/trades", headers=signed_headers("GET", "/portfolio/trades"))
resp.raise_for_status()
return resp.json()
def contribution(t: dict) -> float:
# Flip sells so the sum approximates realised cash PnL.
net = float(t["outcomeTokenNetCost"])
return -net if t["strategy"] == "Sell" else net
def group_by(trades: list[dict], key: Callable[[dict], str]) -> dict[str, float]:
out: dict[str, float] = defaultdict(float)
for t in trades:
out[key(t)] += contribution(t)
return dict(out)
async def main() -> None:
trades = await fetch_trades()
print(f"Fetched {len(trades)} trades")
print("by market :", group_by(trades, lambda t: t["market"]["slug"]))
print("by side :", group_by(trades,
lambda t: "YES" if t["outcomeIndex"] == 0 else "NO"))
print("by action :", group_by(trades, lambda t: t["strategy"]))
print("by hour :", group_by(trades,
lambda t: datetime.fromisoformat(
t["blockTimestamp"].replace("Z", "+00:00")
).strftime("%H")))
if __name__ == "__main__":
asyncio.run(main())
// Module 13, Attribution: split PnL by dimension.
// Source: GET /portfolio/trades (auth, no pagination).
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
)
const base = "https://api.limitless.exchange"
func signedHeaders(method, pathWithQuery, body string) map[string]string {
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
msg := ts + "\n" + method + "\n" + pathWithQuery + "\n" + body
secret, _ := base64.StdEncoding.DecodeString(os.Getenv("LMTS_TOKEN_SECRET"))
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(msg))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return map[string]string{
"lmts-api-key": os.Getenv("LMTS_TOKEN_ID"),
"lmts-timestamp": ts,
"lmts-signature": sig,
}
}
type TradeMarket struct {
Slug string `json:"slug"`
Title string `json:"title"`
}
type Trade struct {
BlockTimestamp string `json:"blockTimestamp"` // ISO 8601
OutcomeIndex int `json:"outcomeIndex"` // 0 = YES, 1 = NO
OutcomeTokenNetCost string `json:"outcomeTokenNetCost"`
OutcomeTokenPrice string `json:"outcomeTokenPrice"`
Strategy string `json:"strategy"` // "Buy" | "Sell"
Market TradeMarket `json:"market"`
}
func fetchTrades() ([]Trade, error) {
req, err := http.NewRequest(http.MethodGet, base+"/portfolio/trades", nil)
if err != nil {
return nil, err
}
for k, v := range signedHeaders("GET", "/portfolio/trades", "") {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out []Trade
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
func contribution(t Trade) float64 {
net, _ := strconv.ParseFloat(t.OutcomeTokenNetCost, 64)
if t.Strategy == "Sell" {
return -net
}
return net
}
func groupBy(trades []Trade, key func(Trade) string) map[string]float64 {
out := map[string]float64{}
for _, t := range trades {
out[key(t)] += contribution(t)
}
return out
}
func main() {
trades, err := fetchTrades()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Fetched %d trades\n", len(trades))
byMarket := groupBy(trades, func(t Trade) string { return t.Market.Slug })
bySide := groupBy(trades, func(t Trade) string {
if t.OutcomeIndex == 0 {
return "YES"
}
return "NO"
})
byAction := groupBy(trades, func(t Trade) string { return t.Strategy })
byHour := groupBy(trades, func(t Trade) string {
ts, err := time.Parse(time.RFC3339, t.BlockTimestamp)
if err != nil {
return "??"
}
return fmt.Sprintf("%02d", ts.UTC().Hour())
})
fmt.Println("by market :", byMarket)
fmt.Println("by side :", bySide)
fmt.Println("by action :", byAction)
fmt.Println("by hour :", byHour)
}
How to run this
- 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.
- Save the snippet above as attribute-pnl.ts, then run npx tsx attribute-pnl.ts.
- Save the snippet above as attribute_pnl.py, then run python attribute_pnl.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- 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.
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.
-
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 viaGET /portfolio/positions, asclob[].positions.yes.realisedPnlandunrealizedPnl. Track them separately, never as one lump sum. -
What does the Limitless pnl-chart endpoint return?
GET /portfolio/pnl-chart?timeframe=30d(also7d,90d, orall) returns{ timeframe, data, currentValue, previousValue, percentChange, current }. Thedataarray holds{timestamp, value}points, timestamps in milliseconds, values in USD: your realised PnL time series. Thecurrentsnapshot breaks down realised + unrealised + total. It’s authenticated, and it’s the source series for Sharpe, Sortino, and max drawdown. -
How do you annualise a Sharpe ratio correctly?
Match the annualisation factor to your return frequency: daily returns usesqrt(252), hourly returns usesqrt(252×24), and minute bars in 24/7 markets usesqrt(525600). Mismatches are a classic bug: hourly returns annualised withsqrt(252)understate Sharpe by roughly 5×, and daily returns annualised withsqrt(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. -
What is PnL attribution and why does it matter?
Total PnL is one number; attribution is why. FetchGET /portfolio/tradesand group bymarket.slug,strategy(Buy/Sell),outcomeIndex(YES/NO), and hour-of-day fromblockTimestamp, summingoutcomeTokenNetCostper 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. -
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.
I track realised and unrealised PnL separately, not as one lump sum
I computed Sharpe, Sortino, and max drawdown on my strategy returns
I split my PnL by market, side, time-of-day, and hold duration
I plotted my equity curve and looked at it, not just the summary table
I can honestly say which bucket (or market) is carrying the strategy
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:
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.
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.
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