Building a Polymarket Copy-Trading Bot with PolyEdge SDK

This guide demonstrates how to stream pre-block Polymarket mempool trades with the PolyEdge TypeScript SDK (polyedge) and execute automated copy orders with the official Polymarket SDK (@polymarket/client).

[PolyEdge Mempool Stream] ──(tx pre-block)──> [Match Target Trader] ──> [Polymarket Order API]

1. Project Setup & Dependencies

Initialize a Node.js TypeScript project:

mkdir poly-copy-bot && cd poly-copy-bot
npm init -y
npm install @polymarket/client polyedge dotenv viem
npm install -D typescript tsx @types/node
npx tsc --init

Create a .env configuration file:

# PolyEdge API Key
POLYEDGE_API_KEY=pe_live_xxxxxxxxxxxxxxxx

# Polymarket Account Credentials
POLYMARKET_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
POLYMARKET_DEPOSIT_WALLET=0xYourPolymarketDepositWalletAddress

# Target Trader Addresses to Copy (comma-separated, lower-case)
TARGET_ADDRESSES=0x34dd4a4b70eaf79a17878f7938263c801d4dfd83,0x4bf459194025b2ca11a2db2deb9ef2a08c467021

# Sizing Mode: FIXED or PROPORTIONAL
COPY_MODE=FIXED

# FIXED Mode: Fixed USD notional per trade
FIXED_AMOUNT_USD=50

# PROPORTIONAL Mode: Ratio (0.1 = 10%) and Max Spend Ceiling (USD)
COPY_RATIO=0.1
MAX_SPEND_USD=200

# Order Type: FAK (Fill-and-Kill) or FOK (Fill-or-Kill)
ORDER_TYPE=FAK

# Slippage Tolerance (0.03 = 3%)
SLIPPAGE_TOLERANCE_PCT=0.03

2. Order Types & Sizing Strategies

Order Execution Types (OrderType)

  • OrderType.FAK (Fill-And-Kill / Recommended): Matches immediately against resting book liquidity. Any unfilled quantity is immediately cancelled without resting on the book.
  • OrderType.FOK (Fill-Or-Kill): The order must fill completely at the specified price limits, or the entire order is rejected.

Sizing Modes

  • Fixed (FIXED): Commits a fixed dollar amount (e.g. $50) per signal.
  • Proportional (PROPORTIONAL): Replicates a fraction (e.g. 10%) of the target trader's fill notional, capped by MAX_SPEND_USD.

3. Data Models (types.ts)

Underlying transaction types are provided directly by polyedge. We only define application-level signals and configuration:

// types.ts
import type { OrderType } from "@polymarket/client";

export interface CopySignal {
  txHash: string;
  targetAddress: string;
  role: "taker" | "maker";
  assetId: string;
  side: "BUY" | "SELL";
  price: number;
  shares: number;
  notionalUsd: number;
  marketQuestion: string;
}

export interface BotConfig {
  polymarketPrivateKey: `0x${string}`;
  polymarketDepositWallet: `0x${string}`;
  copyMode: "FIXED" | "PROPORTIONAL";
  fixedAmountUsd: number;
  copyRatio: number;
  maxSpendUsd: number;
  orderType: OrderType.FAK | OrderType.FOK;
  slippageTolerancePct: number;
}

4. Initialize & Connect Stream (stream.ts)

Use the polyedge SDK to provision a stream filtered by target wallet addresses and establish a connection with built-in 45s keep-alive watchdog and automatic reconnect:

// stream.ts
import { PolyEdgeClient, type PolyEdgeStream } from "polyedge";

export async function createAndConnectStream(
  apiKey: string,
  targetAddresses: string[]
): Promise<PolyEdgeStream> {
  const client = new PolyEdgeClient({ apiKey });

  // 1. Provision a stream subscribed to target addresses
  const streamConfig = await client.streams.create({
    name: `CopyBot-${Date.now()}`,
    addresses: targetAddresses,
  });

  if (!streamConfig.id) {
    throw new Error("Failed to create stream: ID not returned");
  }

  console.log(`[PolyEdge] Stream provisioned: ${streamConfig.id}`);

  // 2. Connect with 45s watchdog
  return client.streams.connect(streamConfig.id, {
    heartbeatTimeoutMs: 45_000,
  });
}

