Prediction markets let you trade on the outcome of real-world events — will BTC go up or down in the next 15 minutes? These markets work a lot like traditional exchanges: there’s an orderbook, buyers and sellers post limit orders, and prices move as opinions shift. A market maker is someone (or something) who provides liquidity by continuously placing buy and sell orders on both sides of the book, profiting from the spread between them.
In this tutorial, we will build a simple market-making bot in TypeScript that connects to the Bayse Markets API, discovers tradable markets, streams live orderbook data over WebSockets, and places spread quotes around the mid-price. Along the way, we will cover market discovery, orderbooks, order management, and share minting — the key building blocks for any trading bot on Bayse.
Prerequisites
To follow along, you will need:
- Node.js (version 20 or higher — we need native
fetch) - A Bayse Markets account with API credentials — an API key (public key) and a secret key. The Bayse API is currently available via a waitlist. You can request access by filling out the form at bayse.link/api-waitlist. Once approved, you can generate your API key pair from the authentication section of the API documentation.
- Some familiarity with TypeScript and async/await
Setup
Create a new project folder, initialize it, and install the dependencies:
$ mkdir bayse-spread-farmer && cd bayse-spread-farmer$ npm init -y$ npm install ws$ npm install -D typescript tsx @types/node @types/ws
We only need one runtime dependency — the ws library for WebSocket connections. Everything else (HTTP calls, crypto for request signing) comes from Node.js built-ins.
Create a tsconfig.json in the project root:
{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"]}
Then set up a .env file with your credentials and trading parameters:
# Your Bayse API key pair — get these from https://docs.bayse.markets/authenticationBAYSE_API_KEY=your_public_key_hereBAYSE_SECRET_KEY=your_secret_key_here# Total spread width around the mid-price, in your trading currency.# With NGN (100x multiplier), a spread of 4 means we bid ₦2 below mid and# offer ₦2 above. When both sides fill, we capture the ₦4 difference.SPREAD=4# Number of shares per order. This is the quantity placed on each side of the book.ORDER_SIZE=100# Maximum net position (in shares) before we start skewing quotes to reduce exposure.# The closer we get to this limit, the more aggressively we quote to unwind.MAX_INVENTORY=500# Trading currency. Bayse uses a base multiplier per currency to convert probabilities# into costs — 1x for USD, 100x for NGN. A probability of 0.65 costs $0.65 in USD# or ₦65 in NGN, with winning shares paying out $1 or ₦100 respectively.# See https://docs.bayse.markets/concepts/multi-currency for details.CURRENCY=NGN# Series slug identifying which recurring event series to trade.# This targets the BTC 15-minute binary markets.SERIES_SLUG=crypto-btc-15min# How often (in milliseconds) the bot recalculates and refreshes its quotes.QUOTE_INTERVAL_MS=5000
Configuration
Before we get to the interesting parts, let’s set up a simple config loader. Create src/config.ts:
// Base multipliers per currency — used to convert between currency// amounts and the probability prices (0–1) that the API uses internally.const BASE_MULTIPLIER: Record<string, number> = { USD: 1, NGN: 100,};export interface Config { apiKey: string; secretKey: string; spread: number; // spread in probability terms (e.g. 0.04) orderSize: number; maxInventory: number; currency: string; seriesSlug: string; quoteIntervalMs: number;}export function loadConfig(): Config { const required = (key: string): string => { const val = process.env[key]; if (!val) throw new Error(`missing env var: ${key}`); return val; }; const currency = process.env.CURRENCY ?? "NGN"; const multiplier = BASE_MULTIPLIER[currency] ?? 1; return { apiKey: required("BAYSE_API_KEY"), secretKey: required("BAYSE_SECRET_KEY"), spread: parseFloat(process.env.SPREAD ?? "4") / multiplier, orderSize: parseFloat(process.env.ORDER_SIZE ?? "100"), maxInventory: parseFloat(process.env.MAX_INVENTORY ?? "500"), currency, seriesSlug: process.env.SERIES_SLUG ?? "crypto-btc-15min", quoteIntervalMs: parseInt(process.env.QUOTE_INTERVAL_MS ?? "5000", 10), };}
The spread is specified in your trading currency — ₦4 for NGN. The config loader divides it by the currency’s base multiplier (100 for NGN, 1 for USD) to convert it into the probability space that the API uses internally. So a ₦4 spread on a ₦100 contract becomes 0.04 in probability terms: if the mid-price is 0.50 (₦50), we bid at 0.48 (₦48) and offer at 0.52 (₦52). When both sides fill, we capture the ₦4 difference.
Types
Create src/types.ts to define the shapes we’ll work with throughout the project:
export interface Event { id: string; title: string; status: string; closingDate: string; markets: Market[];}export interface Market { id: string; outcomes: Outcome[];}export interface RawMarket { id: string; status: string; outcome1Id: string; outcome1Label: string; outcome1Price: number; outcome2Id: string; outcome2Label: string; outcome2Price: number; [key: string]: unknown;}export interface Outcome { id: string; label: string; latestPrice: number;}export interface OrderbookLevel { price: number; quantity: number;}export interface Orderbook { bids: OrderbookLevel[]; asks: OrderbookLevel[];}export interface Order { id: string; outcomeId: string; side: "BUY" | "SELL"; price: number; amount: number; filled: number; status: string;}export interface Position { outcomeId: string; available: number; locked: number;}export type Side = "BUY" | "SELL";export interface Quote { outcomeId: string; side: Side; price: number; amount: number;}export interface TrackedOrder { id: string; outcomeId: string; side: Side; price: number;}
A few things worth noting: on Bayse, an Event (e.g. “Will BTC be higher in 15 minutes?”) contains one or more Markets, and each Market has two Outcomes — typically “Up” and “Down” for price markets, or “Yes” and “No” for event markets. Each outcome has its own orderbook. The RawMarket interface reflects the flat shape returned by the API, where outcomes are encoded as outcome1Id, outcome1Label, etc, rather than nested objects.
Request signing
Bayse uses HMAC-SHA256 signatures for authenticated requests (POST, DELETE). The signature covers a dot-delimited string of the timestamp, HTTP method, path, and a SHA-256 hash of the request body. For full details on the signing scheme, see the Bayse API authentication docs.
Create src/auth.ts:
import { createHmac, createHash } from "node:crypto";export function sign( secretKey: string, method: string, path: string, body: string, timestamp: string,): string { const bodyHash = body ? createHash("sha256").update(body).digest("hex") : ""; const message = `${timestamp}.${method}.${path}.${bodyHash}`; return createHmac("sha256", secretKey).update(message).digest("base64");}
Read requests (GET) only need the public key in the X-Public-Key header. Write requests additionally require X-Timestamp and X-Signature headers. We will handle this in our API client next.
Building the REST client
Create src/api.ts. This is a thin wrapper around the Bayse Relay API that handles auth and exposes the endpoints we need:
import { sign } from "./auth.js";import type { Config } from "./config.js";import type { Event, RawMarket, Market, Order, Position, Orderbook } from "./types.js";const BASE_URL = "https://relay.bayse.markets";export class BayseAPI { constructor(private config: Config) {} private async request( method: string, path: string, body: string = "", ): Promise<any> { const headers: Record<string, string> = { "Content-Type": "application/json", "X-Public-Key": this.config.apiKey, }; if (method !== "GET") { const timestamp = Math.floor(Date.now() / 1000).toString(); const signPath = path.split("?")[0]; const signature = sign(this.config.secretKey, method, signPath, body, timestamp); headers["X-Timestamp"] = timestamp; headers["X-Signature"] = signature; } const res = await fetch(`${BASE_URL}${path}`, { method, headers, ...(body ? { body } : {}), }); if (!res.ok) { const text = await res.text(); throw new Error(`API ${method} ${path} → ${res.status}: ${text}`); } const text = await res.text(); return text ? JSON.parse(text) : {}; } private async get(path: string) { return this.request("GET", path); } private async post(path: string, body: object) { return this.request("POST", path, JSON.stringify(body)); } private async del(path: string) { return this.request("DELETE", path); } // ... methods below}
The request method is the foundation. For GET requests, it only attaches the public key. For POST and DELETE, it generates a timestamp, signs the request, and includes both in the headers. Now let’s add the actual API methods on top of this.
Market discovery with series slugs
On Bayse, events are organized into series — recurring event templates identified by a slug like crypto-btc-15min. When you query for active events with a series slug, the API returns all currently open events in that series. This is how our bot finds markets to trade.
Add the following method to BayseAPI:
async getActiveEvents(): Promise<Event[]> { const params = new URLSearchParams({ status: "open", seriesSlug: this.config.seriesSlug, currency: this.config.currency, limit: "10", }); const data = await this.get(`/v1/pm/events?${params}`); return (data.events ?? []).map((e: any) => ({ id: e.id, title: e.title, status: e.status, closingDate: e.closingDate, markets: (e.markets ?? []).map((m: RawMarket): Market => ({ id: m.id, outcomes: [ { id: m.outcome1Id, label: m.outcome1Label, latestPrice: m.outcome1Price }, { id: m.outcome2Id, label: m.outcome2Label, latestPrice: m.outcome2Price }, ], })), }));}
The series slug is the key to market discovery here. Rather than scanning every event on the platform, we ask for only events that match our target series. The API response includes embedded markets and their outcomes with latest prices, which gives us enough to start quoting immediately while we wait for live WebSocket data.
We also normalize the flat RawMarket shape into a cleaner Market with a nested outcomes array, which is easier to work with downstream.
Fetching the orderbook
Each outcome has its own orderbook — a list of resting buy orders (bids) and sell orders (asks) sorted by price. The best bid and best ask give us the current market spread, and the midpoint between them is what we’ll use as our reference price.
async getOrderbook(outcomeId: string): Promise<Orderbook> { const params = new URLSearchParams(); params.append("outcomeId[]", outcomeId); params.append("currency", this.config.currency); params.append("depth", "5"); const data = await this.get(`/v1/pm/books?${params}`); const book = Array.isArray(data) && data.length > 0 ? data[0] : {}; return { bids: book.bids ?? [], asks: book.asks ?? [], };}
The depth parameter limits how many levels we get back. For our purposes, we only need the top of the book — but pulling a few extra levels gives us a better sense of liquidity.
Placing and cancelling orders
Orders on Bayse are placed against a specific outcome within a market within an event — so the endpoint is nested accordingly. We use limit orders exclusively, specifying the exact price we’re willing to trade at:
async placeOrder( eventId: string, marketId: string, outcomeId: string, side: Side, price: number, amount: number,): Promise<Order> { const body = { outcomeId, side, price: Math.round(price * 100) / 100, amount, type: "LIMIT", currency: this.config.currency, }; return this.post( `/v1/pm/events/${eventId}/markets/${marketId}/orders`, body, );}async cancelOrder(orderId: string): Promise<void> { await this.del(`/v1/pm/orders/${orderId}`);}async getOpenOrders(): Promise<Order[]> { const data = await this.get("/v1/pm/orders"); return data.orders ?? [];}
A couple of things to note: prices on Bayse use two decimal places, so we round before sending. The side field expects uppercase BUY or SELL.
Cancellation is straightforward — a DELETE request with the order ID. In practice, we’ll cancel-and-replace orders frequently as the mid-price moves.
Minting and burning shares
This is where prediction markets diverge from traditional exchanges. On Bayse, shares don’t just exist — they are minted in complementary pairs. When you mint on a binary market, you spend currency and receive equal quantities of both outcome shares. If the event resolves to “Up” (or “Yes”), each winning share pays out ₦100 (with NGN) and each losing share is worthless.
Why mint? If you want to sell “Up” shares (because you think the price is too high), you need to have them first. You can either buy them from someone else or mint a pair and sell the Up side while holding the Down side. Minting gives you the inventory to make markets on both sides.
The inverse operation is burning — if you hold equal quantities of both outcomes, you can burn them to reclaim the underlying currency. This is useful for capital management: after fills on both sides, you’ll accumulate pairs that can be burned to free up cash.
async mintShares(marketId: string, quantity: number): Promise<void> { await this.post(`/v1/pm/markets/${marketId}/mint`, { quantity, currency: this.config.currency, });}async burnShares(marketId: string, quantity: number): Promise<void> { await this.post(`/v1/pm/markets/${marketId}/burn`, { quantity, currency: this.config.currency, });}
Portfolio
We also need to know what we’re holding. The portfolio endpoint returns balances per outcome, split into available (free to trade) and locked (tied up in open orders):
async getPositions(): Promise<Position[]> { const data = await this.get("/v1/pm/portfolio"); return (data.outcomeBalances ?? []).map((ob: any) => ({ outcomeId: ob.outcomeId, available: ob.availableBalance ?? 0, locked: (ob.balance ?? 0) - (ob.availableBalance ?? 0), }));}
Streaming orderbooks over WebSockets
Polling the orderbook REST endpoint every few seconds would work, but it’s wasteful and slow. Bayse provides a WebSocket feed that pushes orderbook updates in real time. Create src/ws.ts:
import WebSocket from "ws";const WS_URL = "wss://socket.bayse.markets/ws/v1/markets";type OrderbookHandler = (outcomeId: string, bids: any[], asks: any[]) => void;export class OrderbookFeed { private ws: WebSocket | null = null; private subscriptions: Set<string> = new Set(); private handler: OrderbookHandler; private currency: string; private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectAttempt = 0; constructor(currency: string, handler: OrderbookHandler) { this.currency = currency; this.handler = handler; } connect(): void { this.ws = new WebSocket(WS_URL); this.ws.on("open", () => { console.log("[ws] connected to orderbook feed"); this.reconnectAttempt = 0; for (const id of this.subscriptions) { this.sendSubscribe(id); } }); this.ws.on("message", (raw: Buffer) => { const lines = raw.toString().split("\n").filter(Boolean); for (const line of lines) { try { const msg = JSON.parse(line); if (msg.type === "orderbook_update" && msg.data?.orderbook) { const { outcomeId, bids, asks } = msg.data.orderbook; this.handler(outcomeId, bids ?? [], asks ?? []); } } catch { // ignore malformed frames } } }); this.ws.on("close", () => { console.log("[ws] disconnected, reconnecting..."); this.scheduleReconnect(); }); this.ws.on("error", (err) => { console.error("[ws] error:", err.message); }); } subscribe(marketId: string): void { this.subscriptions.add(marketId); if (this.ws?.readyState === WebSocket.OPEN) { this.sendSubscribe(marketId); } } unsubscribeAll(): void { this.subscriptions.clear(); } close(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.ws?.close(); } private sendSubscribe(marketId: string): void { this.ws?.send( JSON.stringify({ type: "subscribe", channel: "orderbook", marketIds: [marketId], currency: this.currency, }), ); } private scheduleReconnect(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 30_000); this.reconnectAttempt++; this.reconnectTimer = setTimeout(() => this.connect(), delay); }}
To subscribe, we send a JSON message with the market ID and currency. The server then streams orderbook_update messages containing the full bid and ask levels for each outcome in that market. Note that Bayse may batch multiple JSON messages in a single WebSocket frame separated by newlines, so we split on \n before parsing.
The reconnection logic uses exponential backoff — starting at 1 second and doubling up to a 30-second cap. This keeps us from hammering the server if there’s a transient issue while ensuring we get back online quickly.
The market-making bot
Now for the main event. Create src/bot.ts:
import { BayseAPI } from "./api.js";import { OrderbookFeed } from "./ws.js";import type { Config } from "./config.js";import type { Market, TrackedOrder } from "./types.js";export class SpreadFarmer { private api: BayseAPI; private feed: OrderbookFeed; private config: Config; private timer: ReturnType<typeof setInterval> | null = null; private activeEvent: { eventId: string; market: Market; closingDate: string } | null = null; private books: Map<string, { bestBid: number; bestAsk: number }> = new Map(); private liveOrders: Map<string, TrackedOrder> = new Map(); private available: Map<string, number> = new Map(); private total: Map<string, number> = new Map(); constructor(config: Config) { this.config = config; this.api = new BayseAPI(config); this.feed = new OrderbookFeed(config.currency, (outcomeId, bids, asks) => { this.books.set(outcomeId, { bestBid: bids.length > 0 ? bids[0].price : 0, bestAsk: asks.length > 0 ? asks[0].price : 0, }); }); } async start(): Promise<void> { console.log(`[bot] starting spread farmer (spread=${this.config.spread}, size=${this.config.orderSize})`); this.feed.connect(); await this.discoverMarket(); this.timer = setInterval(() => this.tick(), this.config.quoteIntervalMs); } async stop(): Promise<void> { console.log("[bot] shutting down..."); if (this.timer) clearInterval(this.timer); this.feed.close(); await this.cancelAllOrders(); console.log("[bot] stopped"); } // ... methods below}
The constructor wires up the WebSocket feed with a callback that updates our in-memory view of the best bid and ask for each outcome. This means by the time our quoting loop runs, it has near-real-time pricing data to work with.
Discovering markets
The bot needs to find which market to trade. Since Bayse’s BTC 15-min markets rotate — a new one opens as the previous one closes — we re-run discovery on every tick:
private async discoverMarket(): Promise<void> { try { const events = await this.api.getActiveEvents(); if (events.length === 0) { console.log("[bot] no active events found, will retry next tick"); return; } const sorted = events .filter((e) => e.markets.length > 0) .sort((a, b) => { const aClose = new Date(a.closingDate).getTime(); const bClose = new Date(b.closingDate).getTime(); return aClose - bClose; }); if (sorted.length === 0) return; const event = sorted[0]; const market = event.markets[0]; const eventId = event.id; if (this.activeEvent?.market.id === market.id) return; console.log(`[bot] trading on: "${event.title}" (market ${market.id})`); await this.cancelAllOrders(); this.feed.unsubscribeAll(); this.activeEvent = { eventId, market, closingDate: event.closingDate }; this.feed.subscribe(market.id); for (const outcome of market.outcomes) { this.books.set(outcome.id, { bestBid: outcome.latestPrice, bestAsk: outcome.latestPrice, }); } } catch (err: any) { console.error("[bot] discovery error:", err.message); }}
We sort by closing date and pick the soonest — that’s the current window. If we’re already trading on it, we skip. When we switch to a new market, we cancel all existing orders, unsubscribe from the old WebSocket channel, subscribe to the new one, and seed our book cache with the latest prices from the REST response so we have something to quote against immediately.
The quoting loop
Every quoteIntervalMs milliseconds, the bot runs its main tick function:
private async tick(): Promise<void> { try { await this.discoverMarket(); if (!this.activeEvent) return; const { eventId, market, closingDate } = this.activeEvent; // stop quoting 30 seconds before close const closesAt = new Date(closingDate).getTime(); if (Date.now() > closesAt - 30_000) { console.log("[bot] market closing soon, cancelling orders"); await this.cancelAllOrders(); this.activeEvent = null; return; } await this.refreshInventory(); const upOutcome = market.outcomes.find((o) => o.label === "Up"); const downOutcome = market.outcomes.find((o) => o.label === "Down"); if (!upOutcome || !downOutcome) return; const book = this.books.get(upOutcome.id); if (!book || book.bestBid === 0 || book.bestAsk === 0) return; // mid-price: simple average of best bid and ask const mid = (book.bestBid + book.bestAsk) / 2; const halfSpread = this.config.spread / 2; // inventory skew const upTotal = this.total.get(upOutcome.id) ?? 0; const downTotal = this.total.get(downOutcome.id) ?? 0; const upAvail = this.available.get(upOutcome.id) ?? 0; const downAvail = this.available.get(downOutcome.id) ?? 0; const skew = this.computeSkew(upTotal); const bidPrice = Math.max(0.01, mid - halfSpread + skew); const askPrice = Math.min(0.99, mid + halfSpread + skew); // burn matched pairs to free up capital const burnable = Math.min(upAvail, downAvail); if (burnable > 0) { try { await this.api.burnShares(market.id, burnable); console.log(`[bot] burned ${burnable} share pairs`); } catch (err: any) { console.error("[bot] burn error:", err.message); } } // mint if we don't have enough shares to cover sell orders if (upTotal < this.config.orderSize) { const mintQty = this.config.orderSize - upTotal; try { await this.api.mintShares(market.id, mintQty); console.log(`[bot] minted ${mintQty} share pairs`); } catch (err: any) { console.error("[bot] mint error:", err.message); } } console.log( `[bot] mid=${mid.toFixed(2)} bid=${bidPrice.toFixed(2)} ask=${askPrice.toFixed(2)} ` + `up=${upAvail}/${upTotal} down=${downAvail}/${downTotal} skew=${skew.toFixed(3)}`, ); await this.ensureOrder(eventId, market.id, upOutcome.id, "BUY", bidPrice); await this.ensureOrder(eventId, market.id, upOutcome.id, "SELL", askPrice); } catch (err: any) { console.error("[bot] tick error:", err.message); }}
There’s a lot happening here, so let’s break it down:
- Market rotation — We re-run discovery to catch new markets as they open.
- Close protection — We cancel orders 30 seconds before close to avoid getting caught in settlement.
- Inventory refresh — We pull our latest positions from the portfolio endpoint.
- Mid-price calculation — The midpoint between the best bid and best ask. This is our “fair value” estimate.
- Inventory skew — If we’re accumulating too many Up shares, we shift both quotes downward to become a more aggressive seller and a less aggressive buyer. This helps us unwind the position.
- Burn pairs — If we hold equal amounts of both outcomes (from fills on both sides), we burn them to reclaim capital.
- Mint shares — If we don’t have enough Up shares to cover our sell order, we mint more. Remember, minting gives us both outcomes.
- Place quotes — Finally, we place (or update) our bid and ask orders.
Inventory skew
The skew is a simple linear function:
private computeSkew(position: number): number { const halfSpread = this.config.spread / 2; return -(position / this.config.maxInventory) * halfSpread;}
At zero inventory, there’s no skew — our quotes are symmetric around the mid. As we accumulate Up shares, the skew turns negative, pushing both our bid and ask lower. At max inventory, the shift equals a full half-spread, meaning our ask is now at the mid-price (very aggressive to sell) and our bid is a full spread below mid (very reluctant to buy). This is a standard market-making technique for controlling inventory risk.
Order management
Rather than blindly cancelling and replacing every tick, we check whether the price has moved enough to warrant an update:
private async ensureOrder( eventId: string, marketId: string, outcomeId: string, side: Side, price: number,): Promise<void> { const key = `${outcomeId}:${side}`; const existing = this.liveOrders.get(key); // leave it if price hasn't moved more than 1 unit if (existing && Math.abs(existing.price - price) < 0.01) return; if (existing) { try { await this.api.cancelOrder(existing.id); } catch { // may already be filled or cancelled } this.liveOrders.delete(key); } try { const order = await this.api.placeOrder( eventId, marketId, outcomeId, side, price, this.config.orderSize, ); this.liveOrders.set(key, { id: order.id, outcomeId, side, price }); } catch (err: any) { console.error(`[bot] order error (${side} @ ${price.toFixed(2)}):`, err.message); }}private async cancelAllOrders(): Promise<void> { await Promise.allSettled( [...this.liveOrders.values()].map((o) => this.api.cancelOrder(o.id)), ); this.liveOrders.clear();}private async refreshInventory(): Promise<void> { try { const positions = await this.api.getPositions(); this.available.clear(); this.total.clear(); for (const pos of positions) { this.available.set(pos.outcomeId, pos.available); this.total.set(pos.outcomeId, pos.available + pos.locked); } } catch (err: any) { console.error("[bot] portfolio error:", err.message); }}
We track orders by a composite key of {outcomeId}:{side}, so we maintain at most one bid and one ask per outcome. If the computed price hasn’t moved by more than one unit, we leave the existing order alone — no point churning through cancel/place cycles for tiny moves.
cancelAllOrders uses Promise.allSettled rather than Promise.all so that one failed cancellation (the order might already be filled) doesn’t prevent the others from executing.
Wiring it up
Create src/index.ts as the entry point:
import { loadConfig } from "./config.js";import { SpreadFarmer } from "./bot.js";const config = loadConfig();const bot = new SpreadFarmer(config);const shutdown = async () => { await bot.stop(); process.exit(0);};process.on("SIGINT", shutdown);process.on("SIGTERM", shutdown);bot.start().catch((err) => { console.error("fatal:", err); process.exit(1);});
We wire up SIGINT and SIGTERM handlers for graceful shutdown — the bot cancels all its open orders before exiting so we don’t leave stale liquidity on the book.
Run the bot in development with:
$ npx tsx src/index.ts
You should see output like:
[bot] starting spread farmer (spread=4, size=100)[ws] connected to orderbook feed[bot] trading on: "BTC 15min - 2025-03-22 14:00" (market abc123)[bot] minted 100 share pairs[bot] mid=0.50 bid=0.48 ask=0.52 up=100/100 down=100/100 skew=0.000
Conclusion
We built a basic market-making bot that uses the Bayse API to discover markets by series slug, stream live orderbooks over WebSockets, place and manage limit orders, and mint/burn share pairs for inventory management. It’s intentionally minimal — the spread capture logic is about as simple as it gets.
From here, you could extend it in a number of directions: dynamic spread adjustment based on volatility, multi-market quoting across different series, smarter inventory management, or even a basic P&L tracker. The Bayse API has more to offer than what we’ve covered here — check the official docs for the full reference.