Welcome to API Academy

Module 12 · Data · ~28 min

Backtesting.

By the end of this module, you can take any strategy idea and find out, in minutes, with real Limitless data, whether it would have made money. Before you risk a dollar of your own.

To get there, you’ll test strategies on real history before risking real capital. Walk candles one tick at a time, simulate fills with an honest cost model, and ship the same code to production.

Data tier · Reference card
Quick answer

How do you backtest a trading strategy on Limitless?

Replay real candle history through your strategy one bar at a time: fetch OHLCV from GET /markets/{slug}/oracle-candles, call the strategy’s on_tick hook on each bar, and simulate fills at the next bar’s open so look-ahead is impossible by construction. Attach an honest cost model, every input comes from a real endpoint: fees from GET /profiles/{account}rank.feeRateBps, spread estimated from GET /markets/{slug}/orderbook, slippage calibrated from your own GET /portfolio/trades fills, and a settlement-risk haircut on the final payoff. The whole event loop is about forty lines, no framework, and the same code can be promoted to production. Use the fast vectorised style only for parameter sweeps: sweep vectorised, validate finalists in the event loop, ship from the event loop.

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

Section 01

Event loop vs vectorised.

Two philosophies compete for how to run a backtest. An event-driven loop walks history one tick at a time, the way your bot will run in production, slower but honest. A vectorised backtest treats the whole history as one big array operation and runs hundreds of times faster, but the speed comes at the cost of easy correctness. The cards below compare them; the line underneath is the workflow you actually want: sweep in vector, validate in event loop, ship from the event loop.

Event-driven

Iterate the history one tick at a time, call your strategy’s on_tick hook, apply fills, update PnL. Slower, but it mirrors how your live bot will run, so the same code can be promoted to production.

  • Realistic order/fill semantics
  • No look-ahead by construction
  • Same codepath as live execution
  • Seconds to hours per run

Vectorised

Express the strategy as array operations over the whole history at once (NumPy, Polars, Pandas). Blazing fast, but every operation is an opportunity to accidentally peek at the future.

  • 100× to 1000× faster
  • Great for parameter sweeps
  • Easy to introduce look-ahead
  • Approximate fills

Sweep params vectorised. Validate finalists in an event loop. Ship from the event loop.

Section 02

The cost model.

A backtest without a cost model is a cruel form of self-deception. Every strategy looks profitable in mid-price-to-mid-price land. Model the real costs or delete the notebook. On Limitless, every input you need is available from a real endpoint, no guessing.

Fees

Fee rate is a property of your rank. Read it from GET /profiles/{account}rank.feeRateBps and apply it to every simulated fill, not just the profitable ones.

Spread

When you cross the book, you pay half the spread twice (once in, once out). Estimate it from GET /markets/{slug}/orderbook, the mid-to-best-ask delta, rather than the candle close.

Slippage

Calibrate against your own GET /portfolio/trades history: compare outcomeTokenPrice on each fill against the quoted mid at blockTimestamp. That’s your empirical slippage curve.

Settlement risk

Markets resolve. Being right on the price is worthless if the oracle disputes your outcome, model a haircut on the final payoff.

Ground truth: compare your backtest’s realised PnL curve against GET /portfolio/pnl-chart?timeframe=30d from a live run, if they diverge, your cost model is lying.

Section 03

A minimal event loop.

Forty lines, no framework. Fetches candles from GET /markets/{slug}/oracle-candles, walks them one at a time, calls your strategy’s on_tick, simulates the fill using the next bar’s open (no look-ahead), and tracks realised PnL. Replace the example mean-revert strategy with anything you like.

How to run this

  1. No env vars needed, this pulls candles from the public oracle-candles endpoint. Swap btc-above-100k-june for a live slug, and tune FEE_BPS (from /profiles/{account}.rank.feeRateBps) and SLIP_BPS (calibrated from your own /portfolio/trades) for honest numbers.
  2. Save the snippet above as run-backtest.ts, then run npx tsx run-backtest.ts.
  3. Final line prints Bars: N  Realised PnL: 0.xxxx. A 30-day, 1-hour run yields ~720 bars. A strongly negative number on a benign strategy usually means your cost model is finally honest, not that the strategy broke.
// Module 12, A Minimal Event-Driven Backtester
// Data: GET /markets/{slug}/oracle-candles (public, Chainlink OHLCV)
// Fill: next bar's open + configurable slippage. No look-ahead.

const BASE = 'https://api.limitless.exchange';
const FEE_BPS  = 20;                  // from /profiles/{account}.rank.feeRateBps
const SLIP_BPS = 15;                  // calibrated from /portfolio/trades

type Row = { timestamp: number; open: number; high: number; low: number; close: number; volume: number; };
type Signal = 0 | 1 | -1;
interface Strategy { onTick(row: Row, position: number): Signal; }

class MeanRevert implements Strategy {
  private history: number[] = [];
  onTick(row: Row, position: number): Signal {
    this.history.push(row.close);
    if (this.history.length < 20) return 0;
    const mean = this.history.slice(-20).reduce((a, b) => a + b) / 20;
    if (row.close < mean * 0.97 && position <= 0) return  1;
    if (row.close > mean * 1.03 && position >= 0) return -1;
    return 0;
  }
}