5. Trade Attribution & Matching (matcher.ts)

In Polymarket's binary market architecture, each market contains two outcome tokens. token_ids_index strictly evaluates to 0 or 1, indexing the traded token asset ID.

Inspect both the aggressor (taker) and resting limit orders (makers) to identify target trader participation:

// matcher.ts
import type { LiveTransaction } from "polyedge";
import type { CopySignal } from "./types";

export function extractCopySignals(
  tx: LiveTransaction,
  targetAddresses: Set<string>
): CopySignal[] {
  const signals: CopySignal[] = [];
  const market = tx.market;

  if (!market || !market.token_ids || market.token_ids.length < 2) {
    return signals;
  }

  const resolveAssetId = (index?: number): string | null => {
    return (index === 0 || index === 1) && market.token_ids ? market.token_ids[index] : null;
  };

  // 1. Inspect Taker (Aggressor fill)
  if (tx.taker?.user?.address) {
    const takerAddr = tx.taker.user.address.toLowerCase();
    if (targetAddresses.has(takerAddr)) {
      const assetId = resolveAssetId(tx.taker.token_ids_index);
      const shares = Number(tx.taker.shares) / 1e6;
      const notionalUsd = Number(tx.taker.usdc) / 1e6;
      const price = shares > 0 ? notionalUsd / shares : 0;

      if (assetId && shares > 0 && price > 0 && tx.taker.side && tx.tx_hash) {
        signals.push({
          txHash: tx.tx_hash,
          targetAddress: takerAddr,
          role: "taker",
          assetId,
          side: tx.taker.side,
          price,
          shares,
          notionalUsd,
          marketQuestion: market.question || "",
        });
      }
    }
  }

  // 2. Inspect Makers (Passive limit orders filled in match)
  if (Array.isArray(tx.makers)) {
    for (const maker of tx.makers) {
      if (!maker.user?.address) continue;
      const makerAddr = maker.user.address.toLowerCase();
      if (targetAddresses.has(makerAddr)) {
        const assetId = resolveAssetId(maker.token_ids_index);
        const shares = Number(maker.shares) / 1e6;
        const notionalUsd = Number(maker.usdc) / 1e6;
        const price = shares > 0 ? notionalUsd / shares : 0;

        if (assetId && shares > 0 && price > 0 && maker.side && tx.tx_hash) {
          signals.push({
            txHash: tx.tx_hash,
            targetAddress: makerAddr,
            role: "maker",
            assetId,
            side: maker.side,
            price,
            shares,
            notionalUsd,
            marketQuestion: market.question || "",
          });
        }
      }
    }
  }

  return signals;
}

6. Order Execution (executor.ts)

Initialize the official Polymarket SDK (@polymarket/client) and submit market orders with price slippage bounds:

// executor.ts
import { Client, Side as OrderSide, OrderType } from "@polymarket/client";
import { privateKeyToAccount } from "viem/accounts";
import type { BotConfig, CopySignal } from "./types";

const CLOB_API_URL = "https://clob.polymarket.com";
const POLYGON_CHAIN_ID = 137;

export async function createExecutor(config: BotConfig) {
  const account = privateKeyToAccount(config.polymarketPrivateKey);

  const client = new Client(
    CLOB_API_URL,
    POLYGON_CHAIN_ID,
    account,
    config.polymarketDepositWallet
  );

  async function executeOrder(signal: CopySignal) {
    const orderUsd = config.copyMode === "FIXED"
      ? config.fixedAmountUsd
      : Math.min(signal.notionalUsd * config.copyRatio, config.maxSpendUsd);

    if (orderUsd <= 0 || signal.price <= 0) return null;

    // Slippage boundaries: max price for buy, min price for sell
    const maxPrice = signal.side === "BUY"
      ? Math.min(0.99, Number((signal.price * (1 + config.slippageTolerancePct)).toFixed(4)))
      : undefined;

    const minPrice = signal.side === "SELL"
      ? Math.max(0.01, Number((signal.price * (1 - config.slippageTolerancePct)).toFixed(4)))
      : undefined;

    const shares = Number((orderUsd / signal.price).toFixed(2));
    if (shares <= 0) return null;

    console.log(
      `[Copy Order] Target: ${signal.targetAddress} (${signal.role}) | ` +
      `Side: ${signal.side} | USD: $${orderUsd.toFixed(2)} | ` +
      `${signal.side === "BUY" ? `MaxPrice: ${maxPrice}` : `MinPrice: ${minPrice}`}`
    );

    return signal.side === "BUY"
      ? client.placeMarketOrder({
          assetId: signal.assetId,
          side: OrderSide.BUY,
          amount: orderUsd,
          orderType: config.orderType,
          ...(maxPrice ? { maxPrice } : {}),
        })
      : client.placeMarketOrder({
          assetId: signal.assetId,
          side: OrderSide.SELL,
          shares,
          orderType: config.orderType,
          ...(minPrice ? { minPrice } : {}),
        });
  }

  return { client, executeOrder };
}

