Skip to main content
This guide covers how to build an autonomous market maker that creates prediction markets, trades them, resolves them on expiry, and collects profits — all running unattended. Patterns are extracted from the PMX reference agent.

Architecture

A production market maker runs several concurrent loops:
LoopIntervalPurpose
Market creation5 minMaintain a target number of active markets
Trading60–90 secBuy the likely winning side on each active market
Resolution5 minResolve expired markets using historical prices
Redemption5 minRedeem winning tokens and claim creator fees

Prerequisites

const { Connection, Transaction, Keypair, PublicKey } = require("@solana/web3.js");
const bs58 = require("bs58");

const API = "https://api.pmx.trade";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const wallet = Keypair.fromSecretKey(bs58.decode(process.env.PRIVATE_KEY));
const WALLET = wallet.publicKey.toBase58();
Reuse the api() and signAndSend() helpers from the Trading Bot tutorial.

Creating Markets

The /v2/markets/create-full endpoint handles market creation and mint initialization in a single call. It returns multiple transactions to sign sequentially.
async function createMarket({ title, description, yesTicker, noTicker, durationMinutes, seedAmount }) {
  const resolutionTime = Math.floor(Date.now() / 1000) + durationMinutes * 60;

  const res = await api("POST", "/v2/markets/create-full", {
    wallet: WALLET,
    title,
    description,
    yesTicker,
    noTicker,
    resolutionTime,
    humanSeedAmount: seedAmount,
    tradeFeeBps: 200,
  });

  if (!res.success) throw new Error(res.error);

  const { createTx, initMintsTx, setMetadataTx, marketId } = res.data;

  // 1. Create the market on-chain
  const createSig = await signAndSend(createTx);

  // 2. Initialize YES/NO token mints (required — without this, trading fails)
  let mintsSig;
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      mintsSig = await signAndSend(initMintsTx);
      break;
    } catch (err) {
      if (attempt === 2) throw new Error(`initMints failed after 3 retries: ${err.message}`);
      await new Promise((r) => setTimeout(r, 2000));
    }
  }

  // 3. Optional: set token metadata (image, name)
  if (setMetadataTx) {
    try { await signAndSend(setMetadataTx); } catch {}
  }

  return { marketId, createSig, mintsSig };
}
If initMintsTx fails, the market is broken — mints are uninitialized and no one can trade. Always retry this step and verify afterward.

Generating Market Titles

Crypto price markets need a parseable title like "BTC above $95,000 by Mar 7 6:30 PM UTC". The threshold should be near the current price with slight randomization:
function generateMarketParams(symbol, currentPrice, change1h) {
  const momentumBias = change1h > 0 ? 0.003 : change1h < 0 ? -0.003 : 0;
  const randomOffset = (Math.random() - 0.5) * 0.03;
  let threshold = currentPrice * (1 + randomOffset + momentumBias);

  // Round nicely based on price magnitude
  if (currentPrice >= 1000) threshold = Math.round(threshold / 10) * 10;
  else if (currentPrice >= 100) threshold = Math.round(threshold);
  else if (currentPrice >= 1) threshold = Math.round(threshold * 100) / 100;

  const duration = 15 + Math.floor(Math.random() * 31); // 15–45 min
  const resolveTime = new Date(Date.now() + duration * 60_000);
  const dateStr = resolveTime.toUTCString().replace(/ GMT$/, " UTC");

  return {
    title: `${symbol} above $${threshold.toLocaleString()} by ${dateStr}`,
    description: `Will ${symbol} be above $${threshold.toLocaleString()} by ${dateStr}?`,
    yesTicker: `${symbol}UP`,
    noTicker: `${symbol}DN`,
    durationMinutes: duration,
    threshold,
  };
}

Market Creation Loop

Maintain a target number of active markets (e.g., 5–10):
const TARGET_ACTIVE = 7;

async function creationLoop() {
  while (true) {
    const markets = await api("GET", `/v2/markets?status=active&creator=${WALLET}`);
    const active = markets.data || [];
    const deficit = TARGET_ACTIVE - active.length;

    if (deficit > 0) {
      const toCreate = Math.min(deficit, 3); // cap per cycle
      for (let i = 0; i < toCreate; i++) {
        // Fetch price, generate params, call createMarket()
        // ... (see edge detection section for price fetching)
      }
    }

    await new Promise((r) => setTimeout(r, 5 * 60_000));
  }
}

Budget Management

A production bot needs to protect its capital. Key patterns:
const USDC_RESERVE = 40;         // always keep $40 for creating new markets
const PER_MARKET_CAP = 8;        // never spend more than $8 trading a single market
const MAX_CYCLE_SPEND_PCT = 0.25; // spend at most 25% of trade budget per cycle

function getTradeBudget(totalUsdc, activeCount) {
  const marketsNeeded = Math.max(0, TARGET_ACTIVE - activeCount);
  const reserve = marketsNeeded > 0 ? Math.min(USDC_RESERVE, marketsNeeded * 15) : 0;
  return totalUsdc - reserve;
}
Track per-market spend to avoid overconcentration:
const marketSpend = {}; // { marketId: totalBought }

function recordBuy(marketId, amount) {
  marketSpend[marketId] = (marketSpend[marketId] || 0) + amount;
}

function canTrade(marketId, amount) {
  return (marketSpend[marketId] || 0) + amount <= PER_MARKET_CAP;
}

Automated Trading