async function fetchCandles(slug: string, from: number, to: number): Promise<Row[]> {
  const url = `${BASE}/markets/${slug}/oracle-candles`
            + `?interval=1h&from=${from}&to=${to}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()).rows as Row[];
}

async function backtest(slug: string, strategy: Strategy) {
  const now  = Math.floor(Date.now() / 1000);
  const from = now - 30 * 24 * 60 * 60;
  const rows = await fetchCandles(slug, from, now);

  const cost = (px: number, side: 1 | -1) =>
    px * (1 + side * (FEE_BPS + SLIP_BPS) / 10_000);

  let cash = 0, position = 0, entry = 0;
  for (let i = 0; i < rows.length - 1; i++) {
    const signal = strategy.onTick(rows[i], position);
    const nextOpen = rows[i + 1].open;       // fill at next bar's open
    if (signal === 1 && position === 0) {
      entry = cost(nextOpen, 1);  position = 1;
    } else if (signal === -1 && position === 1) {
      cash += cost(nextOpen, -1) - entry;  position = 0;
    }
  }
  console.log('Bars:', rows.length, 'Realised PnL:', cash.toFixed(4));
}

backtest('btc-above-100k-june', new MeanRevert()).catch(console.error);

Section 04

Common pitfalls.

Every backtest you’ll ever write has the same four failure modes. Each one makes a losing strategy look profitable, which means you’ll miss them if you’re only checking the PnL curve. The cards below name the four ways your code lies to you. Read each one and ask whether your own loop already has the issue before moving on, these are easy to spot before you run, almost impossible to spot after, because by then you’re attached to the pretty equity curve.

Look-ahead bias

Any feature computed over the whole series (z-scores, rolling means, normalisations) can silently leak tomorrow into today. Only ever pass a strategy data whose timestamp is strictly before the current tick.

Survivorship bias

Only running on markets that still exist today ignores every market that was delisted or resolved badly. Include resolved and withdrawn markets or your results are fiction.

Unrealistic fills

Filling at the candle’s close price assumes infinite liquidity at the last print. Fill at the next bar’s open, or better, walk the actual order book snapshot from that moment.

Overfitting

Ten free parameters will always find a profitable combination on any history. Out-of-sample test, walk-forward validate (Module 17), and prefer fewer knobs over more.

Common questions

Backtesting on Limitless: 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. Should a backtest be event-driven or vectorised?
    Both, in sequence. A vectorised backtest treats the whole history as array operations and runs 100× to 1000× faster, ideal for parameter sweeps, but every operation is a chance to accidentally peek at the future, and fills are approximate. An event-driven loop walks one tick at a time, has no look-ahead by construction, and shares a codepath with live execution. Sweep params vectorised, validate finalists in an event loop, ship from the event loop.
  2. What costs should a Limitless backtest model?
    Four: fees, spread, slippage, and settlement risk. Your fee rate is a property of your rank, read from GET /profiles/{account}rank.feeRateBps and applied to every simulated fill, not just the profitable ones. Spread comes from the GET /markets/{slug}/orderbook mid-to-best-ask delta; you pay half of it twice when you cross in and out. Calibrate slippage from your own GET /portfolio/trades fills against the quoted mid at each blockTimestamp. Then haircut the final payoff for resolution risk.
  3. What is look-ahead bias in a backtest?
    The strategy sees future data while making a past decision, so the in-sample results are fiction. Whole-series features (z-scores, rolling means, normalisations), a careless bfill, or a sloppy resample().mean() can all pull tomorrow’s price into today’s row. The fix: only ever pass the strategy data whose timestamp ≤ current_event_time, and add an assertion that fires if any input row carries a future timestamp. If it ever fires in dev, fix the leak before going live.
  4. Why fill backtest orders at the next bar’s open?
    Filling at the candle’s close assumes infinite liquidity at the last print, the classic unrealistic-fills pitfall. Your signal is computed on the completed bar, so the earliest honest fill is the next bar’s open plus your slippage allowance; better still, walk the actual order book snapshot from that moment. Whatever you choose, know your fill assumption and be able to defend it to a skeptical reviewer.
  5. How do you know if your backtest cost model is honest?
    Compare it against live reality: check the backtest’s realised PnL curve against GET /portfolio/pnl-chart?timeframe=30d from a live run. If they diverge, your cost model is lying. A useful smell going in: a strongly negative result on a benign strategy usually means the cost model finally got honest, while a strategy that only profits in mid-price-to-mid-price land has no edge after fees, spread, and slippage.

Section 05

Module checklist.

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

Module 12 complete

Engine running.

Your strategy ideas now have a verdict. Instead of arguing with yourself about whether an edge is real, you can play it forward against actual exchange history with honest fees, slippage, and timing, and let the equity curve do the talking.

Concretely, you can replay history through any strategy you want to test, with costs modelled honestly. Here’s what you walk away with:

01

A reusable run-backtest harness that iterates candle history one bar at a time, calls your strategy’s on_tick, and fills at the next bar’s open, zero look-ahead by construction.

02

A four-part cost model, fees from rank.feeRateBps, spread from the orderbook, slippage calibrated from your own fills, and a settlement haircut, that turns fantasy PnL into a defensible number.

03

A working mental model for sweep-vectorised / validate-event-loop / ship-event-loop, plus a named-and-defended checklist of the four classic pitfalls (look-ahead, survivorship, unrealistic fills, overfitting).

Next up: turning the PnL series your backtester emits into Sharpe, Sortino, max drawdown, and attribution buckets that expose where the money actually came from.

Complete the checklist above to unlock