> For the complete documentation index, see [llms.txt](https://dev.strixlab.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://dev.strixlab.io/guides/market-maker-bot.md).

# Build a Market-Maker Bot

A complete two-sided quoting bot in about 120 lines — built up piece by piece, with the failure modes explained.

This guide builds a bot that quotes both sides of one outcome around the mid price, requotes when the market moves, and pulls its quotes on the way out. It's the shape most market-making strategies start from.

{% hint style="danger" %}
**Run it on testnet first.** `https://api-staging.strixlab.io/api` is a real order book with fake money. A quoting bug costs you nothing there and real USDC on mainnet.
{% endhint %}

## What the bot does

{% stepper %}
{% step %}

### Track the mid price

Subscribe to the order book over WebSocket and let the SDK maintain the book. The `onMid` callback fires whenever the touch moves.
{% endstep %}

{% step %}

### Quote around it

Place a bid at `mid − halfSpread` and an ask at `mid + halfSpread`, both tick-aligned. Resting orders are maker orders, and **makers pay no fee**.
{% endstep %}

{% step %}

### Requote when it moves

If the mid has moved by more than a threshold, cancel the old pair and place a new one. Requote too eagerly and you spend your rate-limit budget; too lazily and you get picked off.
{% endstep %}

{% step %}

### Always be able to stop

On `SIGINT`, on an unhandled rejection, on a disconnect — `cancelAll()` first, then exit.
{% endstep %}
{% endstepper %}

## Setup

{% code title=".env" %}

```bash
STRIX_BASE_URL=https://api-staging.strixlab.io/api
STRIX_API_KEY=...
STRIX_API_SECRET=...
STRIX_API_PASSPHRASE=...
MM_EVENT="BTC above 100k"
```

{% endcode %}

```bash
npm install strix-sdk
npm install -D tsx
```

## The bot

{% code title="mm-bot.ts" %}

```ts
import { StrixClient, StrixApiError } from 'strix-sdk';

// ── Config ──────────────────────────────────────────────────────────────────
const SPREAD      = 0.04;   // total spread — 2¢ each side of mid
const SIZE        = 50;     // shares per side
const REQUOTE_MIN = 0.005;  // requote when mid moves half a cent
const MAX_POSITION = 500;   // stop adding on a side past this inventory

const client = new StrixClient({
  baseUrl:       process.env.STRIX_BASE_URL!,
  apiKey:        process.env.STRIX_API_KEY!,
  apiSecret:     process.env.STRIX_API_SECRET!,
  apiPassphrase: process.env.STRIX_API_PASSPHRASE!,
});

// ── State ───────────────────────────────────────────────────────────────────
let tokenId  = '';
let tick     = 0.01;
let lastMid: number | null = null;
let liveHashes: string[]   = [];
let quoting  = false;

const alignToTick = (p: number) => Math.round(p / tick) * tick;
const clampPrice  = (p: number) => Math.min(1 - tick, Math.max(tick, p));

// ── Requote ─────────────────────────────────────────────────────────────────
async function requote(mid: number) {
  if (quoting) return;               // one cycle at a time
  quoting = true;
  try {
    // 1. Pull the old quotes. cancelBatch is rate-limit-exempt.
    if (liveHashes.length) {
      await client.orders.cancelBatch(liveHashes).catch(() => {});
      liveHashes = [];
    }

    // 2. Size the new ones against current inventory.
    const { availableBalance, positions } = await client.portfolio.balances();
    const held = Number(positions.find((p) => p.tokenId === tokenId)?.shareAmount ?? 0);

    const bid = clampPrice(alignToTick(mid - SPREAD / 2));
    const ask = clampPrice(alignToTick(mid + SPREAD / 2));

    const orders = [];
    if (held < MAX_POSITION && Number(availableBalance) > bid * SIZE) {
      orders.push({ tokenId, side: 'BUY' as const, price: bid, quantity: SIZE, orderType: 'GTC' as const });
    }
    if (held >= SIZE) {
      orders.push({ tokenId, side: 'SELL' as const, price: ask, quantity: Math.min(SIZE, held), orderType: 'GTC' as const });
    }
    if (!orders.length) return;

    // 3. One batch = one order-write against the rate limit.
    const results = await client.orders.placeBatch(orders);
    for (const r of results) {
      if (r.success) liveHashes.push(r.order.orderHash);
      else console.warn(`rejected #${r.index}: ${r.error}`);
    }

    lastMid = mid;
    console.log(`quoted ${bid.toFixed(3)} / ${ask.toFixed(3)}  (held ${held})`);
  } catch (err) {
    if (err instanceof StrixApiError) console.error(`requote failed ${err.status}: ${err.message}`);
    else throw err;
  } finally {
    quoting = false;
  }
}