The trading loop fetches prices, computes edge, and buys the likely winning side:
async function tradingLoop() {
  while (true) {
    const markets = await api("GET", `/v2/markets?status=active&creator=${WALLET}`);
    const active = (markets.data || []).filter((m) => !isExpired(m));

    for (const market of active) {
      const parsed = parseTitle(market.title); // extract symbol, direction, threshold
      if (!parsed) continue;

      const price = await getCurrentPrice(parsed.symbol);
      const odds = getMarketOdds(market);
      const { edge, side } = calculateEdge(price, parsed.threshold, parsed.direction, odds);

      let amount = 0;
      if (edge > 0.08)      amount = 0.5 + Math.random() * 1.5;
      else if (edge > 0.04) amount = 0.25 + Math.random() * 0.75;
      else if (edge > 0.02) amount = Math.random() < 0.2 ? 0.1 + Math.random() * 0.4 : 0;

      if (amount >= 0.1 && canTrade(market.id, amount)) {
        try {
          await buy(market.id, side, amount);
          recordBuy(market.id, amount);
        } catch {}
      }
    }

    await new Promise((r) => setTimeout(r, 75_000));
  }
}

Near-Expiry Blitz

When a market is close to expiry and the outcome is clear, trade more aggressively:
function getTradeAmount(edge, secsToExpiry, priceDelta) {
  if (secsToExpiry < 600 && priceDelta > 0.005) {
    return 1.5 + Math.random() * 2; // $1.50–$3.50 blitz
  }
  if (edge > 0.08) return 0.5 + Math.random() * 1.5;
  if (edge > 0.04) return 0.25 + Math.random() * 0.75;
  if (edge > 0.02 && Math.random() < 0.2) return 0.1 + Math.random() * 0.4;
  return 0;
}

Resolving Markets

After a market’s resolutionTime passes, the creator must resolve it. For crypto price markets, compare the historical price at expiry against the threshold:
async function resolveMarket(marketId, winningSide) {
  const res = await api("POST", `/v2/markets/${marketId}/resolve`, {
    wallet: WALLET,
    winningSide,
  });
  if (!res.success) throw new Error(res.error);

  // Idempotent — API returns success if already resolved
  if (res.alreadyResolved || !res.data?.transaction) return null;
  return signAndSend(res.data.transaction);
}

function determineWinner(price, threshold, direction) {
  if (direction === "above" || direction === "over") {
    return price >= threshold ? "YES" : "NO";
  }
  return price < threshold ? "YES" : "NO";
}

Resolution Loop

async function resolutionLoop() {
  while (true) {
    const markets = await api("GET", `/v2/markets?status=active&creator=${WALLET}`);
    const now = Math.floor(Date.now() / 1000);

    for (const market of markets.data || []) {
      if (market.resolutionTime > now) continue; // not yet expired

      const parsed = parseTitle(market.title);
      if (!parsed) continue;

      // Get price at the exact resolution timestamp
      const price = await getHistoricalPrice(parsed.symbol, market.resolutionTime);
      if (!price) continue;

      const winner = determineWinner(price, parsed.threshold, parsed.direction);
      try {
        await resolveMarket(market.id, winner);
      } catch {}
    }

    await new Promise((r) => setTimeout(r, 5 * 60_000));
  }
}
Resolution requires Clock.unix_timestamp >= market.resolutionTime and both YES and NO mints must have non-zero supply. If nobody traded one side, resolution will fail.

Redeeming & Claiming Fees

After resolution, redeem winning tokens and collect creator fees:
async function drainResolved() {
  const positions = await api("GET", `/v2/positions/${WALLET}`);
  for (const pos of positions.data || []) {
    if (!pos.isResolved) continue;

    // Redeem winning tokens
    if (pos.needsRedemption && pos.winningSide) {
      const res = await api("POST", `/v2/markets/${pos.marketId}/redeem`, {
        wallet: WALLET,
        side: pos.winningSide,
      });
      if (res.data?.transaction) await signAndSend(res.data.transaction);
    }

    // Claim creator fees
    const feeRes = await api("POST", `/v2/markets/${pos.marketId}/claim-fees`, {
      wallet: WALLET,
    });
    if (feeRes.data?.transaction) await signAndSend(feeRes.data.transaction);
  }
}
Both redeem and claim-fees are idempotent — calling them on already-redeemed or zero-fee markets returns success with no transaction. Safe to call unconditionally.

Running Everything

Start all loops concurrently:
async function main() {
  // Startup checks
  const health = await fetch(`${API}/v2/health`);
  if (!health.ok) throw new Error("API unreachable");

  await Promise.all([
    creationLoop(),
    tradingLoop(),
    resolutionLoop(),
    redemptionLoop(),
  ]);
}

async function redemptionLoop() {
  while (true) {
    await drainResolved();
    await new Promise((r) => setTimeout(r, 5 * 60_000));
  }
}

main().catch(console.error);

Graceful Shutdown

Use interruptible sleeps so the bot shuts down cleanly on SIGINT:
let running = true;
process.on("SIGINT", () => { running = false; });

async function sleepInterruptible(ms) {
  let remaining = ms;
  while (remaining > 0 && running) {
    const chunk = Math.min(remaining, 30_000);
    await new Promise((r) => setTimeout(r, chunk));
    remaining -= chunk;
  }
}

Environment Variables

VariableDescriptionDefault
PRIVATE_KEYBase58 Solana private keyrequired
PMX_API_URLPMX API base URLhttps://api.pmx.trade
SOLANA_RPC_URLSolana RPC endpointhttps://api.devnet.solana.com
TARGET_ACTIVE_MARKETSNumber of markets to maintain7
PER_MARKET_TRADE_CAPMax USDC to trade per market8
USDC_RESERVE_FOR_CREATIONUSDC reserved for new markets40

Next Steps

Trading Bot Patterns

API client, error handling, and position management

Pricing Mechanics

How the AMM computes prices and slippage