7. Main Application Entry Point (index.ts)

Consume incoming mempool transactions with the SDK's async iterator (for await) and submit copy orders:

// index.ts
import "dotenv/config";
import { OrderType } from "@polymarket/client";
import { createAndConnectStream } from "./stream";
import { extractCopySignals } from "./matcher";
import { createExecutor } from "./executor";

async function main() {
  const polyedgeApiKey = process.env.POLYEDGE_API_KEY;
  const privateKey = process.env.POLYMARKET_PRIVATE_KEY as `0x${string}`;
  const depositWallet = process.env.POLYMARKET_DEPOSIT_WALLET as `0x${string}`;
  const rawTargetAddresses = process.env.TARGET_ADDRESSES || "";

  if (!polyedgeApiKey || !privateKey || !depositWallet || !rawTargetAddresses) {
    throw new Error("Missing required environment variables in .env");
  }

  const targetAddresses = rawTargetAddresses
    .split(",")
    .map((addr) => addr.trim().toLowerCase())
    .filter(Boolean);
  const targetSet = new Set(targetAddresses);

  // 1. Initialize Polymarket client
  const executor = await createExecutor({
    polymarketPrivateKey: privateKey,
    polymarketDepositWallet: depositWallet,
    copyMode: process.env.COPY_MODE?.toUpperCase() === "PROPORTIONAL" ? "PROPORTIONAL" : "FIXED",
    fixedAmountUsd: Number(process.env.FIXED_AMOUNT_USD || "50"),
    copyRatio: Number(process.env.COPY_RATIO || "0.1"),
    maxSpendUsd: Number(process.env.MAX_SPEND_USD || "200"),
    orderType: process.env.ORDER_TYPE?.toUpperCase() === "FOK" ? OrderType.FOK : OrderType.FAK,
    slippageTolerancePct: Number(process.env.SLIPPAGE_TOLERANCE_PCT || "0.03"),
  });

  // 2. Provision and connect stream via PolyEdge SDK
  const stream = await createAndConnectStream(polyedgeApiKey, targetAddresses);
  const processedTxHashes = new Set<string>();

  process.on("SIGINT", () => {
    console.log("\n[Shutdown] Closing stream and exiting...");
    stream.destroy();
    process.exit(0);
  });

  console.log(`[Listening] Waiting for matching mempool events...`);

  // 3. Consume transactions via async iterator
  for await (const tx of stream) {
    if (!tx.tx_hash) continue;
    if (processedTxHashes.has(tx.tx_hash)) continue;
    processedTxHashes.add(tx.tx_hash);

    // Keep sliding window of 5,000 hashes
    if (processedTxHashes.size > 5000) {
      const oldest = processedTxHashes.values().next().value;
      if (oldest) processedTxHashes.delete(oldest);
    }

    const signals = extractCopySignals(tx, targetSet);
    for (const signal of signals) {
      try {
        const result = await executor.executeOrder(signal);
        console.log(`[Order Result]`, result);
      } catch (err) {
        console.error(`[Order Failed] Error copying ${signal.txHash}:`, err);
      }
    }
  }
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

8. Run & Verify

Run the bot with tsx:

npx tsx index.ts

When a target trader matches on Polymarket, execution logs appear immediately:

[PolyEdge] Stream provisioned: 9bae9005-9ef1-4f17-8658-36ea54536836
[Listening] Waiting for matching mempool events...
[Copy Order] Target: 0x4bf459194025b2ca11a2db2deb9ef2a08c467021 (taker) | Side: BUY | USD: $50.00 | MaxPrice: 0.5047
[Order Result] { orderId: '0x...', status: 'MATCHED' }