Skip to Content
RecipesPolymarket Arbitrage Agent

Polymarket Arbitrage Agent

What are we cooking?

An autonomous agent that scans Polymarket for arbitrage opportunities — mispriced markets where buying both sides (or related outcomes) costs less than the guaranteed payout. The agent executes dual-leg trades via WaaP CLI, pocketing the difference.

The agent detects two types of arbitrage:

  1. Complementary arbitrage — When YES + NO prices for a single market sum to less than $1.00. Buying both guarantees a profit.
  2. Related market arbitrage — When two markets within the same event cover exhaustive outcomes but their combined prices leave a gap.

All orders are signed via waap-cli sign-typed-data using 2PC-MPC. No raw private keys touch the agent environment.

This recipe builds on the Polymarket Signal Trader recipe. Complete the CLOB credential setup and USDC approval steps there first.


Prerequisites

  • WaaP CLI installed and logged in (waap-cli login)
  • Polymarket CLOB API credentials — see the Signal Trader recipe
  • USDC on Polygon funded to your WaaP wallet
  • USDC approval for the CTF Exchange contract — see the Signal Trader recipe

Project Setup

mkdir waap-polymarket-arb && cd waap-polymarket-arb npm init -y npm install dotenv

Create a .env file:

# Polymarket CLOB POLY_API_KEY=your_api_key POLY_API_SECRET=your_api_secret POLY_PASSPHRASE=your_passphrase # Agent config AGENT_MAX_ORDER_USD=10 MIN_PROFIT_BPS=50 AGENT_POLL_INTERVAL_MS=30000

MIN_PROFIT_BPS is the minimum profit in basis points (1 bps = 0.01%) after fees. At 50 bps, the agent only trades when the expected profit exceeds 0.5%.


Step 1: Market Fetching and Grouping

Fetch markets and group them by event so we can compare related outcomes.