// ── Main ────────────────────────────────────────────────────────────────────
async function main() {
  const { serverTime } = await client.serverTime();
  const drift = Math.abs(serverTime - Math.floor(Date.now() / 1000));
  if (drift > 5) throw new Error(`clock drift ${drift}s — sync NTP first`);

  const info = await client.markets.resolve(process.env.MM_EVENT!);
  tokenId = info.yes.tokenId;
  tick    = info.tickSize;
  console.log(`making ${info.marketTitle} · ${info.yes.name} · tick ${tick}`);

  // Fills — log them and let the next mid move resize the quotes.
  client.ws.user.connect();
  client.ws.user.on('trade.settled', (t) => console.log(`FILL ${t.side} ${t.quantity} @ ${t.price}`));
  client.ws.user.on('error', (e) => console.error('ws error:', e.message));

  // Book — the trigger for everything.
  client.ws.orderbook.subscribe([tokenId], {
    depth: 10,
    onMid: ({ yesMid }) => {
      if (yesMid === null) return;                                   // one-sided book
      if (lastMid !== null && Math.abs(yesMid - lastMid) < REQUOTE_MIN) return;
      void requote(yesMid);
    },
  });
}

// ── Shutdown ────────────────────────────────────────────────────────────────
async function shutdown(reason: string) {
  console.log(`\nshutting down (${reason}) — pulling quotes`);
  await client.orders.cancelAll().catch(() => {});
  client.destroy();
  process.exit(0);
}

process.on('SIGINT',  () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('unhandledRejection', (e) => { console.error(e); void shutdown('unhandledRejection'); });

main().catch(async (err) => { console.error(err); await shutdown('startup failure'); });
```

{% endcode %}

```bash
npx tsx mm-bot.ts
```

## Why it's written that way

<details>

<summary><strong>Why <code>cancelBatch</code> instead of two <code>cancel</code> calls?</strong></summary>

Single-order cancel shares the order-write rate-limit bucket with placements, so under load it can return `429` — and a failed cancel means **the order is still live**. `cancelBatch` and `cancelAll` are exempt, because a cancel only ever removes risk. In a requote loop, always use the bulk paths.

</details>

<details>

<summary><strong>Why one <code>placeBatch</code> instead of two <code>place</code> calls?</strong></summary>

A batch counts as a single order-write against the limit however many orders it carries, and both quotes land in the same instant rather than one spread-width apart. At 300 order-writes per 10 seconds, batching is what lets a bot quote many markets at once.

</details>

<details>

<summary><strong>Why does the bot never assume it filled?</strong></summary>

`place()` returns `status: "OPEN"` even for an order that crosses — matching and settlement are asynchronous, roughly 15–20 seconds. This bot reads inventory from `portfolio.balances()` on each requote and logs `trade.settled` for visibility. Never derive position from placement responses.

</details>

<details>

<summary><strong>Why the <code>quoting</code> flag?</strong></summary>

`onMid` can fire several times while an `await` is in flight. Without the guard, two cycles interleave: the second cancels orders the first hasn't recorded yet, and you end up with orphan quotes you no longer track — the most common way a simple bot ends up long a side it thought it was flat on.

</details>

<details>

<summary><strong>Why check the clock before starting?</strong></summary>

HMAC signatures are rejected when your timestamp is more than 30 seconds from server time. A drifting container clock produces a stream of `401 invalid_signature` errors that look exactly like bad credentials. Checking once at startup turns a confusing failure into a clear one.

</details>

## What to add next

<table><thead><tr><th width="240">Feature</th><th>Approach</th></tr></thead><tbody><tr><td><strong>Inventory skew</strong></td><td>Shift both quotes down as you get long, up as you get short. Cheapest real improvement to a naive two-sided quote.</td></tr><tr><td><strong>Multi-market</strong></td><td>One client, one socket, many tokens. Keep per-token state in a <code>Map</code> and batch requotes across markets.</td></tr><tr><td><strong>Manufactured inventory</strong></td><td><code>positions.split()</code> turns USDC into both legs at once, so you can quote a two-sided market without buying inventory first.</td></tr><tr><td><strong>Staleness guard</strong></td><td>If no book update arrives for N seconds, cancel everything. A silent socket is not a quiet market.</td></tr><tr><td><strong>Wider spreads near resolution</strong></td><td>Watch <code>events.resolution()</code> and back off as the outcome becomes knowable.</td></tr></tbody></table>

{% hint style="warning" %}
**Self-trading is rejected**, not silently crossed. If your bid would take your own resting ask — including the equivalent order on the sibling outcome, translated into YES terms — the placement is refused. Cancel before you requote, exactly as this bot does.
{% endhint %}

Before running anything with real money, work through [Going to Production](/guides/production-checklist.md).
