home

Guides

Market-making bot

A complete Seeryx market-making bot in TypeScript and Python: quote both sides around the midpoint, requote, handle fills and earn liquidity rewards.

The loop

A market maker keeps a bid and an ask in the book and earns the spread and the maker rebate when others trade with it. On Seeryx two bids are enough: a Down bid at q is an Up ask at 100 − q. The bot below quotes the live BTC 5-minute round:

  1. Read the round, its Up book and your account (positions and resting orders).
  2. Price both quotes a few cents either side of the midpoint of the best bid and ask. With no two-sided book there is no midpoint, so it waits.
  3. Requote only when the midpoint moved or a quote was filled: cancel the round's orders in one request, place both quotes in one batch, post only.
  4. Handle fills. Report what changed, stop bidding a side once it holds enough of it, and merge Up and Down pairs back into cash.
  5. Stop before the close. 15 seconds before the end it cancels everything and waits for the next round. What it still holds pays out by itself.

The bot

Run it next to seeryx, message and chain from the earlier pages: npx tsx bot.ts or python bot.py. It signs in, opens a trading session (one wallet signature) and loops every 2 seconds.

bot.ts
// bot.ts: quotes both sides of the live BTC 5-minute round with post-only limit orders. npx tsx bot.ts
import { api, loadWallet, signIn, type Account, type Book, type Round } from './seeryx';
import { signOrder, signPair, tradingSession, type Funds, type Order } from './chain';
import { logValue } from './api-url.mjs';

const MARKET = '/markets/BTC/5m/current';
const SIZE = 10;       // shares per quote (at least minOrderShares of GET /api/fees)
const EDGE = 2;        // cents on each side of the midpoint
const MAX_HELD = 100;  // stop bidding a side once this many of its shares are held
const STOP_S = 15;     // leave the round this many seconds before its close (new orders stop 5 s before)
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
type Results = { results: { status: number; body: { order?: number; error?: string } }[] };

const wallet = await loadWallet();
await signIn(wallet);
const funds = await api<Funds>('/funds');
const { minOrderShares } = await api<{ minOrderShares: number }>('/fees');
// every order is signed by a trading session (one wallet signature now, then none)
const session = funds.onChainTrading ? await tradingSession(funds, wallet) : null;
const sign = (order: Order) => (session ? signOrder(funds, session, wallet.address, order) : order);
let quoted = '';
let seen = new Map<string, number>(); // shares held per side, to report fills

for (;;) {
  try {
    const round = await api<Round>(MARKET);
    if (round.end - Date.now() / 1000 < STOP_S) {
      // step aside before the close: what is still held pays out by itself when the round resolves
      await api(`/orders?slug=${round.slug}`, { method: 'DELETE' });
      await sleep((round.end + 2) * 1000 - Date.now());
      quoted = '';
      continue;
    }
    const [book, account] = await Promise.all([
      api<Book>(`/orderbook/${round.slug}?side=up`), // levels are [cents, shares], best first
      api<Account>('/account'),
    ]);
    const held = (side: string) =>
      account.positions.find((p) => p.slug === round.slug && p.side === side)?.shares ?? 0;
    for (const side of ['up', 'down']) {
      const change = held(side) - (seen.get(side) ?? 0);
      if (Math.abs(change) > 1e-9) console.log('filled', side, logValue(change));
    }
    seen = new Map([['up', held('up')], ['down', held('down')]]);

    // an Up and a Down share together always pay $1: merge pairs back into cash to free it for new quotes
    const pairs = Math.floor(Math.min(held('up'), held('down')));
    if (pairs >= minOrderShares) {
      const signed = session ? await signPair(funds, session, wallet.address, round.slug, true, pairs) : undefined;
      await api('/merge', { method: 'POST', body: { slug: round.slug, shares: pairs, signed } });
      continue;
    }

    const bid = book.bids[0]?.[0], ask = book.asks[0]?.[0];
    if (bid === undefined || ask === undefined) { await sleep(3000); continue; } // no two-sided book: no midpoint
    const mid = (bid + ask) / 2;
    // a Down bid at q is an Up ask at 100 - q: two bids quote both sides without holding shares
    const quotes = [
      { side: 'up' as const, limit: Math.floor(mid - EDGE) },
      { side: 'down' as const, limit: Math.floor(100 - mid - EDGE) },
    ].filter((q) => q.limit >= 1 && q.limit <= 99 && held(q.side) < MAX_HELD);
    const resting = account.orders.filter((o) => o.slug === round.slug).length;
    const key = `${round.slug} ${quotes.map((q) => q.side + q.limit).join(' ')}`;
    if (key !== quoted || resting !== quotes.length) { // the midpoint moved or a quote was filled: requote
      await api(`/orders?slug=${round.slug}`, { method: 'DELETE' });
      const base = { slug: round.slug, action: 'buy', type: 'limit', shares: SIZE, postOnly: true } as const;
      const orders: Order[] = quotes.map((q) => ({ ...base, ...q })); // post only: never takes, never pays the fee
      const body = { orders: await Promise.all(orders.map(sign)) };
      const { results } = await api<Results>('/orders/batch', { method: 'POST', body });
      results.forEach((r, i) => console.log(quotes[i].side, quotes[i].limit, logValue(r.body.order ?? r.body.error)));
      quoted = key;
    }
  } catch (error) {
    const e = error as { message: string; status?: number; retryAfter?: number };
    console.error(logValue(e.message));
    if (e.status === 401 || e.status === 403) await signIn(wallet); // the 7-day sign-in token expired
    if (e.retryAfter) await sleep(e.retryAfter * 1000); // 429 or 503: wait as told
  }
  await sleep(2000);
}

Fills and inventory

  • A fill is a position. A filled Up bid leaves you holding Up shares: they pay $1.00 each if Up wins and nothing if it loses. MAX_HELD caps how much of one side the bot accumulates.
  • Pairs are cash. Holding both sides is a sure $1.00 a pair, so the bot merges pairs back into USDC at once (free) and quotes again with that cash.
  • Post only keeps every quote a maker: no taker fee, and the 20% rebate on each fill. A quote that would cross the book is refused (would_take) and simply placed again on the next pass.
  • Faster fills. The bot polls; for quicker reactions subscribe to the round and the account over the WebSocket and requote on each message.
  • Make it yours: price from an outside signal (the underlying's price move since the round opened) instead of the book's midpoint, skew quotes against what you hold, quote upcoming rounds and more markets.

Liquidity rewards

Resting limit orders near the midpoint earn Rewards points on the markets where liquidityRewards of GET /api/markets is true (the 5-minute and 15-minute crypto markets) and on every event market. Once a minute each order is scored against the midpoint of that market's Up book:

score=value×((3¢ − distance) / 3¢)²
RuleValue
valueThe order's resting size at its own price, in dollars (a Down buy at p counts as an Up ask at 100 − p)
distanceHow far the order is from the midpoint of the best Up bid and ask; 3¢ or more earns nothing
Points0.1 point per dollar of score per hour, credited a minute at a time, per UTC day
Both sides+50% on a market where your orders score on the bid and the ask
CountsOrders that have rested at least 60 seconds, of at least the minimum order and $5, in a live round with a two-sided book
Cap$500 of value per market and side, the closest orders first

GET /api/rewards/liquidity returns your points, the rate your resting orders earn right now and these rules as numbers. Points count toward the levels of Rewards.

Before you run it

  • Keep wallet.json and session.json readable only by you; the session key can trade your cash within its limits.
  • Handle 429 by waiting Retry-After; the bot already does.
  • Read GET /api/fees at start: the rates and the minimum order can change.