DEVELOPER TUTORIAL · CHECKED SEPTEMBER 29, 2026

Build a prediction market bot, starting with paper trading

Run a small, inspectable bot before connecting a market feed or account. This tutorial separates data, decisions and execution so you can see exactly why an automated bot buys, sells or does nothing.

Run the example in two files

Download paper_bot.mjs and paper_quotes.json into the same folder. With Node.js 22 or later, run:

node paper_bot.mjs paper_quotes.json

No dependencies, account, API key or wallet are required. The fixture contains five synthetic observations, including a duplicate and a stale quote. It represents one fictional binary contract with a $1 payout. Its fair values are invented to exercise the code; they are not a prediction model or evidence of a profitable strategy.

What the output should show

Deterministic result from the supplied synthetic fixture
ObservationDecisionReason
1Buy 1Only one contract is offered at the ask.
2SkipSame snapshot; do not consume its depth twice.
3SkipReceipt is 3,001 ms after the source timestamp.
4Sell 1Executable-price assumption exceeds the supplied fair value.
5HaltThe explicit kill switch is set.

Starting cash is $20. The buy costs $0.435 and the sale returns $0.525, leaving $20.09 and zero contracts. Both sides include a deliberately illustrative $0.01 fee and $0.005 slippage per contract. This nine-cent synthetic result is a reproducibility check, not a return forecast.

Separate the three parts of your bot

  1. Data adapter: normalize venue identifiers, quote sides, timestamps and quantities. Keep raw data so transformations can be audited.
  2. Decision engine: accept an explicit probability estimate and decide whether the estimated edge exceeds costs. Log the inputs with every decision.
  3. Execution adapter: simulate fills during development. A real adapter also needs authenticated order submission, order-state reconciliation, cancellation and restart recovery.

The download implements the second layer and a simplified paper execution layer. It contains no network requests or live order path. That makes a broken assumption reproducible from a small input file.

Use prices you could act on

Buying YES consumes the ask; selling YES uses the bid. Last-traded price and midpoint are useful observations but do not guarantee either fill. For Kalshi’s binary book, derive the YES ask from one dollar minus the best NO bid and retain that level’s quantity. Our Kalshi API getting-started guide includes a read-only Python adapter.

For Polymarket, start with market discovery and the public WebSocket example. Match the outcome token to its question and resolution rules. A reconnect requires a fresh snapshot and gap handling before a local book can be trusted again.

Replace the sample’s flat fee with the venue’s actual market-specific fee calculation before studying results. Maker/taker status, rounding and partial fills matter. Use the fee-aware calculator to inspect scenarios; it is separate from this deliberately simplified replay.

Make limits executable

The sample uses exact integer microdollars internally. Its defaults permit at most two contracts per decision, four held contracts and a two-second quote age. Orders are capped by available cash and displayed depth. Each source timestamp is consumed at most once for this single-market replay.

A manual stop or a $2 loss at the last usable quote halts subsequent decisions. Halting leaves an existing position open; it does not pretend that cancellation or liquidation happened. The final equity is an estimate at the last accepted bid after the illustrative exit costs, which can be stale and ignores exit depth.

To explore the parameters from another script, import replay and pass an options object. For example:

import { replay } from './paper_bot.mjs';
// quotes is the parsed JSON fixture.
const result = replay(quotes, {
  cash: '20', feePerContract: '0.01', slippagePerContract: '0.005',
  minimumEdge: '0.03', orderSize: 2, maxPosition: 4,
  maxAgeMs: 2000, lossLimit: '2'
});

What a replay does not validate

  • Queue priority, other traders consuming depth, network delay and execution rejection. The example assumes a hypothetical fill when a decision is accepted.
  • Settlement, disputed outcomes, short positions, multi-market correlation or account margin.
  • Forecast quality. Train and evaluate a model on separate time periods; using information from after a decision introduces look-ahead bias.
  • Profitability. Compare realized results with a baseline after costs, include losses and uncertainty, and keep out-of-sample evaluation separate from tuning.

Before any live execution, test reconnects, invalid inputs, duplicate events, API rate limits, an ambiguous order timeout and process restarts. Reconcile the venue’s open orders and positions before resuming. A retry is not proof that the original order failed.

Runnable example source

Download paper_bot.mjs
Read paper_bot.mjs
// Educational offline replay, Node.js 22+. No network, keys or live orders.
// Run: node paper_bot.mjs paper_quotes.json
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';