import 'dotenv/config'; const GAMMA_API_URL = 'https://gamma-api.polymarket.com'; async function getMarketsGroupedByEvent() { const response = await fetch( `${GAMMA_API_URL}/events?active=true&closed=false&limit=20` ); const events = await response.json(); return events .filter(event => event.markets && event.markets.length > 0) .map(event => ({ title: event.title, markets: event.markets .filter(m => m.clobTokenIds) .map(m => { const prices = m.outcomePrices ? JSON.parse(m.outcomePrices) : null; const tokenIds = JSON.parse(m.clobTokenIds); return { question: m.question, marketId: m.id, tokenIds, // [yesTokenId, noTokenId] yesPrice: prices ? parseFloat(prices[0]) : null, noPrice: prices ? parseFloat(prices[1]) : null, }; }), })); }

Step 2: Complementary Arbitrage Detection

In a binary market, one of YES or NO must resolve to $1.00. If you can buy YES at $0.45 and NO at $0.50, you spend $0.95 for a guaranteed $1.00 payout — a risk-free 5.26% return.

const POLYMARKET_TAKER_FEE_BPS = 200; // 2% taker fee on Polymarket const MIN_PROFIT_BPS = parseInt(process.env.MIN_PROFIT_BPS ?? '50'); function detectComplementaryArb(market) { if (market.yesPrice === null || market.noPrice === null) return null; const totalCost = market.yesPrice + market.noPrice; const payout = 1.0; // Subtract taker fees from both legs const feeFraction = POLYMARKET_TAKER_FEE_BPS / 10_000; const yesFeeCost = market.yesPrice * feeFraction; const noFeeCost = market.noPrice * feeFraction; const totalCostWithFees = totalCost + yesFeeCost + noFeeCost; const profitBps = Math.round(((payout - totalCostWithFees) / totalCostWithFees) * 10_000); if (profitBps < MIN_PROFIT_BPS) return null; return { type: 'complementary', market: market.question, yesPrice: market.yesPrice, noPrice: market.noPrice, totalCost: totalCostWithFees, profitBps, profitPct: (profitBps / 100).toFixed(2) + '%', legs: [ { tokenId: market.tokenIds[0], outcome: 'YES', side: 'BUY', price: market.yesPrice }, { tokenId: market.tokenIds[1], outcome: 'NO', side: 'BUY', price: market.noPrice }, ], }; }

Some events have multiple markets that cover mutually exclusive outcomes. For example, “Who will win the election?” might have separate markets for each candidate. If prices across all outcomes sum to less than $1.00, there is an arbitrage.

function detectRelatedMarketArb(event) { // Only check events with multiple markets if (event.markets.length < 2) return null; // Sum the YES prices across all markets in the event. // If these represent exhaustive outcomes, one must resolve YES. let totalYesCost = 0; const legs = []; let allPriced = true; for (const market of event.markets) { if (market.yesPrice === null) { allPriced = false; break; } totalYesCost += market.yesPrice; legs.push({ tokenId: market.tokenIds[0], outcome: 'YES', side: 'BUY', price: market.yesPrice, market: market.question, }); } if (!allPriced) return null; // Apply taker fees to each leg const feeFraction = POLYMARKET_TAKER_FEE_BPS / 10_000; const totalFeeCost = legs.reduce((sum, leg) => sum + leg.price * feeFraction, 0); const totalCostWithFees = totalYesCost + totalFeeCost; // If outcomes are exhaustive, exactly one resolves to $1.00 const payout = 1.0; const profitBps = Math.round(((payout - totalCostWithFees) / totalCostWithFees) * 10_000); if (profitBps < MIN_PROFIT_BPS) return null; return { type: 'related', event: event.title, marketCount: event.markets.length, totalCost: totalCostWithFees, profitBps, profitPct: (profitBps / 100).toFixed(2) + '%', legs, }; }

Step 4: Fee-Aware Profit Calculation

A dedicated function to compute expected profit after all fees, accounting for the order size.

function calculateProfit(opportunity, orderSizeUsd) { const { totalCost, profitBps, legs } = opportunity; // Scale the order across legs proportionally const perLegUsd = orderSizeUsd / legs.length; // Gross profit per dollar of total cost const grossProfitPerDollar = profitBps / 10_000; // Total expected profit for the order const expectedProfit = orderSizeUsd * grossProfitPerDollar; return { orderSizeUsd, perLegUsd, expectedProfitUsd: expectedProfit.toFixed(4), profitBps, returnPct: (grossProfitPerDollar * 100).toFixed(2) + '%', }; }

Step 5: Dual-Leg Execution

Execute both legs of the arbitrage as quickly as possible. Leg risk (one leg fills, the other does not) is the primary danger — see the Risk Considerations section below.

import { execSync } from 'child_process'; import { createHmac } from 'crypto'; const CTF_EXCHANGE = '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E'; const CHAIN_ID = 137; const MAX_ORDER_USD = parseFloat(process.env.AGENT_MAX_ORDER_USD ?? '10'); function getWalletAddress() { const output = execSync('waap-cli whoami --json', { encoding: 'utf-8' }); const lines = output.split('\n').filter(l => l.trim().startsWith('{')); for (const line of lines) { try { const obj = JSON.parse(line); if (obj.evmWalletAddress) return obj.evmWalletAddress; } catch {} } throw new Error('Could not determine wallet address'); } function signWithWaaP(typedData) { const dataJson = JSON.stringify(typedData); const output = execSync( `waap-cli sign-typed-data --data '${dataJson.replace(/'/g, "'\\''")}'`, { encoding: 'utf-8' } ); // Output is plain text: "Signature: 0x..." const match = output.match(/Signature:\s*(0x[a-fA-F0-9]+)/); if (!match) throw new Error('Could not parse signature from waap-cli output'); return match[1]; } function buildOrder(walletAddress, tokenId, side, amountUsd) { const amountMicroUsdc = String(Math.floor(amountUsd * 1e6)); const orderMessage = { salt: Math.floor(Math.random() * 1_000_000_000), maker: walletAddress, signer: walletAddress, tokenId, makerAmount: amountMicroUsdc, takerAmount: amountMicroUsdc, expiration: '0', nonce: '0', feeRateBps: '0', side: side === 'BUY' ? 0 : 1, signatureType: 0, }; const typedData = { types: { EIP712Domain: [ { name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], Order: [ { name: 'salt', type: 'uint256' }, { name: 'maker', type: 'address' }, { name: 'signer', type: 'address' }, { name: 'tokenId', type: 'uint256' }, { name: 'makerAmount', type: 'uint256' }, { name: 'takerAmount', type: 'uint256' }, { name: 'expiration', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'feeRateBps', type: 'uint256' }, { name: 'side', type: 'uint8' }, { name: 'signatureType', type: 'uint8' }, ], }, domain: { name: 'Polymarket CTF Exchange', version: '1', chainId: CHAIN_ID, verifyingContract: CTF_EXCHANGE, }, primaryType: 'Order', message: orderMessage, }; return { typedData, orderMessage }; } function generatePmApiSign({ apiSecret, timestamp, method, requestPath, body }) { const normalizedSecret = apiSecret.replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/=]/g, ''); const secretKey = Buffer.from(normalizedSecret, 'base64'); const message = `${timestamp}${String(method).toUpperCase()}${requestPath}${body ?? ''}`; const digestBase64 = createHmac('sha256', secretKey).update(message, 'utf8').digest('base64'); return digestBase64.replace(/\+/g, '-').replace(/\//g, '_'); } async function submitOrder(orderMessage, signature, owner) { const requestPath = '/order'; const timestamp = String(Date.now()); const requestBody = JSON.stringify({ order: orderMessage, owner, signature }); const pmApiSign = generatePmApiSign({ apiSecret: process.env.POLY_API_SECRET, timestamp, method: 'POST', requestPath, body: requestBody, }); const response = await fetch('https://clob.polymarket.com/order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'PM-API-KEY': process.env.POLY_API_KEY, 'PM-API-PASSPHRASE': process.env.POLY_PASSPHRASE, 'PM-API-TIMESTAMP': timestamp, 'PM-API-SIGN': pmApiSign, }, body: requestBody, }); return await response.json(); } async function executeDualLeg(opportunity, walletAddress) { const perLegUsd = MAX_ORDER_USD / opportunity.legs.length; const results = []; // Sign all legs first, then submit in rapid succession to minimize leg risk const signedLegs = []; for (const leg of opportunity.legs) { const { typedData, orderMessage } = buildOrder(walletAddress, leg.tokenId, leg.side, perLegUsd); const signature = signWithWaaP(typedData); signedLegs.push({ orderMessage, signature }); } // Submit all legs as fast as possible for (const { orderMessage, signature } of signedLegs) { try { const result = await submitOrder(orderMessage, signature, walletAddress); results.push({ status: 'submitted', result }); } catch (err) { results.push({ status: 'failed', error: err.message }); } } return results; }

Step 6: Position Tracking

Track open positions and executed arbitrages for monitoring and risk management.

const positions = []; function recordPosition(opportunity, legResults, profit) { const position = { timestamp: new Date().toISOString(), type: opportunity.type, description: opportunity.type === 'complementary' ? opportunity.market : opportunity.event, legs: opportunity.legs.map((leg, i) => ({ ...leg, result: legResults[i], })), expectedProfitBps: opportunity.profitBps, allLegsFilled: legResults.every(r => r.status === 'submitted'), }; positions.push(position); console.log(`Position recorded: ${position.description}`); console.log(` Legs filled: ${legResults.filter(r => r.status === 'submitted').length}/${legResults.length}`); console.log(` Expected profit: ${opportunity.profitPct}`); // Alert if not all legs filled — this is a risk exposure if (!position.allLegsFilled) { console.warn(' WARNING: Not all legs filled. Position has unhedged exposure.'); } return position; } function getPositionSummary() { return { total: positions.length, fullyHedged: positions.filter(p => p.allLegsFilled).length, partialFills: positions.filter(p => !p.allLegsFilled).length, }; }

Full Working Code

// index.js import 'dotenv/config'; import { execSync } from 'child_process'; import { createHmac } from 'crypto'; // --- Configuration --- const GAMMA_API_URL = 'https://gamma-api.polymarket.com'; const CTF_EXCHANGE = '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E'; const CHAIN_ID = 137; const MAX_ORDER_USD = parseFloat(process.env.AGENT_MAX_ORDER_USD ?? '10'); const MIN_PROFIT_BPS = parseInt(process.env.MIN_PROFIT_BPS ?? '50'); const POLYMARKET_TAKER_FEE_BPS = 200; const POLL_INTERVAL_MS = parseInt(process.env.AGENT_POLL_INTERVAL_MS ?? '30000'); // --- Market Fetching --- async function getMarketsGroupedByEvent() { const response = await fetch(`${GAMMA_API_URL}/events?active=true&closed=false&limit=20`); const events = await response.json(); return events .filter(e => e.markets && e.markets.length > 0) .map(event => ({ title: event.title, markets: event.markets.filter(m => m.clobTokenIds).map(m => { const prices = m.outcomePrices ? JSON.parse(m.outcomePrices) : null; const tokenIds = JSON.parse(m.clobTokenIds); return { question: m.question, marketId: m.id, tokenIds, yesPrice: prices ? parseFloat(prices[0]) : null, noPrice: prices ? parseFloat(prices[1]) : null, }; }), })); } // --- Arbitrage Detection --- function detectComplementaryArb(market) { if (market.yesPrice === null || market.noPrice === null) return null; const totalCost = market.yesPrice + market.noPrice; const feeFraction = POLYMARKET_TAKER_FEE_BPS / 10_000; const totalCostWithFees = totalCost + (market.yesPrice + market.noPrice) * feeFraction; const profitBps = Math.round(((1.0 - totalCostWithFees) / totalCostWithFees) * 10_000); if (profitBps < MIN_PROFIT_BPS) return null; return { type: 'complementary', market: market.question, yesPrice: market.yesPrice, noPrice: market.noPrice, totalCost: totalCostWithFees, profitBps, profitPct: (profitBps / 100).toFixed(2) + '%', legs: [ { tokenId: market.tokenIds[0], outcome: 'YES', side: 'BUY', price: market.yesPrice }, { tokenId: market.tokenIds[1], outcome: 'NO', side: 'BUY', price: market.noPrice }, ], }; } function detectRelatedMarketArb(event) { if (event.markets.length < 2) return null; let totalYesCost = 0; const legs = []; for (const market of event.markets) { if (market.yesPrice === null) return null; totalYesCost += market.yesPrice; legs.push({ tokenId: market.tokenIds[0], outcome: 'YES', side: 'BUY', price: market.yesPrice, market: market.question, }); } const feeFraction = POLYMARKET_TAKER_FEE_BPS / 10_000; const totalCostWithFees = totalYesCost + totalYesCost * feeFraction; const profitBps = Math.round(((1.0 - totalCostWithFees) / totalCostWithFees) * 10_000); if (profitBps < MIN_PROFIT_BPS) return null; return { type: 'related', event: event.title, marketCount: event.markets.length, totalCost: totalCostWithFees, profitBps, profitPct: (profitBps / 100).toFixed(2) + '%', legs, }; } // --- WaaP CLI Helpers --- function getWalletAddress() { const output = execSync('waap-cli whoami --json', { encoding: 'utf-8' }); const lines = output.split('\n').filter(l => l.trim().startsWith('{')); for (const line of lines) { try { const obj = JSON.parse(line); if (obj.evmWalletAddress) return obj.evmWalletAddress; } catch {} } throw new Error('Could not determine wallet address'); } function signWithWaaP(typedData) { const dataJson = JSON.stringify(typedData); const output = execSync( `waap-cli sign-typed-data --data '${dataJson.replace(/'/g, "'\\''")}'`, { encoding: 'utf-8' } ); const match = output.match(/Signature:\s*(0x[a-fA-F0-9]+)/); if (!match) throw new Error('Could not parse signature from waap-cli output'); return match[1]; } function buildOrder(walletAddress, tokenId, side, amountUsd) { const amountMicroUsdc = String(Math.floor(amountUsd * 1e6)); const orderMessage = { salt: Math.floor(Math.random() * 1_000_000_000), maker: walletAddress, signer: walletAddress, tokenId, makerAmount: amountMicroUsdc, takerAmount: amountMicroUsdc, expiration: '0', nonce: '0', feeRateBps: '0', side: side === 'BUY' ? 0 : 1, signatureType: 0, }; const typedData = { types: { EIP712Domain: [ { name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], Order: [ { name: 'salt', type: 'uint256' }, { name: 'maker', type: 'address' }, { name: 'signer', type: 'address' }, { name: 'tokenId', type: 'uint256' }, { name: 'makerAmount', type: 'uint256' }, { name: 'takerAmount', type: 'uint256' }, { name: 'expiration', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'feeRateBps', type: 'uint256' }, { name: 'side', type: 'uint8' }, { name: 'signatureType', type: 'uint8' }, ], }, domain: { name: 'Polymarket CTF Exchange', version: '1', chainId: CHAIN_ID, verifyingContract: CTF_EXCHANGE, }, primaryType: 'Order', message: orderMessage, }; return { typedData, orderMessage }; } function generatePmApiSign({ apiSecret, timestamp, method, requestPath, body }) { const normalizedSecret = apiSecret.replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/=]/g, ''); const secretKey = Buffer.from(normalizedSecret, 'base64'); const msg = `${timestamp}${String(method).toUpperCase()}${requestPath}${body ?? ''}`; const digestBase64 = createHmac('sha256', secretKey).update(msg, 'utf8').digest('base64'); return digestBase64.replace(/\+/g, '-').replace(/\//g, '_'); } async function submitOrder(orderMessage, signature, owner) { const requestPath = '/order'; const timestamp = String(Date.now()); const requestBody = JSON.stringify({ order: orderMessage, owner, signature }); const pmApiSign = generatePmApiSign({ apiSecret: process.env.POLY_API_SECRET, timestamp, method: 'POST', requestPath, body: requestBody, }); const response = await fetch('https://clob.polymarket.com/order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'PM-API-KEY': process.env.POLY_API_KEY, 'PM-API-PASSPHRASE': process.env.POLY_PASSPHRASE, 'PM-API-TIMESTAMP': timestamp, 'PM-API-SIGN': pmApiSign, }, body: requestBody, }); return await response.json(); } // --- Dual-Leg Execution --- async function executeDualLeg(opportunity, walletAddress) { const perLegUsd = MAX_ORDER_USD / opportunity.legs.length; // Sign all legs first to minimize time between submissions const signedLegs = []; for (const leg of opportunity.legs) { const { typedData, orderMessage } = buildOrder(walletAddress, leg.tokenId, leg.side, perLegUsd); const signature = signWithWaaP(typedData); signedLegs.push({ orderMessage, signature }); } // Submit all legs in rapid succession const results = []; for (const { orderMessage, signature } of signedLegs) { try { const result = await submitOrder(orderMessage, signature, walletAddress); results.push({ status: 'submitted', result }); } catch (err) { results.push({ status: 'failed', error: err.message }); } } return results; } // --- Position Tracking --- const positions = []; function recordPosition(opportunity, legResults) { const position = { timestamp: new Date().toISOString(), type: opportunity.type, description: opportunity.type === 'complementary' ? opportunity.market : opportunity.event, profitBps: opportunity.profitBps, profitPct: opportunity.profitPct, allLegsFilled: legResults.every(r => r.status === 'submitted'), legCount: legResults.length, filledCount: legResults.filter(r => r.status === 'submitted').length, }; positions.push(position); return position; } // --- Main Loop --- async function main() { const walletAddress = getWalletAddress(); console.log(`Arbitrage agent started. Wallet: ${walletAddress}`); console.log(`Min profit: ${MIN_PROFIT_BPS} bps, Max order: $${MAX_ORDER_USD}, Poll: ${POLL_INTERVAL_MS / 1000}s`); while (true) { try { const events = await getMarketsGroupedByEvent(); let opportunities = []; // Scan for complementary arbs within each market for (const event of events) { for (const market of event.markets) { const arb = detectComplementaryArb(market); if (arb) opportunities.push(arb); } // Scan for related market arbs across each event const relatedArb = detectRelatedMarketArb(event); if (relatedArb) opportunities.push(relatedArb); } if (opportunities.length === 0) { console.log(`Scanned ${events.length} events — no arbitrage found.`); } else { console.log(`Found ${opportunities.length} arbitrage opportunities:`); for (const opp of opportunities) { const label = opp.type === 'complementary' ? opp.market : opp.event; console.log(` [${opp.type}] ${label} — profit: ${opp.profitPct} (${opp.legs.length} legs)`); const legResults = await executeDualLeg(opp, walletAddress); const position = recordPosition(opp, legResults); if (position.allLegsFilled) { console.log(` All ${position.legCount} legs filled.`); } else { console.warn(` WARNING: Only ${position.filledCount}/${position.legCount} legs filled. Unhedged exposure.`); } } } // Periodic position summary if (positions.length > 0 && positions.length % 5 === 0) { const summary = { total: positions.length, fullyHedged: positions.filter(p => p.allLegsFilled).length, partialFills: positions.filter(p => !p.allLegsFilled).length, }; console.log(`Position summary: ${JSON.stringify(summary)}`); } } catch (err) { console.error('Tick error:', err.message); } await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); } } main().catch(err => { console.error('Fatal:', err); process.exit(1); });

Risk Considerations

Leg Risk

The biggest risk in prediction market arbitrage is leg risk: one leg of your trade fills but the other does not. This leaves you with an unhedged directional position instead of a risk-free arbitrage.

Mitigation strategies:

  • Sign all legs before submitting any — the agent does this already, minimizing the gap between submissions.
  • Use limit orders at or above current market price — increases fill probability.
  • Set short expiration times — if a leg does not fill quickly, cancel both.
  • Track partial fills — the position tracker flags unhedged exposure so you can manually close if needed.

Latency

Polymarket prices move fast. By the time you detect an opportunity, sign two orders via WaaP CLI, and submit them, the prices may have shifted. The MIN_PROFIT_BPS threshold should account for this slippage.

Liquidity

Low-liquidity markets may show attractive spreads but lack the depth to fill meaningful order sizes. Check order book depth before sizing up.

Fee Changes

The agent assumes a 2% taker fee (POLYMARKET_TAKER_FEE_BPS = 200). If Polymarket adjusts fees, update this constant. Incorrect fee assumptions turn profitable-looking trades into losses.


Next Steps

  • Order book depth checks: Query the CLOB order book before trading to verify liquidity at the target price.
  • Expiration-based orders: Set order expiration to 30-60 seconds so stale legs auto-cancel.
  • Multi-event scanning: Increase the Gamma API limit and scan more events per tick.
  • Alerting: Send notifications (Telegram, email) when partial fills leave unhedged exposure.
  • Set Privileges: Use waap-cli policy set --daily-spend-limit 100 to cap the agent’s daily capital deployment.
Last updated on