├── src ├── utils │ └── __init__.py ├── risk │ ├── __init__.py │ └── risk_manager.py ├── execution │ ├── __init__.py │ └── order_executor.py ├── market_maker │ ├── __init__.py │ └── quote_engine.py ├── __init__.py ├── inventory │ ├── __init__.py │ └── inventory_manager.py ├── services │ ├── __init__.py │ ├── metrics.py │ └── auto_redeem.py ├── polymarket │ ├── __init__.py │ ├── order_signer.py │ ├── rest_client.py │ └── websocket_client.py ├── logging_config.py ├── config.py └── main.py ├── requirements.txt ├── Dockerfile ├── docker-compose.yml ├── .gitignore ├── pyproject.toml └── README.md /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions for Polymarket market maker bot.""" 2 | 3 | -------------------------------------------------------------------------------- /src/risk/__init__.py: -------------------------------------------------------------------------------- 1 | from src.risk.risk_manager import RiskManager 2 | 3 | __all__ = ["RiskManager"] 4 | 5 | -------------------------------------------------------------------------------- /src/execution/__init__.py: -------------------------------------------------------------------------------- 1 | from src.execution.order_executor import OrderExecutor 2 | 3 | __all__ = ["OrderExecutor"] 4 | 5 | -------------------------------------------------------------------------------- /src/market_maker/__init__.py: -------------------------------------------------------------------------------- 1 | from src.market_maker.quote_engine import Quote, QuoteEngine 2 | 3 | __all__ = ["Quote", "QuoteEngine"] 4 | 5 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | """Polymarket CLOB market-making bot with optimized inventory management and spread farming.""" 2 | 3 | __version__ = "0.1.0" 4 | 5 | -------------------------------------------------------------------------------- /src/inventory/__init__.py: -------------------------------------------------------------------------------- 1 | from src.inventory.inventory_manager import Inventory, InventoryManager 2 | 3 | __all__ = ["Inventory", "InventoryManager"] 4 | 5 | -------------------------------------------------------------------------------- /src/services/__init__.py: -------------------------------------------------------------------------------- 1 | from src.services.auto_redeem import AutoRedeem 2 | from src.services.metrics import start_metrics_server 3 | 4 | __all__ = ["start_metrics_server", "AutoRedeem"] 5 | 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | websockets>=12.0 2 | httpx>=0.27.0 3 | web3>=6.15.0 4 | eth-account>=0.10.0 5 | pydantic>=2.8.2 6 | pydantic-settings>=2.1.0 7 | python-dotenv>=1.0.1 8 | structlog>=24.1.0 9 | prometheus-client>=0.20.0 10 | aiohttp>=3.9.0 11 | 12 | -------------------------------------------------------------------------------- /src/polymarket/__init__.py: -------------------------------------------------------------------------------- 1 | from src.polymarket.order_signer import OrderSigner 2 | from src.polymarket.rest_client import PolymarketRestClient 3 | from src.polymarket.websocket_client import PolymarketWebSocketClient 4 | 5 | __all__ = ["PolymarketRestClient", "PolymarketWebSocketClient", "OrderSigner"] 6 | 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY src/ ./src/ 9 | COPY pyproject.toml . 10 | 11 | ENV PYTHONPATH=/app 12 | ENV PYTHONUNBUFFERED=1 13 | 14 | EXPOSE 9305 15 | 16 | CMD ["python", "-m", "src.main"] 17 | 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | market-maker-bot: 5 | build: . 6 | container_name: polymarket-market-maker-bot 7 | env_file: 8 | - .env 9 | environment: 10 | - PYTHONUNBUFFERED=1 11 | restart: unless-stopped 12 | ports: 13 | - "9305:9305" 14 | logging: 15 | driver: "json-file" 16 | options: 17 | max-size: "10m" 18 | max-file: "5" 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual environments 24 | .venv/ 25 | venv/ 26 | ENV/ 27 | env/ 28 | 29 | # IDE 30 | .vscode/ 31 | .idea/ 32 | *.swp 33 | *.swo 34 | *~ 35 | 36 | # Environment 37 | .env 38 | .env.local 39 | 40 | # Logs 41 | *.log 42 | logs/ 43 | 44 | # OS 45 | .DS_Store 46 | Thumbs.db 47 | 48 | # Testing 49 | .pytest_cache/ 50 | .coverage 51 | htmlcov/ 52 | 53 | # Docker 54 | docker-compose.override.yml 55 | 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "polymarket-market-maker-bot" 3 | version = "0.1.0" 4 | description = "Professional Polymarket CLOB market-making bot with inventory management, spread farming, and optimized cancel/replace cycles." 5 | authors = [{ name = "Polymarket Market Maker" }] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "websockets>=12.0", 10 | "httpx>=0.27.0", 11 | "web3>=6.15.0", 12 | "eth-account>=0.10.0", 13 | "pydantic>=2.8.2", 14 | "pydantic-settings>=2.1.0", 15 | "python-dotenv>=1.0.1", 16 | "structlog>=24.1.0", 17 | "prometheus-client>=0.20.0", 18 | "aiohttp>=3.9.0", 19 | ] 20 | 21 | [project.optional-dependencies] 22 | dev = [ 23 | "black>=24.3.0", 24 | "ruff>=0.6.8", 25 | "pytest>=8.3.2", 26 | "pytest-asyncio>=0.23.8", 27 | ] 28 | 29 | [tool.black] 30 | line-length = 100 31 | target-version = ["py311"] 32 | 33 | [tool.ruff] 34 | line-length = 100 35 | select = ["E", "F", "I"] 36 | target-version = "py311" 37 | 38 | -------------------------------------------------------------------------------- /src/logging_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | from typing import Literal 6 | 7 | import structlog 8 | 9 | 10 | def configure_logging(level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO") -> None: 11 | logging.basicConfig( 12 | format="%(message)s", 13 | stream=sys.stdout, 14 | level=level, 15 | ) 16 | 17 | structlog.configure( 18 | processors=[ 19 | structlog.processors.TimeStamper(fmt="iso"), 20 | structlog.stdlib.add_logger_name, 21 | structlog.stdlib.add_log_level, 22 | structlog.processors.StackInfoRenderer(), 23 | structlog.processors.format_exc_info, 24 | structlog.processors.EventRenamer("message"), 25 | structlog.processors.JSONRenderer(), 26 | ], 27 | wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, level)), 28 | logger_factory=structlog.stdlib.LoggerFactory(), 29 | cache_logger_on_first_use=True, 30 | ) 31 | 32 | -------------------------------------------------------------------------------- /src/polymarket/order_signer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from eth_account import Account 6 | from eth_account.messages import encode_defunct 7 | from web3 import Web3 8 | import structlog 9 | 10 | logger = structlog.get_logger(__name__) 11 | 12 | 13 | class OrderSigner: 14 | def __init__(self, private_key: str): 15 | self.account = Account.from_key(private_key) 16 | self.web3 = Web3() 17 | 18 | def sign_order(self, order: dict[str, Any]) -> str: 19 | try: 20 | order_hash = self._hash_order(order) 21 | message = encode_defunct(text=order_hash) 22 | signed_message = self.account.sign_message(message) 23 | return signed_message.signature.hex() 24 | except Exception as e: 25 | logger.error("order_signing_failed", error=str(e)) 26 | raise 27 | 28 | def _hash_order(self, order: dict[str, Any]) -> str: 29 | parts = [ 30 | str(order.get("market", "")), 31 | str(order.get("side", "")), 32 | str(order.get("size", "")), 33 | str(order.get("price", "")), 34 | str(order.get("time", "")), 35 | str(order.get("salt", "")), 36 | ] 37 | return ":".join(parts) 38 | 39 | def get_address(self) -> str: 40 | return self.account.address 41 | 42 | -------------------------------------------------------------------------------- /src/services/metrics.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from prometheus_client import Counter, Gauge, Histogram, start_http_server 4 | 5 | orders_placed_counter = Counter( 6 | "pm_mm_orders_placed_total", "Total orders placed", ["side", "outcome"] 7 | ) 8 | orders_filled_counter = Counter( 9 | "pm_mm_orders_filled_total", "Total orders filled", ["side", "outcome"] 10 | ) 11 | orders_cancelled_counter = Counter( 12 | "pm_mm_orders_cancelled_total", "Total orders cancelled" 13 | ) 14 | inventory_gauge = Gauge( 15 | "pm_mm_inventory", "Current inventory positions", ["type"] 16 | ) 17 | exposure_gauge = Gauge("pm_mm_exposure_usd", "Current net exposure in USD") 18 | spread_gauge = Gauge("pm_mm_spread_bps", "Current spread in basis points") 19 | profit_gauge = Gauge("pm_mm_profit_usd", "Cumulative profit in USD") 20 | quote_latency_histogram = Histogram( 21 | "pm_mm_quote_latency_ms", 22 | "Quote generation and placement latency in milliseconds", 23 | buckets=[10, 50, 100, 250, 500, 1000], 24 | ) 25 | 26 | 27 | def start_metrics_server(host: str, port: int) -> None: 28 | start_http_server(port, addr=host) 29 | 30 | 31 | def record_order_placed(side: str, outcome: str) -> None: 32 | orders_placed_counter.labels(side=side, outcome=outcome).inc() 33 | 34 | 35 | def record_order_filled(side: str, outcome: str) -> None: 36 | orders_filled_counter.labels(side=side, outcome=outcome).inc() 37 | 38 | 39 | def record_order_cancelled() -> None: 40 | orders_cancelled_counter.inc() 41 | 42 | 43 | def record_inventory(inventory_type: str, value: float) -> None: 44 | inventory_gauge.labels(type=inventory_type).set(value) 45 | 46 | 47 | def record_exposure(exposure_usd: float) -> None: 48 | exposure_gauge.set(exposure_usd) 49 | 50 | 51 | def record_spread(spread_bps: float) -> None: 52 | spread_gauge.set(spread_bps) 53 | 54 | 55 | def record_profit(profit_usd: float) -> None: 56 | profit_gauge.set(profit_usd) 57 | 58 | 59 | def record_quote_latency(latency_ms: float) -> None: 60 | quote_latency_histogram.observe(latency_ms) 61 | 62 | -------------------------------------------------------------------------------- /src/services/auto_redeem.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import httpx 6 | import structlog 7 | 8 | from src.config import Settings 9 | 10 | logger = structlog.get_logger(__name__) 11 | 12 | 13 | class AutoRedeem: 14 | def __init__(self, settings: Settings): 15 | self.settings = settings 16 | self.client = httpx.AsyncClient(timeout=30.0) 17 | 18 | async def check_redeemable_positions(self, address: str) -> list[dict[str, Any]]: 19 | try: 20 | response = await self.client.get( 21 | f"{self.settings.polymarket_api_url}/positions", 22 | params={"user": address, "redeemable": "true"}, 23 | ) 24 | response.raise_for_status() 25 | return response.json() 26 | except Exception as e: 27 | logger.error("redeemable_positions_check_failed", error=str(e)) 28 | return [] 29 | 30 | async def redeem_position(self, position_id: str) -> bool: 31 | try: 32 | response = await self.client.post( 33 | f"{self.settings.polymarket_api_url}/redeem/{position_id}", 34 | ) 35 | response.raise_for_status() 36 | logger.info("position_redeemed", position_id=position_id) 37 | return True 38 | except Exception as e: 39 | logger.error("position_redeem_failed", position_id=position_id, error=str(e)) 40 | return False 41 | 42 | async def auto_redeem_all(self, address: str) -> int: 43 | if not self.settings.auto_redeem_enabled: 44 | return 0 45 | 46 | redeemable = await self.check_redeemable_positions(address) 47 | redeemed = 0 48 | 49 | for position in redeemable: 50 | value_usd = float(position.get("value", 0)) 51 | if value_usd >= self.settings.redeem_threshold_usd: 52 | if await self.redeem_position(position.get("id")): 53 | redeemed += 1 54 | 55 | logger.info("auto_redeem_completed", redeemed=redeemed, total=len(redeemable)) 56 | return redeemed 57 | 58 | async def close(self): 59 | await self.client.aclose() 60 | 61 | -------------------------------------------------------------------------------- /src/polymarket/rest_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import httpx 6 | import structlog 7 | 8 | from src.config import Settings 9 | 10 | logger = structlog.get_logger(__name__) 11 | 12 | 13 | class PolymarketRestClient: 14 | def __init__(self, settings: Settings): 15 | self.settings = settings 16 | self.base_url = settings.polymarket_api_url 17 | self.client = httpx.AsyncClient(timeout=30.0) 18 | 19 | async def get_markets(self, active: bool = True, closed: bool = False) -> list[dict[str, Any]]: 20 | try: 21 | params = {"active": str(active).lower(), "closed": str(closed).lower()} 22 | response = await self.client.get(f"{self.base_url}/markets", params=params) 23 | response.raise_for_status() 24 | return response.json() 25 | except Exception as e: 26 | logger.error("markets_fetch_failed", error=str(e)) 27 | raise 28 | 29 | async def get_orderbook(self, market_id: str) -> dict[str, Any]: 30 | try: 31 | response = await self.client.get(f"{self.base_url}/book", params={"market": market_id}) 32 | response.raise_for_status() 33 | return response.json() 34 | except Exception as e: 35 | logger.error("orderbook_fetch_failed", market_id=market_id, error=str(e)) 36 | raise 37 | 38 | async def get_market_info(self, market_id: str) -> dict[str, Any]: 39 | try: 40 | response = await self.client.get(f"{self.base_url}/markets/{market_id}") 41 | response.raise_for_status() 42 | return response.json() 43 | except Exception as e: 44 | logger.error("market_info_fetch_failed", market_id=market_id, error=str(e)) 45 | raise 46 | 47 | async def get_balances(self, address: str) -> dict[str, Any]: 48 | try: 49 | response = await self.client.get(f"{self.base_url}/balances", params={"user": address}) 50 | response.raise_for_status() 51 | return response.json() 52 | except Exception as e: 53 | logger.error("balances_fetch_failed", address=address, error=str(e)) 54 | raise 55 | 56 | async def get_open_orders(self, address: str, market_id: str | None = None) -> list[dict[str, Any]]: 57 | try: 58 | params = {"user": address} 59 | if market_id: 60 | params["market"] = market_id 61 | response = await self.client.get(f"{self.base_url}/open-orders", params=params) 62 | response.raise_for_status() 63 | return response.json() 64 | except Exception as e: 65 | logger.error("open_orders_fetch_failed", address=address, error=str(e)) 66 | raise 67 | 68 | async def close(self): 69 | await self.client.aclose() 70 | 71 | -------------------------------------------------------------------------------- /src/polymarket/websocket_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import json 5 | from typing import Any, Callable 6 | 7 | import structlog 8 | import websockets 9 | 10 | from src.config import Settings 11 | 12 | logger = structlog.get_logger(__name__) 13 | 14 | 15 | class PolymarketWebSocketClient: 16 | def __init__(self, settings: Settings): 17 | self.settings = settings 18 | self.ws_url = settings.polymarket_ws_url 19 | self.websocket: websockets.WebSocketServerProtocol | None = None 20 | self.message_handlers: dict[str, Callable] = {} 21 | self.running = False 22 | 23 | def register_handler(self, message_type: str, handler: Callable): 24 | self.message_handlers[message_type] = handler 25 | 26 | async def connect(self): 27 | try: 28 | self.websocket = await websockets.connect(self.ws_url) 29 | logger.info("websocket_connected", url=self.ws_url) 30 | self.running = True 31 | except Exception as e: 32 | logger.error("websocket_connection_failed", error=str(e)) 33 | raise 34 | 35 | async def subscribe_orderbook(self, market_id: str): 36 | if not self.websocket: 37 | await self.connect() 38 | 39 | message = { 40 | "type": "subscribe", 41 | "channel": "l2_book", 42 | "market": market_id, 43 | } 44 | await self.websocket.send(json.dumps(message)) 45 | logger.info("orderbook_subscribed", market_id=market_id) 46 | 47 | async def subscribe_trades(self, market_id: str): 48 | if not self.websocket: 49 | await self.connect() 50 | 51 | message = { 52 | "type": "subscribe", 53 | "channel": "trades", 54 | "market": market_id, 55 | } 56 | await self.websocket.send(json.dumps(message)) 57 | logger.info("trades_subscribed", market_id=market_id) 58 | 59 | async def listen(self): 60 | if not self.websocket: 61 | await self.connect() 62 | 63 | while self.running: 64 | try: 65 | message = await self.websocket.recv() 66 | data = json.loads(message) 67 | 68 | message_type = data.get("type") 69 | if message_type and message_type in self.message_handlers: 70 | await self.message_handlers[message_type](data) 71 | 72 | except websockets.exceptions.ConnectionClosed: 73 | logger.warning("websocket_connection_closed") 74 | await asyncio.sleep(5) 75 | await self.connect() 76 | except Exception as e: 77 | logger.error("websocket_listen_error", error=str(e)) 78 | await asyncio.sleep(1) 79 | 80 | async def close(self): 81 | self.running = False 82 | if self.websocket: 83 | await self.websocket.close() 84 | 85 | -------------------------------------------------------------------------------- /src/risk/risk_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import structlog 6 | 7 | from src.config import Settings 8 | from src.inventory.inventory_manager import InventoryManager 9 | 10 | logger = structlog.get_logger(__name__) 11 | 12 | 13 | class RiskManager: 14 | def __init__(self, settings: Settings, inventory_manager: InventoryManager): 15 | self.settings = settings 16 | self.inventory_manager = inventory_manager 17 | 18 | def check_exposure_limits(self, proposed_size_usd: float, side: str) -> bool: 19 | current_exposure = self.inventory_manager.inventory.net_exposure_usd 20 | 21 | if side == "BUY": 22 | new_exposure = current_exposure + proposed_size_usd 23 | if new_exposure > self.settings.max_exposure_usd: 24 | logger.warning( 25 | "exposure_limit_exceeded", 26 | current=current_exposure, 27 | proposed=new_exposure, 28 | limit=self.settings.max_exposure_usd, 29 | ) 30 | return False 31 | 32 | elif side == "SELL": 33 | new_exposure = current_exposure - proposed_size_usd 34 | if new_exposure < self.settings.min_exposure_usd: 35 | logger.warning( 36 | "exposure_limit_exceeded", 37 | current=current_exposure, 38 | proposed=new_exposure, 39 | limit=self.settings.min_exposure_usd, 40 | ) 41 | return False 42 | 43 | return True 44 | 45 | def check_position_size(self, size_usd: float) -> bool: 46 | if size_usd > self.settings.max_position_size_usd: 47 | logger.warning( 48 | "position_size_exceeded", 49 | size=size_usd, 50 | max=self.settings.max_position_size_usd, 51 | ) 52 | return False 53 | return True 54 | 55 | def check_inventory_skew(self) -> bool: 56 | skew = self.inventory_manager.inventory.get_skew() 57 | if skew > self.settings.inventory_skew_limit: 58 | logger.warning("inventory_skew_exceeded", skew=skew, limit=self.settings.inventory_skew_limit) 59 | return False 60 | return True 61 | 62 | def validate_order(self, side: str, size_usd: float) -> tuple[bool, str]: 63 | if not self.check_position_size(size_usd): 64 | return (False, "Position size exceeds limit") 65 | 66 | if not self.check_exposure_limits(size_usd, side): 67 | return (False, "Exposure limit exceeded") 68 | 69 | if not self.check_inventory_skew(): 70 | return (False, "Inventory skew too high") 71 | 72 | return (True, "OK") 73 | 74 | def should_stop_trading(self) -> bool: 75 | exposure = abs(self.inventory_manager.inventory.net_exposure_usd) 76 | max_exposure = abs(self.settings.max_exposure_usd) 77 | 78 | if exposure > max_exposure * 0.9: 79 | logger.warning("near_exposure_limit", exposure=exposure, max=max_exposure) 80 | return True 81 | 82 | return False 83 | 84 | -------------------------------------------------------------------------------- /src/market_maker/quote_engine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | import structlog 7 | 8 | from src.config import Settings 9 | from src.inventory.inventory_manager import InventoryManager 10 | 11 | logger = structlog.get_logger(__name__) 12 | 13 | 14 | @dataclass 15 | class Quote: 16 | side: str 17 | price: float 18 | size: float 19 | market: str 20 | token_id: str 21 | 22 | 23 | class QuoteEngine: 24 | def __init__(self, settings: Settings, inventory_manager: InventoryManager): 25 | self.settings = settings 26 | self.inventory_manager = inventory_manager 27 | 28 | def calculate_bid_price(self, mid_price: float, spread_bps: int) -> float: 29 | return mid_price * (1 - spread_bps / 10000) 30 | 31 | def calculate_ask_price(self, mid_price: float, spread_bps: int) -> float: 32 | return mid_price * (1 + spread_bps / 10000) 33 | 34 | def calculate_mid_price(self, best_bid: float, best_ask: float) -> float: 35 | if best_bid <= 0 or best_ask <= 0: 36 | return 0.0 37 | return (best_bid + best_ask) / 2.0 38 | 39 | def generate_quotes( 40 | self, market_id: str, best_bid: float, best_ask: float, yes_token_id: str, no_token_id: str 41 | ) -> tuple[Quote | None, Quote | None]: 42 | mid_price = self.calculate_mid_price(best_bid, best_ask) 43 | 44 | if mid_price == 0: 45 | return (None, None) 46 | 47 | spread_bps = self.settings.min_spread_bps 48 | 49 | bid_price = self.calculate_bid_price(mid_price, spread_bps) 50 | ask_price = self.calculate_ask_price(mid_price, spread_bps) 51 | 52 | base_size = self.settings.default_size 53 | 54 | yes_size = self.inventory_manager.get_quote_size_yes(base_size, mid_price) 55 | no_size = self.inventory_manager.get_quote_size_no(base_size, mid_price) 56 | 57 | yes_quote = None 58 | no_quote = None 59 | 60 | if self.inventory_manager.can_quote_yes(yes_size): 61 | yes_quote = Quote( 62 | side="BUY", 63 | price=bid_price, 64 | size=yes_size, 65 | market=market_id, 66 | token_id=yes_token_id, 67 | ) 68 | 69 | if self.inventory_manager.can_quote_no(no_size): 70 | no_quote = Quote( 71 | side="BUY", 72 | price=1.0 - ask_price, 73 | size=no_size, 74 | market=market_id, 75 | token_id=no_token_id, 76 | ) 77 | 78 | return (yes_quote, no_quote) 79 | 80 | def adjust_for_inventory_skew(self, base_size: float, price: float, side: str) -> float: 81 | skew = self.inventory_manager.inventory.get_skew() 82 | 83 | if skew > 0.2: 84 | if side == "BUY" and self.inventory_manager.inventory.net_exposure_usd > 0: 85 | return base_size * 0.5 86 | elif side == "SELL" and self.inventory_manager.inventory.net_exposure_usd < 0: 87 | return base_size * 0.5 88 | 89 | return base_size 90 | 91 | def should_trim_quotes(self, time_to_close_hours: float) -> bool: 92 | if time_to_close_hours < 1.0: 93 | return True 94 | return False 95 | 96 | -------------------------------------------------------------------------------- /src/inventory/inventory_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | import structlog 7 | 8 | logger = structlog.get_logger(__name__) 9 | 10 | 11 | @dataclass 12 | class Inventory: 13 | yes_position: float = 0.0 14 | no_position: float = 0.0 15 | net_exposure_usd: float = 0.0 16 | total_value_usd: float = 0.0 17 | 18 | def update(self, yes_delta: float, no_delta: float, price: float): 19 | self.yes_position += yes_delta 20 | self.no_position += no_delta 21 | 22 | yes_value = self.yes_position * price 23 | no_value = self.no_position * (1.0 - price) 24 | 25 | self.net_exposure_usd = yes_value - no_value 26 | self.total_value_usd = yes_value + no_value 27 | 28 | def get_skew(self) -> float: 29 | total = abs(self.yes_position) + abs(self.no_position) 30 | if total == 0: 31 | return 0.0 32 | return abs(self.net_exposure_usd) / self.total_value_usd if self.total_value_usd > 0 else 0.0 33 | 34 | def is_balanced(self, max_skew: float = 0.3) -> bool: 35 | return self.get_skew() <= max_skew 36 | 37 | 38 | class InventoryManager: 39 | def __init__(self, max_exposure_usd: float, min_exposure_usd: float, target_balance: float = 0.0): 40 | self.max_exposure_usd = max_exposure_usd 41 | self.min_exposure_usd = min_exposure_usd 42 | self.target_balance = target_balance 43 | self.inventory = Inventory() 44 | 45 | def update_inventory(self, yes_delta: float, no_delta: float, price: float): 46 | self.inventory.update(yes_delta, no_delta, price) 47 | logger.debug( 48 | "inventory_updated", 49 | yes_position=self.inventory.yes_position, 50 | no_position=self.inventory.no_position, 51 | net_exposure=self.inventory.net_exposure_usd, 52 | skew=self.inventory.get_skew(), 53 | ) 54 | 55 | def can_quote_yes(self, size_usd: float) -> bool: 56 | potential_exposure = self.inventory.net_exposure_usd + size_usd 57 | return potential_exposure <= self.max_exposure_usd 58 | 59 | def can_quote_no(self, size_usd: float) -> bool: 60 | potential_exposure = self.inventory.net_exposure_usd - size_usd 61 | return potential_exposure >= self.min_exposure_usd 62 | 63 | def get_quote_size_yes(self, base_size: float, price: float) -> float: 64 | if not self.can_quote_yes(base_size): 65 | max_size = max(0, self.max_exposure_usd - self.inventory.net_exposure_usd) 66 | return min(base_size, max_size / price) 67 | 68 | if self.inventory.net_exposure_usd > self.target_balance: 69 | return base_size * 0.5 70 | 71 | return base_size 72 | 73 | def get_quote_size_no(self, base_size: float, price: float) -> float: 74 | if not self.can_quote_no(base_size): 75 | max_size = max(0, abs(self.min_exposure_usd - self.inventory.net_exposure_usd)) 76 | return min(base_size, max_size / (1.0 - price)) 77 | 78 | if self.inventory.net_exposure_usd < self.target_balance: 79 | return base_size * 0.5 80 | 81 | return base_size 82 | 83 | def should_rebalance(self, skew_limit: float = 0.3) -> bool: 84 | return not self.inventory.is_balanced(skew_limit) 85 | 86 | def get_rebalance_target(self) -> tuple[float, float]: 87 | current_skew = self.inventory.get_skew() 88 | if current_skew < 0.1: 89 | return (0.0, 0.0) 90 | 91 | rebalance_yes = -self.inventory.yes_position * 0.5 92 | rebalance_no = -self.inventory.no_position * 0.5 93 | 94 | return (rebalance_yes, rebalance_no) 95 | 96 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from pydantic import BaseModel, Field 6 | from pydantic_settings import BaseSettings, SettingsConfigDict 7 | 8 | 9 | class Settings(BaseSettings): 10 | model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") 11 | 12 | environment: str = "development" 13 | log_level: str = "INFO" 14 | 15 | # Polymarket API 16 | polymarket_api_url: str = Field( 17 | default="https://clob.polymarket.com", description="Polymarket CLOB API base URL" 18 | ) 19 | polymarket_ws_url: str = Field( 20 | default="wss://clob-ws.polymarket.com", description="Polymarket WebSocket URL" 21 | ) 22 | 23 | # Authentication 24 | private_key: str = Field(description="Ethereum private key for signing orders") 25 | public_address: str = Field(description="Ethereum public address") 26 | 27 | # Market configuration 28 | market_id: str = Field(description="Polymarket market ID to trade") 29 | conditional_token_address: str | None = None 30 | 31 | # Market discovery 32 | market_discovery_enabled: bool = Field(default=True, description="Enable market discovery") 33 | discovery_window_minutes: int = Field(default=15, description="Market discovery window (15m or 60m)") 34 | 35 | # Quoting parameters 36 | default_size: float = Field(default=100.0, description="Default order size in USD") 37 | min_spread_bps: int = Field(default=10, description="Minimum spread in basis points") 38 | quote_step_bps: int = Field(default=5, description="Quote stepping in basis points") 39 | oversize_threshold: float = Field(default=1.5, description="Oversize multiplier threshold") 40 | 41 | # Inventory management 42 | max_exposure_usd: float = Field(default=10000.0, description="Maximum net exposure in USD") 43 | min_exposure_usd: float = Field(default=-10000.0, description="Minimum net exposure in USD") 44 | target_inventory_balance: float = Field(default=0.0, description="Target inventory balance") 45 | inventory_skew_limit: float = Field(default=0.3, description="Maximum inventory skew (0-1)") 46 | 47 | # Cancel/replace logic 48 | cancel_replace_interval_ms: int = Field(default=500, description="Cancel/replace cycle interval (ms)") 49 | taker_delay_ms: int = Field(default=500, description="Taker delay in milliseconds") 50 | batch_cancellations: bool = Field(default=True, description="Batch cancellation requests") 51 | 52 | # Risk management 53 | max_position_size_usd: float = Field(default=5000.0, description="Maximum single position size") 54 | stop_loss_pct: float = Field(default=10.0, description="Stop loss percentage") 55 | 56 | # Auto-redeem 57 | auto_redeem_enabled: bool = Field(default=True, description="Enable auto-redeem") 58 | redeem_threshold_usd: float = Field(default=1.0, description="Minimum redeem amount in USD") 59 | 60 | # Gas optimization 61 | gas_batching_enabled: bool = Field(default=True, description="Enable gas batching") 62 | gas_price_gwei: float = Field(default=20.0, description="Gas price in Gwei") 63 | 64 | # Auto-close 65 | auto_close_enabled: bool = Field(default=False, description="Enable auto-close logic") 66 | close_spread_threshold_bps: int = Field(default=50, description="Minimum spread to close position (bps)") 67 | 68 | # Performance tuning 69 | quote_refresh_rate_ms: int = Field(default=1000, description="Quote refresh rate in milliseconds") 70 | order_lifetime_ms: int = Field(default=3000, description="Order lifetime before refresh (ms)") 71 | 72 | # Metrics and logging 73 | metrics_host: str = "0.0.0.0" 74 | metrics_port: int = 9305 75 | 76 | # RPC endpoint for on-chain operations 77 | rpc_url: str = Field(default="https://polygon-rpc.com", description="Polygon RPC endpoint") 78 | 79 | 80 | _settings: Settings | None = None 81 | 82 | 83 | def get_settings() -> Settings: 84 | global _settings 85 | if _settings is None: 86 | _settings = Settings() 87 | return _settings 88 | 89 | -------------------------------------------------------------------------------- /src/execution/order_executor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import time 5 | from typing import Any 6 | 7 | import httpx 8 | import structlog 9 | 10 | from src.config import Settings 11 | from src.polymarket.order_signer import OrderSigner 12 | 13 | logger = structlog.get_logger(__name__) 14 | 15 | 16 | class OrderExecutor: 17 | def __init__(self, settings: Settings, order_signer: OrderSigner): 18 | self.settings = settings 19 | self.order_signer = order_signer 20 | self.client = httpx.AsyncClient(timeout=30.0) 21 | self.pending_cancellations: set[str] = set() 22 | 23 | async def place_order(self, order: dict[str, Any]) -> dict[str, Any]: 24 | try: 25 | timestamp = int(time.time() * 1000) 26 | order["time"] = timestamp 27 | order["salt"] = str(int(time.time())) 28 | 29 | signature = self.order_signer.sign_order(order) 30 | order["signature"] = signature 31 | order["maker"] = self.order_signer.get_address() 32 | 33 | response = await self.client.post( 34 | f"{self.settings.polymarket_api_url}/order", 35 | json=order, 36 | headers={"Content-Type": "application/json"}, 37 | ) 38 | response.raise_for_status() 39 | 40 | result = response.json() 41 | logger.info("order_placed", order_id=result.get("id"), side=order.get("side"), price=order.get("price")) 42 | return result 43 | except Exception as e: 44 | logger.error("order_placement_failed", error=str(e), order=order) 45 | raise 46 | 47 | async def cancel_order(self, order_id: str) -> bool: 48 | try: 49 | if self.settings.batch_cancellations and order_id in self.pending_cancellations: 50 | return True 51 | 52 | self.pending_cancellations.add(order_id) 53 | 54 | response = await self.client.delete( 55 | f"{self.settings.polymarket_api_url}/order/{order_id}", 56 | ) 57 | response.raise_for_status() 58 | 59 | logger.info("order_cancelled", order_id=order_id) 60 | return True 61 | except Exception as e: 62 | logger.error("order_cancellation_failed", order_id=order_id, error=str(e)) 63 | return False 64 | 65 | async def cancel_all_orders(self, market_id: str) -> int: 66 | try: 67 | response = await self.client.delete( 68 | f"{self.settings.polymarket_api_url}/orders", 69 | params={"market": market_id}, 70 | ) 71 | response.raise_for_status() 72 | 73 | cancelled = response.json().get("cancelled", 0) 74 | logger.info("orders_cancelled", market_id=market_id, count=cancelled) 75 | self.pending_cancellations.clear() 76 | return cancelled 77 | except Exception as e: 78 | logger.error("cancel_all_orders_failed", market_id=market_id, error=str(e)) 79 | return 0 80 | 81 | async def batch_cancel_orders(self, order_ids: list[str]) -> int: 82 | if not self.settings.batch_cancellations: 83 | cancelled = 0 84 | for order_id in order_ids: 85 | if await self.cancel_order(order_id): 86 | cancelled += 1 87 | return cancelled 88 | 89 | try: 90 | response = await self.client.post( 91 | f"{self.settings.polymarket_api_url}/orders/cancel", 92 | json={"orderIds": order_ids}, 93 | ) 94 | response.raise_for_status() 95 | 96 | cancelled = len([oid for oid in order_ids if oid not in self.pending_cancellations]) 97 | self.pending_cancellations.clear() 98 | logger.info("batch_orders_cancelled", count=cancelled) 99 | return cancelled 100 | except Exception as e: 101 | logger.error("batch_cancel_failed", error=str(e)) 102 | return 0 103 | 104 | async def close(self): 105 | await self.client.aclose() 106 | 107 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import signal 5 | import time 6 | from typing import Any 7 | 8 | import structlog 9 | from dotenv import load_dotenv 10 | 11 | from src.config import Settings, get_settings 12 | from src.execution.order_executor import OrderExecutor 13 | from src.inventory.inventory_manager import InventoryManager 14 | from src.logging_config import configure_logging 15 | from src.market_maker.quote_engine import QuoteEngine 16 | from src.polymarket.order_signer import OrderSigner 17 | from src.polymarket.rest_client import PolymarketRestClient 18 | from src.polymarket.websocket_client import PolymarketWebSocketClient 19 | from src.risk.risk_manager import RiskManager 20 | from src.services import AutoRedeem, start_metrics_server 21 | 22 | logger = structlog.get_logger(__name__) 23 | 24 | 25 | class MarketMakerBot: 26 | def __init__(self, settings: Settings): 27 | self.settings = settings 28 | self.running = False 29 | self.rest_client = PolymarketRestClient(settings) 30 | self.ws_client = PolymarketWebSocketClient(settings) 31 | self.order_signer = OrderSigner(settings.private_key) 32 | self.order_executor = OrderExecutor(settings, self.order_signer) 33 | 34 | self.inventory_manager = InventoryManager( 35 | settings.max_exposure_usd, 36 | settings.min_exposure_usd, 37 | settings.target_inventory_balance, 38 | ) 39 | self.risk_manager = RiskManager(settings, self.inventory_manager) 40 | self.quote_engine = QuoteEngine(settings, self.inventory_manager) 41 | 42 | self.auto_redeem = AutoRedeem(settings) 43 | 44 | self.current_orderbook: dict[str, Any] = {} 45 | self.open_orders: dict[str, dict[str, Any]] = {} 46 | self.last_quote_time = 0.0 47 | 48 | async def discover_market(self) -> dict[str, Any] | None: 49 | if not self.settings.market_discovery_enabled: 50 | return await self.rest_client.get_market_info(self.settings.market_id) 51 | 52 | try: 53 | markets = await self.rest_client.get_markets(active=True, closed=False) 54 | 55 | for market in markets: 56 | if market.get("id") == self.settings.market_id: 57 | logger.info("market_discovered", market_id=market.get("id"), question=market.get("question")) 58 | return market 59 | 60 | logger.warning("market_not_found", market_id=self.settings.market_id) 61 | return None 62 | except Exception as e: 63 | logger.error("market_discovery_failed", error=str(e)) 64 | return None 65 | 66 | async def update_orderbook(self): 67 | try: 68 | orderbook = await self.rest_client.get_orderbook(self.settings.market_id) 69 | self.current_orderbook = orderbook 70 | 71 | if self.ws_client.websocket: 72 | self.ws_client.register_handler("l2_book_update", self._handle_orderbook_update) 73 | except Exception as e: 74 | logger.error("orderbook_update_failed", error=str(e)) 75 | 76 | def _handle_orderbook_update(self, data: dict[str, Any]): 77 | if data.get("market") == self.settings.market_id: 78 | self.current_orderbook = data.get("book", self.current_orderbook) 79 | 80 | async def refresh_quotes(self, market_info: dict[str, Any]): 81 | current_time = time.time() * 1000 82 | elapsed = current_time - self.last_quote_time 83 | 84 | if elapsed < self.settings.quote_refresh_rate_ms: 85 | return 86 | 87 | self.last_quote_time = current_time 88 | 89 | orderbook = self.current_orderbook 90 | if not orderbook: 91 | await self.update_orderbook() 92 | orderbook = self.current_orderbook 93 | 94 | best_bid = float(orderbook.get("best_bid", 0)) 95 | best_ask = float(orderbook.get("best_ask", 1)) 96 | 97 | if best_bid <= 0 or best_ask <= 1: 98 | logger.warning("invalid_orderbook", best_bid=best_bid, best_ask=best_ask) 99 | return 100 | 101 | yes_token_id = market_info.get("yes_token_id", "") 102 | no_token_id = market_info.get("no_token_id", "") 103 | 104 | yes_quote, no_quote = self.quote_engine.generate_quotes( 105 | self.settings.market_id, best_bid, best_ask, yes_token_id, no_token_id 106 | ) 107 | 108 | await self._cancel_stale_orders() 109 | 110 | if yes_quote: 111 | await self._place_quote(yes_quote, "YES") 112 | 113 | if no_quote: 114 | await self._place_quote(no_quote, "NO") 115 | 116 | async def _cancel_stale_orders(self): 117 | try: 118 | open_orders = await self.rest_client.get_open_orders( 119 | self.order_signer.get_address(), self.settings.market_id 120 | ) 121 | 122 | current_time = time.time() * 1000 123 | order_ids_to_cancel = [] 124 | 125 | for order in open_orders: 126 | order_time = order.get("timestamp", 0) 127 | age = current_time - order_time 128 | 129 | if age > self.settings.order_lifetime_ms: 130 | order_ids_to_cancel.append(order.get("id")) 131 | 132 | if order_ids_to_cancel: 133 | await self.order_executor.batch_cancel_orders(order_ids_to_cancel) 134 | except Exception as e: 135 | logger.error("stale_order_cancellation_failed", error=str(e)) 136 | 137 | async def _place_quote(self, quote: Any, outcome: str): 138 | is_valid, reason = self.risk_manager.validate_order(quote.side, quote.size * quote.price) 139 | 140 | if not is_valid: 141 | logger.warning("quote_rejected", reason=reason, outcome=outcome) 142 | return 143 | 144 | try: 145 | order = { 146 | "market": quote.market, 147 | "side": quote.side, 148 | "size": str(quote.size), 149 | "price": str(quote.price), 150 | "token_id": quote.token_id, 151 | } 152 | 153 | result = await self.order_executor.place_order(order) 154 | logger.info( 155 | "quote_placed", 156 | outcome=outcome, 157 | side=quote.side, 158 | price=quote.price, 159 | size=quote.size, 160 | order_id=result.get("id"), 161 | ) 162 | except Exception as e: 163 | logger.error("quote_placement_failed", outcome=outcome, error=str(e)) 164 | 165 | async def run_cancel_replace_cycle(self, market_info: dict[str, Any]): 166 | while self.running: 167 | try: 168 | await self.refresh_quotes(market_info) 169 | await asyncio.sleep(self.settings.cancel_replace_interval_ms / 1000.0) 170 | except Exception as e: 171 | logger.error("cancel_replace_cycle_error", error=str(e)) 172 | await asyncio.sleep(1) 173 | 174 | async def run_auto_redeem(self): 175 | while self.running: 176 | try: 177 | if self.settings.auto_redeem_enabled: 178 | await self.auto_redeem.auto_redeem_all(self.order_signer.get_address()) 179 | await asyncio.sleep(300) 180 | except Exception as e: 181 | logger.error("auto_redeem_error", error=str(e)) 182 | await asyncio.sleep(60) 183 | 184 | async def run(self): 185 | self.running = True 186 | 187 | logger.info("market_maker_starting", market_id=self.settings.market_id) 188 | 189 | market_info = await self.discover_market() 190 | if not market_info: 191 | logger.error("market_not_available") 192 | return 193 | 194 | await self.update_orderbook() 195 | 196 | if self.settings.market_discovery_enabled: 197 | await self.ws_client.connect() 198 | await self.ws_client.subscribe_orderbook(self.settings.market_id) 199 | 200 | tasks = [ 201 | self.run_cancel_replace_cycle(market_info), 202 | self.run_auto_redeem(), 203 | ] 204 | 205 | if self.ws_client.running: 206 | tasks.append(self.ws_client.listen()) 207 | 208 | try: 209 | await asyncio.gather(*tasks) 210 | finally: 211 | await self.cleanup() 212 | 213 | async def cleanup(self): 214 | self.running = False 215 | await self.order_executor.cancel_all_orders(self.settings.market_id) 216 | await self.rest_client.close() 217 | await self.ws_client.close() 218 | await self.order_executor.close() 219 | await self.auto_redeem.close() 220 | logger.info("market_maker_shutdown_complete") 221 | 222 | 223 | async def bootstrap(settings: Settings): 224 | load_dotenv() 225 | configure_logging(settings.log_level) 226 | start_metrics_server(settings.metrics_host, settings.metrics_port) 227 | 228 | bot = MarketMakerBot(settings) 229 | 230 | loop = asyncio.get_event_loop() 231 | stop_event = asyncio.Event() 232 | 233 | def _handle_signal(): 234 | logger.info("shutdown_signal_received") 235 | bot.running = False 236 | stop_event.set() 237 | 238 | for sig in (signal.SIGINT, signal.SIGTERM): 239 | try: 240 | loop.add_signal_handler(sig, _handle_signal) 241 | except NotImplementedError: 242 | pass 243 | 244 | try: 245 | await bot.run() 246 | finally: 247 | logger.info("bot_shutdown_complete") 248 | 249 | 250 | def main(): 251 | settings = get_settings() 252 | asyncio.run(bootstrap(settings)) 253 | 254 | 255 | if __name__ == "__main__": 256 | main() 257 | 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Polymarket Market Maker Bot 2 | 3 | Production-grade **Polymarket CLOB market-making bot** with optimized inventory management, spread farming, intelligent cancel/replace cycles, and automated risk controls. 4 | Designed for professional market makers seeking balanced exposure, efficient quote placement, and maximum spread capture on Polymarket prediction markets. 5 | 6 |
17 | 18 | ## What This Bot Does 19 | 20 | The Polymarket Market Maker Bot provides: 21 | 22 | - **Real-time market making** on Polymarket CLOB (Central Limit Order Book) 23 | - **Balanced inventory management** with mirrored YES/NO exposure control 24 | - **Intelligent quote placement** at top-of-book for maximum spread capture 25 | - **Optimized cancel/replace cycles** tuned for 500ms taker delay 26 | - **Passive order execution** to earn maker rebates and avoid crossing 27 | - **Risk management** with exposure limits, position size caps, and inventory skew checks 28 | - **Auto-redeem** for settled markets and profitable positions 29 | - **Gas batching** to minimize on-chain transaction costs 30 | - **Real-time orderbook tracking** via WebSocket for low-latency updates 31 | 32 | ## Core Features 33 | 34 | ### Inventory Balance & Exposure Control 35 | 36 | - **Mirrored YES/NO positioning** – Maintains balanced exposure across both sides 37 | - **Net exposure limits** – Configurable min/max exposure in USD 38 | - **Inventory skew detection** – Automatically adjusts quote sizes when inventory becomes unbalanced 39 | - **Automatic rebalancing** – Smart quote sizing to reduce runaway inventory 40 | - **Target inventory balance** – Maintains desired net exposure level 41 | 42 | ### Spread Farming Efficiency 43 | 44 | - **Top-of-book quoting** – Places orders at best bid/ask for maximum fill probability 45 | - **Passive order execution** – All orders are maker orders to earn rebates 46 | - **Queue positioning** – Optimized order placement for better queue position 47 | - **Missed fill reduction** – Fast cancel/replace cycles to capture spread opportunities 48 | - **Anti-crossing logic** – Prevents accidental taker orders 49 | 50 | ### Cancel/Replace Cadence 51 | 52 | - **Low-latency refresh cycles** – Configurable quote refresh rate (default: 1000ms) 53 | - **500ms taker delay optimization** – Timing logic tuned for Polymarket's 500ms delay 54 | - **Batch cancellations** – Groups cancel requests to reduce API calls and gas costs 55 | - **Stale order detection** – Automatically cancels orders exceeding lifetime threshold 56 | - **Smooth quote transitions** – No gaps or overlaps during refresh cycles 57 | 58 | ### Market Discovery & Real-Time Updates 59 | 60 | - **15m/1h window discovery** – Automatically discovers markets within discovery windows 61 | - **WebSocket orderbook feeds** – Real-time L2 orderbook updates for instant quote adjustments 62 | - **Trade feed monitoring** – Tracks fills and adjusts inventory automatically 63 | - **Market info caching** – Efficient market metadata retrieval 64 | 65 | ### Risk Management 66 | 67 | - **Exposure limits** – Hard caps on net exposure in USD 68 | - **Position size limits** – Maximum single order size 69 | - **Inventory skew limits** – Prevents excessive position concentration 70 | - **Stop-loss protection** – Optional percentage-based stop-loss 71 | - **Pre-trade validation** – All orders validated before placement 72 | 73 | ### Auto-Redeem & Gas Optimization 74 | 75 | - **Automatic redemption** – Redeems settled positions above threshold 76 | - **Gas batching** – Groups multiple operations to reduce gas costs 77 | - **Configurable gas price** – Customizable gas price in Gwei 78 | - **Efficient order lifecycle** – Minimizes on-chain operations 79 | 80 | ### Performance Monitoring 81 | 82 | - **Prometheus metrics** – Real-time metrics for orders, inventory, exposure, and profit 83 | - **Structured JSON logging** – Full audit trail of all operations 84 | - **Fill rate tracking** – Monitors passive fill rates 85 | - **Latency metrics** – Quote generation and placement latency tracking 86 | 87 | ## Technical Architecture 88 | 89 | ``` 90 | ┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ 91 | │ Polymarket CLOB API │ │ Market Maker Bot │ │ Inventory Mgr │ 92 | │ (REST + WebSocket) │ <--> │ (Quote Engine) │ <--> │ (Balance Ctrl) │ 93 | └─────────────────────┘ └──────────────────────┘ └──────────────────┘ 94 | │ │ │ 95 | │ v │ 96 | │ ┌──────────────────┐ │ 97 | │ │ Order Executor │ │ 98 | │ │ (Cancel/Replace) │ │ 99 | │ └──────────────────┘ │ 100 | │ │ │ 101 | v v v 102 | ┌─────────────────────┐ ┌──────────────────┐ ┌──────────────────┐ 103 | │ WebSocket Feed │ │ Risk Manager │ │ Auto Redeem │ 104 | │ (Orderbook/Trades) │ │ (Validations) │ │ (Settlements) │ 105 | └─────────────────────┘ └──────────────────┘ └──────────────────┘ 106 | ``` 107 | 108 | ### Key Modules 109 | 110 | - `src/config.py` – Pydantic settings for all bot parameters (exposure, spreads, timing, etc.) 111 | - `src/polymarket/rest_client.py` – REST API client for market data, orders, balances 112 | - `src/polymarket/websocket_client.py` – WebSocket client for real-time orderbook/trade feeds 113 | - `src/polymarket/order_signer.py` – Ethereum order signing for authenticated requests 114 | - `src/inventory/inventory_manager.py` – Inventory tracking and balanced exposure management 115 | - `src/market_maker/quote_engine.py` – Quote generation with spread calculation and sizing 116 | - `src/execution/order_executor.py` – Order placement, cancellation, and batching 117 | - `src/risk/risk_manager.py` – Pre-trade validations and risk checks 118 | - `src/services/auto_redeem.py` – Automatic position redemption for settled markets 119 | - `src/main.py` – Main orchestrator for market-making loop and lifecycle 120 | 121 | ## Getting Started 122 | 123 | ### 1. Requirements 124 | 125 | - Python **3.11+** 126 | - **Ethereum private key** with funds on Polygon network 127 | - **Polygon RPC endpoint** (public or private) 128 | - Polymarket account with sufficient balance for trading 129 | - Understanding of market-making risks and Polymarket mechanics 130 | 131 | ### 2. Installation 132 | 133 | ```bash 134 | git clone https://github.com/your-org/polymarket-market-maker-bot.git 135 | cd polymarket-market-maker-bot 136 | python -m venv .venv 137 | source .venv/bin/activate # Windows: .venv\Scripts\activate 138 | pip install -r requirements.txt 139 | ``` 140 | 141 | ### 3. Configuration 142 | 143 | Create a `.env` file: 144 | 145 | ```env 146 | ENVIRONMENT=production 147 | LOG_LEVEL=INFO 148 | 149 | # Polymarket API 150 | POLYMARKET_API_URL=https://clob.polymarket.com 151 | POLYMARKET_WS_URL=wss://clob-ws.polymarket.com 152 | 153 | # Authentication (REQUIRED) 154 | PRIVATE_KEY=0x... 155 | PUBLIC_ADDRESS=0x... 156 | 157 | # Market Configuration 158 | MARKET_ID=0x... 159 | 160 | # Market Discovery 161 | MARKET_DISCOVERY_ENABLED=true 162 | DISCOVERY_WINDOW_MINUTES=15 163 | 164 | # Quoting Parameters 165 | DEFAULT_SIZE=100.0 166 | MIN_SPREAD_BPS=10 167 | QUOTE_STEP_BPS=5 168 | OVERSIZE_THRESHOLD=1.5 169 | 170 | # Inventory Management 171 | MAX_EXPOSURE_USD=10000.0 172 | MIN_EXPOSURE_USD=-10000.0 173 | TARGET_INVENTORY_BALANCE=0.0 174 | INVENTORY_SKEW_LIMIT=0.3 175 | 176 | # Cancel/Replace Logic 177 | CANCEL_REPLACE_INTERVAL_MS=500 178 | TAKER_DELAY_MS=500 179 | BATCH_CANCELLATIONS=true 180 | 181 | # Risk Management 182 | MAX_POSITION_SIZE_USD=5000.0 183 | STOP_LOSS_PCT=10.0 184 | 185 | # Auto-Redeem 186 | AUTO_REDEEM_ENABLED=true 187 | REDEEM_THRESHOLD_USD=1.0 188 | 189 | # Gas Optimization 190 | GAS_BATCHING_ENABLED=true 191 | GAS_PRICE_GWEI=20.0 192 | 193 | # Performance Tuning 194 | QUOTE_REFRESH_RATE_MS=1000 195 | ORDER_LIFETIME_MS=3000 196 | 197 | # Metrics and Logging 198 | METRICS_HOST=0.0.0.0 199 | METRICS_PORT=9305 200 | 201 | # Polygon RPC 202 | RPC_URL=https://polygon-rpc.com 203 | ``` 204 | 205 | ### 4. Run the Bot 206 | 207 | ```bash 208 | python -m src.main 209 | ``` 210 | 211 | The bot will: 212 | - Discover and connect to the specified Polymarket market 213 | - Establish WebSocket connection for real-time orderbook updates 214 | - Start the cancel/replace cycle with optimized quote placement 215 | - Monitor inventory and adjust quotes for balanced exposure 216 | - Execute auto-redeem for settled positions 217 | - Log all operations and expose Prometheus metrics 218 | 219 | ### 5. Docker Deployment 220 | 221 | ```bash 222 | docker compose up --build -d 223 | ``` 224 | 225 | View logs: 226 | ```bash 227 | docker compose logs -f market-maker-bot 228 | ``` 229 | 230 | ## Parameter Tuning Guide 231 | 232 | ### Inventory Balance 233 | 234 | - **MAX_EXPOSURE_USD / MIN_EXPOSURE_USD**: Set based on your capital. Smaller values = tighter control. 235 | - **INVENTORY_SKEW_LIMIT**: 0.3 = 30% max skew. Lower = more balanced but fewer opportunities. 236 | - **TARGET_INVENTORY_BALANCE**: 0.0 for neutral, positive for bullish bias, negative for bearish. 237 | 238 | ### Spread Farming 239 | 240 | - **MIN_SPREAD_BPS**: Minimum spread to quote (10 bps = 0.1%). Lower = more quotes but tighter spreads. 241 | - **QUOTE_STEP_BPS**: Stepping between price levels (5 bps = 0.05%). 242 | - **DEFAULT_SIZE**: Base order size in USD. Adjust based on market depth. 243 | 244 | ### Cancel/Replace Timing 245 | 246 | - **CANCEL_REPLACE_INTERVAL_MS**: 500ms matches taker delay. Faster = more responsive but more API calls. 247 | - **QUOTE_REFRESH_RATE_MS**: 1000ms for smooth quote updates. Lower = faster refresh but higher gas costs. 248 | - **ORDER_LIFETIME_MS**: 3000ms before canceling stale orders. Adjust based on market conditions. 249 | 250 | ### Performance Optimization 251 | 252 | - **BATCH_CANCELLATIONS**: `true` to group cancel requests and reduce gas costs. 253 | - **GAS_BATCHING_ENABLED**: `true` to batch on-chain operations. 254 | - **AUTO_REDEEM_ENABLED**: `true` to automatically redeem settled positions. 255 | 256 | ## Monitoring & Observability 257 | 258 | ### Prometheus Metrics 259 | 260 | Access metrics at: `http://localhost:9305/metrics` 261 | 262 | Key metrics: 263 | - `pm_mm_orders_placed_total` – Total orders placed by side and outcome 264 | - `pm_mm_orders_filled_total` – Total orders filled (passive fills) 265 | - `pm_mm_inventory` – Current YES/NO inventory positions 266 | - `pm_mm_exposure_usd` – Net exposure in USD 267 | - `pm_mm_spread_bps` – Current spread in basis points 268 | - `pm_mm_profit_usd` – Cumulative profit in USD 269 | - `pm_mm_quote_latency_ms` – Quote generation and placement latency 270 | 271 | ### Structured Logging 272 | 273 | All events logged as JSON: 274 | - Order placement and cancellation events 275 | - Inventory updates and rebalancing 276 | - Risk check failures 277 | - WebSocket connection status 278 | - Auto-redeem operations 279 | 280 | Example log: 281 | ```json 282 | { 283 | "event": "quote_placed", 284 | "outcome": "YES", 285 | "side": "BUY", 286 | "price": "0.65", 287 | "size": "100.0", 288 | "order_id": "0x...", 289 | "timestamp": "2024-01-01T12:00:00Z" 290 | } 291 | ``` 292 | 293 | ## Performance Benchmarks 294 | 295 | Expected performance characteristics: 296 | 297 | - **Quote refresh latency**: < 100ms 298 | - **Cancel/replace cycle**: 500-1000ms 299 | - **WebSocket latency**: < 50ms 300 | - **Fill rate**: 60-80% passive (maker) fills 301 | - **Inventory skew**: Maintained below 30% 302 | - **Gas efficiency**: 30-50% reduction via batching 303 | 304 | ## SEO: Who Is This Bot For? 305 | 306 | This project is designed for people searching for: 307 | 308 | - **Polymarket market maker bot** 309 | - **Polymarket trading bot** 310 | - **Polymarket copy trading bot** 311 | - **CLOB market making** 312 | - **Polymarket automated trading** 313 | - **Prediction market bot** 314 | - **Polymarket liquidity provider** 315 | 316 | Perfect for: 317 | 318 | - **Professional market makers** seeking to provide liquidity on Polymarket 319 | - **Traders** looking to automate spread capture and inventory management 320 | - **Quantitative traders** who want optimized cancel/replace cycles 321 | - **DeFi enthusiasts** interested in prediction market market-making 322 | 323 | ## Risk Management Best Practices 324 | 325 | 1. **Start Small**: Begin with low `DEFAULT_SIZE` and `MAX_EXPOSURE_USD` values 326 | 2. **Monitor Inventory**: Watch inventory skew and adjust limits as needed 327 | 3. **Set Exposure Limits**: Use conservative exposure limits to prevent runaway positions 328 | 4. **Test on Testnet**: Test thoroughly before deploying with real funds 329 | 5. **Monitor Gas Costs**: Gas batching helps, but monitor costs during high network activity 330 | 6. **Review Logs**: Regularly review logs for risk check failures and edge cases 331 | 332 | ## Common Issues & Troubleshooting 333 | 334 | ### Orders Not Filling 335 | 336 | - **Check spread**: Increase `MIN_SPREAD_BPS` if spreads are too tight 337 | - **Verify orderbook**: Ensure WebSocket connection is active 338 | - **Review queue position**: Orders may be too far from best bid/ask 339 | 340 | ### High Inventory Skew 341 | 342 | - **Reduce exposure limits**: Lower `MAX_EXPOSURE_USD` / `MIN_EXPOSURE_USD` 343 | - **Adjust quote sizing**: Bot automatically reduces sizes when skewed 344 | - **Enable rebalancing**: Check inventory skew limits are configured 345 | 346 | ### Excessive Gas Costs 347 | 348 | - **Enable batching**: Set `BATCH_CANCELLATIONS=true` and `GAS_BATCHING_ENABLED=true` 349 | - **Reduce refresh rate**: Increase `QUOTE_REFRESH_RATE_MS` to fewer updates 350 | - **Monitor network**: Use private RPC during high gas periods 351 | 352 | ### WebSocket Disconnections 353 | 354 | - **Automatic reconnection**: Bot automatically reconnects on disconnect 355 | - **Check network**: Ensure stable network connection 356 | - **Review logs**: Check for connection errors in logs 357 | 358 | ## Future Enhancements 359 | 360 | - **Multi-market support**: Quote on multiple markets simultaneously 361 | - **Advanced strategies**: Dynamic spread adjustment based on volatility 362 | - **ML-based sizing**: Machine learning for optimal order sizing 363 | - **Portfolio-level risk**: Cross-market exposure limits 364 | - **Copy trading**: Replicate successful market maker strategies 365 | 366 | ## License 367 | 368 | Use at your own risk. Market-making involves capital risk and requires understanding of prediction markets. 369 | Ensure compliance with local regulations and Polymarket terms of service before using in production. 370 | 371 | ## Safety & Compliance 372 | 373 | - This bot executes real trades on Polymarket. Test thoroughly before deploying with real funds. 374 | - Market-making involves inventory risk. Monitor exposure and inventory skew continuously. 375 | - Gas costs can be significant during high network activity. Monitor and optimize accordingly. 376 | - Review Polymarket's terms of service and ensure compliance with automated trading policies. 377 | - Maintain audit logs and monitor all operations for compliance purposes. 378 | 379 | --------------------------------------------------------------------------------