const UNIT = 1_000_000n;
export function money(value) {
  if (typeof value !== 'string' || !/^\d+(\.\d{1,6})?$/.test(value))
    throw new Error('Money must be a nonnegative decimal string with at most 6 places.');
  const [whole, fraction = ''] = value.split('.');
  return BigInt(whole) * UNIT + BigInt(fraction.padEnd(6, '0'));
}
const dollars = value => `${value < 0n ? '-' : ''}${(value < 0n ? -value : value) / UNIT}.${((value < 0n ? -value : value) % UNIT).toString().padStart(6, '0')}`;
const min = (...values) => values.reduce((a, b) => a < b ? a : b);
function integer(value, name) {
  if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid ${name}.`);
  return value;
}

export function replay(quotes, options = {}) {
  if (!Array.isArray(quotes) || quotes.length === 0 || quotes.length > 10_000)
    throw new Error('Provide 1–10,000 observations.');
  const initial = money(options.cash ?? '20');
  const fee = money(options.feePerContract ?? '0.01'); // illustrative, NOT a venue fee formula
  const slippage = money(options.slippagePerContract ?? '0.005');
  const edge = money(options.minimumEdge ?? '0.03');
  const lossLimit = money(options.lossLimit ?? '2');
  const maxAge = integer(options.maxAgeMs ?? 2000, 'maximum age');
  const maxPosition = BigInt(integer(options.maxPosition ?? 4, 'maximum position'));
  const orderSize = BigInt(integer(options.orderSize ?? 2, 'order size'));
  let cash = initial, position = 0n, lastBid = null, halted = false;
  let lastReceived = -1, newestSource = -1, market = null;
  const seen = new Set(), events = [];

  for (const row of quotes) {
    if (!row || typeof row !== 'object') throw new Error('Invalid observation.');
    const at = integer(row.at_ms, 'source timestamp');
    const received = integer(row.received_ms, 'receipt timestamp');
    if (received < lastReceived) throw new Error('Receipt timestamps must be ordered.');
    lastReceived = received;
    if (typeof row.market !== 'string' || !row.market) throw new Error('Missing market identifier.');
    if (market && row.market !== market) throw new Error('This example replays one market only.');
    market = row.market;
    const event = { at_ms: at, action: 'skip', reason: '' };
    const skip = reason => { events.push({ ...event, reason }); };
    if (row.stop === true) halted = true;
    if (halted) { skip('kill switch'); continue; }
    if (received < at || received - at > maxAge) { skip('stale or future quote'); continue; }
    if (row.status !== 'open') { skip('market not open'); continue; }
    if (seen.has(at)) { skip('duplicate snapshot'); continue; }
    if (at < newestSource) { skip('out-of-order source snapshot'); continue; }
    seen.add(at);
    newestSource = at;
    const bid = money(row.bid), ask = money(row.ask), fair = money(row.fair);
    if (bid <= 0n || ask >= UNIT || bid > ask || fair > UNIT)
      throw new Error('Expected 0 < bid <= ask < 1 and 0 <= fair <= 1.');
    const bidSize = BigInt(integer(row.bid_size, 'bid depth'));
    const askSize = BigInt(integer(row.ask_size, 'ask depth'));
    lastBid = bid;
    const sellPrice = bid - slippage - fee;
    const markedEquity = cash + position * (sellPrice > 0n ? sellPrice : 0n);
    if (initial - markedEquity >= lossLimit) {
      halted = true; skip('loss limit; position remains open'); continue;
    }
    const buyPrice = ask + slippage + fee;
    let quantity = 0n, action = 'skip';
    if (position > 0n && sellPrice - fair >= edge) {
      action = 'sell'; quantity = min(position, orderSize, bidSize);
      cash += quantity * sellPrice; position -= quantity;
    } else if (fair - buyPrice >= edge) {
      action = 'buy'; quantity = min(maxPosition - position, orderSize, askSize, cash / buyPrice);
      cash -= quantity * buyPrice; position += quantity;
    }
    events.push({ at_ms: at, action: quantity > 0n ? action : 'skip',
      reason: quantity > 0n ? 'simulated fill' : 'edge, depth or exposure limit',
      contracts: Number(quantity), cash: dollars(cash), position: Number(position) });
  }
  const liquidation = lastBid === null ? null : (lastBid > fee + slippage ? lastBid - fee - slippage : 0n);
  return { mode: 'offline paper replay', events, cash: dollars(cash), position: Number(position),
    equity_at_last_quote: liquidation === null ? null : dollars(cash + position * liquidation),
    pnl_at_last_quote: liquidation === null ? null : dollars(cash + position * liquidation - initial),
    halted, note: 'Synthetic fair values; hypothetical top-of-book fills. Last-quote marks may be stale and ignore exit depth. No settlement, queue or latency model.' };
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  try {
    if (!process.argv[2]) throw new Error('Usage: node paper_bot.mjs paper_quotes.json');
    console.log(JSON.stringify(replay(JSON.parse(await readFile(process.argv[2], 'utf8'))), null, 2));
  } catch (error) { console.error(error.message); process.exitCode = 1; }
}

Sources & references

Primary sources used for the figures, mechanics, and regulatory statements in this guide. Where a fact is time-bound, the source date is shown — verify the latest version before relying on it for trading or compliance decisions.

  1. Kalshi public market data — Kalshi
  2. Kalshi order-book structure — Kalshi
  3. Polymarket market data — Polymarket
  4. Polymarket fee model — Polymarket

The example and fixture were exercised locally; no funded account or live trading was tested. Sources and review method · Report a correction