"""CLOB order executor โ places limit orders on Polymarket (CLOB V2)"""
import time
from loguru import logger
from config import PRIVATE_KEY, PROXY_ADDRESS, SIGNATURE_TYPE, CHAIN_ID, CLOB_API_URL, DRY_RUN, TRADE_PROXY
import db
MAX_RETRIES = 3
RETRY_BACKOFF = [1, 3, 7] # seconds between retries
BUILDER_CODE = "0x3c829d5150b70f3ba347670d4b1eda96be3c255b3f64895a7eeef5caea7952d5"
# Lazy init โ only connect when needed
_client = None
_proxy_applied = False
def _apply_proxy():
"""
Configure CLOB HTTP client to use proxy.
py-clob-client uses a module-level httpx.Client without proxy support,
so we replace it with a proxy-enabled instance. This is verified after
patching to catch breaking changes in the library.
"""
global _proxy_applied
if _proxy_applied or not TRADE_PROXY:
return
try:
import httpx
from py_clob_client_v2.http_helpers import helpers
if not hasattr(helpers, '_http_client'):
logger.error("py_clob_client internal API changed: helpers._http_client not found. "
"Orders will be sent WITHOUT proxy โ IP may be exposed!")
return
helpers._http_client = httpx.Client(http2=True, proxy=TRADE_PROXY, timeout=30)
# Verify patch took effect
patched = getattr(helpers, '_http_client', None)
if patched is None or not hasattr(patched, '_transport'):
logger.error("Proxy patch verification failed โ proxy may not be active!")
return
_proxy_applied = True
_proxy_host = TRADE_PROXY.split('@')[-1] if '@' in TRADE_PROXY else TRADE_PROXY
logger.info(f"CLOB proxy configured: {_proxy_host}")
except Exception as e:
logger.error(f"Failed to configure trade proxy: {e}. Orders will be sent WITHOUT proxy!")
def get_client():
"""Lazy init py-clob-client with proxy"""
global _client
if _client is not None:
return _client
if not PRIVATE_KEY or PRIVATE_KEY == "your_polygon_wallet_private_key_here":
logger.warning("No PRIVATE_KEY configured โ executor disabled")
return None
# Apply proxy BEFORE creating client (client init may make HTTP calls)
_apply_proxy()
try:
from py_clob_client_v2 import ClobClient
kwargs = {
"host": CLOB_API_URL,
"key": PRIVATE_KEY,
"chain_id": CHAIN_ID,
"signature_type": SIGNATURE_TYPE,
}
if PROXY_ADDRESS and SIGNATURE_TYPE == 2:
kwargs["funder"] = PROXY_ADDRESS
_client = ClobClient(**kwargs)
_client.set_api_creds(_client.create_or_derive_api_key())
logger.info("CLOB client initialized")
return _client
except Exception as e:
logger.error(f"CLOB client init failed: {e}")
return None
def _is_safe_to_retry(e):
"""True only if the error proves the POST never reached the server, so
re-sending cannot duplicate the order. Ambiguous errors (read timeout,
unknown) may mean the order WAS accepted, so we must not retry them."""
msg = str(e).lower()
safe_markers = (
"connection refused", "failed to establish", "connect timeout",
"name or service not known", "temporary failure in name resolution",
"max retries exceeded", "connection aborted",
)
return any(m in msg for m in safe_markers)
def place_bet(signal: dict, dry_run: bool = None) -> dict:
"""
Place a limit order on Polymarket.
signal: {market_id, side, token_id, price, size, edge, ...}
dry_run: override config DRY_RUN
Returns: {success, order_id, message, dry_run}
"""
if dry_run is None:
dry_run = DRY_RUN
result = {
"success": False,
"order_id": None,
"message": "",
"dry_run": dry_run,
"actual_cost": 0.0,
}
# === Idempotency guard: skip if active trade already exists for this market+side ===
# live_only: in live mode, ignore old dry-run simulated trades
active_trades = db.get_active_trades(live_only=not dry_run)
for t in active_trades:
if t["market_id"] == signal["market_id"] and t["side"] == signal["side"]:
result["message"] = f"Duplicate blocked: active trade #{t['id']} already exists for {signal['market_id'][:8]}โฆ/{signal['side']}"
logger.warning(f"โ {result['message']}")
return result
# === Cancelled-trade guard: stale cancel + CLOB fill race condition ===
# Resolver cancels stale pending orders after 30min, but CLOB may have already
# filled them on-chain. Block new orders until market resolves and reconciliation runs.
if db.has_cancelled_for_market(signal["market_id"], signal["side"], live_only=not dry_run):
result["message"] = f"Duplicate blocked: cancelled trade exists for {signal['market_id'][:8]}โฆ/{signal['side']} (possible on-chain fill)"
logger.warning(f"โ {result['message']}")
return result
if dry_run:
# Simulate order
result["success"] = True
result["order_id"] = f"dry-{signal['market_id'][:8]}-{int(time.time())}"
result["actual_cost"] = signal["size"]
result["message"] = f"DRY RUN: Would {signal['side']} @ ${signal['price']:.2f} x ${signal['size']}"
logger.info(f"๐งช {result['message']}")
# Save trade to DB
db.save_trade({
"market_id": signal["market_id"],
"order_id": result["order_id"],
"side": signal["side"],
"token_id": signal["token_id"],
"price": signal["price"],
"size": signal["size"],
"edge": signal["edge"],
"edge_tier": signal.get("edge_tier"),
"model_prob": signal["model_prob"],
"market_prob": signal["market_prob"],
"status": "simulated",
"dry_run": 1,
})
return result
# Real order
client = get_client()
if not client:
result["message"] = "CLOB client not available"
logger.error(result["message"])
return result
# Geoblock safety: if a trade proxy is configured but failed to attach, refuse to
# send the order un-proxied (would expose a geoblocked IP and likely be rejected).
if TRADE_PROXY and not _proxy_applied:
result["message"] = "Trade proxy configured but inactive - refusing to send un-proxied order"
logger.error(result["message"])
return result
try:
from py_clob_client_v2 import OrderArgs, OrderType, Side
# signal["size"] is dollar amount, OrderArgs size is number of shares
price = round(signal["price"], 2)
shares = round(signal["size"] / price, 2) if price > 0 else 0
# Polymarket minimum ~5 shares, but cap dollar cost to signal size
MIN_SHARES = 5
if shares < MIN_SHARES:
min_cost = MIN_SHARES * price
if min_cost > signal["size"] * 1.5:
# Minimum order too expensive relative to intended size โ skip
result["message"] = f"Skipped: min {MIN_SHARES} shares @ ${price:.2f} = ${min_cost:.2f} exceeds budget ${signal['size']:.2f}"
logger.warning(f"โ ๏ธ {result['message']}")
return result
shares = MIN_SHARES
# Actual dollar cost = shares ร price (may differ from signal size due to MIN_SHARES)
actual_cost = round(shares * price, 2)
order_args = OrderArgs(
token_id=signal["token_id"],
price=price,
size=shares,
side=Side.BUY,
builder_code=BUILDER_CODE,
)
signed = client.create_order(order_args)
# === Retry loop for post_order (network-sensitive) ===
last_error = None
for attempt in range(MAX_RETRIES):
try:
response = client.post_order(signed, OrderType.GTC)
order_id = response.get("orderID") or response.get("id")
# A 2xx body without an orderID (or success=False) is a real
# rejection, not a placed order. Treat as failure so we never
# record a phantom pending trade that occupies a position slot.
if not order_id or response.get("success") is False:
raise RuntimeError(f"Order rejected (no orderID): {response}")
result["success"] = True
result["order_id"] = order_id
result["actual_cost"] = actual_cost
result["message"] = f"Order placed: {signal['side']} @ ${price:.2f} x ${actual_cost:.2f} ({shares} shares)"
logger.info(f"๐ฐ REAL ORDER: {result['message']} (ID: {order_id}, attempt: {attempt+1})")
# Save trade to DB โ use actual_cost (shares ร price), not signal size
db.save_trade({
"market_id": signal["market_id"],
"order_id": order_id,
"side": signal["side"],
"token_id": signal["token_id"],
"price": signal["price"],
"size": actual_cost,
"edge": signal["edge"],
"edge_tier": signal.get("edge_tier"),
"model_prob": signal["model_prob"],
"market_prob": signal["market_prob"],
"status": "pending",
"dry_run": 0,
})
return result
except Exception as e:
last_error = e
# Retry ONLY connection-level failures (request never sent).
# Ambiguous errors may mean the order WAS placed: do not retry
# (double-spend). Let reconciliation handle the unverified case.
if _is_safe_to_retry(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF[attempt]
logger.warning(f"post_order attempt {attempt+1} failed (retryable): {e}; retrying in {wait}s")
time.sleep(wait)
else:
break
# Loop ended without a confirmed placement
result["message"] = f"Order failed: {last_error}"
logger.error(f"โ {result['message']}")
# Deterministic failures (balance, allowance) โ cancelled (order never placed)
# Ambiguous failures (timeout, network) โ unverified (may have been placed)
err_str = str(last_error).lower()
# Match specific wording variants, not a bare "allowance" substring that
# could appear in an unrelated error and wrongly mark a placed order cancelled.
definite_reject_markers = (
"not enough balance", "insufficient balance", "insufficient funds",
"not enough allowance", "insufficient allowance",
)
is_definite_reject = any(m in err_str for m in definite_reject_markers)
save_status = "cancelled" if is_definite_reject else "unverified"
db.save_trade({
"market_id": signal["market_id"],
"order_id": None,
"side": signal["side"],
"token_id": signal["token_id"],
"price": signal["price"],
"size": signal["size"],
"edge": signal["edge"],
"edge_tier": signal.get("edge_tier"),
"model_prob": signal["model_prob"],
"market_prob": signal["market_prob"],
"status": save_status,
"dry_run": 0,
})
return result
except Exception as e:
result["message"] = f"Order creation failed: {e}"
logger.error(result["message"])
return result
def cancel_order(order_id: str) -> bool:
"""Cancel an open order"""
client = get_client()
if not client:
return False
try:
from py_clob_client_v2 import OrderPayload
client.cancel_order(OrderPayload(orderID=order_id))
logger.info(f"Cancelled order {order_id}")
return True
except Exception as e:
logger.error(f"Cancel failed: {e}")
return False
def get_ctf_balance(token_id: str) -> float:
"""Get conditional token balance from CTF contract on-chain."""
try:
import requests
CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'
rpc = 'https://polygon.drpc.org'
tid_hex = hex(int(token_id))[2:].zfill(64)
proxy_padded = PROXY_ADDRESS[2:].lower().zfill(64)
# ERC1155 balanceOf(address, uint256)
data = '0x00fdd58e' + proxy_padded + tid_hex
r = requests.post(rpc, json={
'jsonrpc': '2.0', 'method': 'eth_call',
'params': [{'to': CTF, 'data': data}, 'latest'], 'id': 1
}, timeout=10, proxies={'http': None, 'https': None})
return int(r.json()['result'], 16) / 1e6
except Exception as e:
logger.error(f"CTF balance check failed: {e}")
return -1 # unknown
def redeem_position(condition_id: str, neg_risk: bool = True, token_id: str = None) -> dict:
"""
Redeem a resolved position on Polymarket.
Burns winning tokens โ returns USDC to exchange.
Strategy: try neg_risk=False first (partition-based, no amounts needed),
then neg_risk=True with actual on-chain balance.
Verifies tokens were actually burned after redeem.
"""
result = {"success": False, "message": "", "tx_hash": None}
if DRY_RUN:
result["success"] = True
result["message"] = f"DRY RUN: Would redeem {condition_id[:16]}..."
logger.info(f"๐งช {result['message']}")
return result
# Pre-check: if we know the token_id, verify there are tokens to redeem
if token_id:
balance_before = get_ctf_balance(token_id)
if balance_before == -1:
# RPC error: balance unknown. Do NOT act on unknown/stale data; skip
# and let the next resolve cycle retry rather than risk a wrong redeem.
result["message"] = f"CTF balance unknown (RPC error) for {condition_id[:16]}... skipping redeem, will retry"
logger.warning(result["message"])
return result
if balance_before == 0:
result["success"] = True
result["message"] = f"Already redeemed {condition_id[:16]}... (0 balance on CTF)"
logger.info(f"โ
{result['message']}")
return result
elif balance_before > 0:
logger.info(f"CTF balance before redeem: {balance_before:.4f} shares for {condition_id[:16]}...")
try:
from polymarket_apis import PolymarketWeb3Client
web3_client = PolymarketWeb3Client(
private_key=PRIVATE_KEY,
signature_type=SIGNATURE_TYPE,
)
receipt = None
# Strategy 1: neg_risk=False (uses partition indices [1,2], no amounts needed)
try:
receipt = web3_client.redeem_position(
condition_id=condition_id,
amounts=[1, 1], # ignored for non-neg_risk (library uses partition indices)
neg_risk=False,
)
logger.info(f"Redeem neg_risk=False tx sent for {condition_id[:16]}...")
except Exception as e:
logger.debug(f"neg_risk=False failed: {e}, trying neg_risk=True...")
# Verify after strategy 1
if receipt and token_id:
time.sleep(2) # wait for chain confirmation
balance_after = get_ctf_balance(token_id)
if balance_after == 0:
tx_hash = receipt.get("transactionHash", "") if isinstance(receipt, dict) else str(receipt)
result["success"] = True
result["tx_hash"] = tx_hash
result["message"] = f"Redeemed {condition_id[:16]}... (neg_risk=False) โ tx: {str(tx_hash)[:16]}..."
logger.info(f"๐ฐ REDEEM VERIFIED: {result['message']}")
return result
else:
logger.warning(f"neg_risk=False didn't burn tokens ({balance_after:.4f} remaining)")
# Strategy 2: neg_risk=True with actual token balance
if token_id:
balance = get_ctf_balance(token_id)
if balance > 0:
# Try both single-outcome orderings (we don't know which index is
# ours); each is verified below. Dropped [balance, balance]: it asks
# to redeem BOTH outcomes while holding one, a guaranteed revert that
# wastes gas on $3-6 positions.
for amounts in [[balance, 0], [0, balance]]:
try:
receipt = web3_client.redeem_position(
condition_id=condition_id,
amounts=amounts,
neg_risk=True,
)
time.sleep(2)
balance_after = get_ctf_balance(token_id)
if balance_after == 0:
tx_hash = receipt.get("transactionHash", "") if isinstance(receipt, dict) else str(receipt)
result["success"] = True
result["tx_hash"] = tx_hash
result["message"] = f"Redeemed {condition_id[:16]}... (neg_risk=True, amounts={amounts}) โ tx: {str(tx_hash)[:16]}..."
logger.info(f"๐ฐ REDEEM VERIFIED: {result['message']}")
return result
else:
logger.debug(f"amounts={amounts} didn't burn tokens ({balance_after:.4f} remaining)")
except Exception as e:
logger.debug(f"neg_risk=True amounts={amounts} failed: {e}")
continue
# Fallback: if no token_id for verification, trust the receipt
if receipt and not token_id:
tx_hash = receipt.get("transactionHash", "") if isinstance(receipt, dict) else str(receipt)
result["success"] = True
result["tx_hash"] = tx_hash
result["message"] = f"Redeemed {condition_id[:16]}... (unverified) โ tx: {str(tx_hash)[:16]}..."
logger.info(f"๐ฐ REDEEM (unverified): {result['message']}")
return result
if not result["success"]:
result["message"] = f"All redeem strategies failed for {condition_id[:16]}..."
logger.error(f"โ {result['message']}")
except Exception as e:
result["message"] = f"Redeem failed for {condition_id[:16]}...: {e}"
logger.error(f"โ {result['message']}")
return result
def wrap_usdc_to_pusd() -> float:
"""
Wrap any USDC.e on the Safe wallet into pUSD via Collateral Onramp.
After CTF redeem, USDC.e lands on Safe but CLOB needs pUSD.
Returns amount wrapped (0 if nothing to wrap).
"""
if DRY_RUN:
return 0
try:
from polymarket_apis import PolymarketWeb3Client
from polymarket_apis.clients.web3_client import (
get_packed_signature, sign_safe_transaction, ADDRESS_ZERO,
)
wc = PolymarketWeb3Client(
private_key=PRIVATE_KEY,
signature_type=SIGNATURE_TYPE,
)
safe = wc.address
w3 = wc.w3
# Check USDC.e balance on Safe
usdc_balance = wc.get_usdc_balance(safe)
if usdc_balance < 0.01:
return 0
amount_int = int(usdc_balance * 1e6)
USDC_E = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'
ONRAMP = w3.to_checksum_address('0x93070a847efEf7F70739046A929D47a521F5B8ee')
# Ensure approval (idempotent โ sets max allowance)
wc.set_collateral_approval(ONRAMP)
# wrap(address _asset, address _to, uint256 _amount)
selector = w3.keccak(text='wrap(address,address,uint256)')[:4].hex()
asset_pad = USDC_E[2:].lower().zfill(64)
to_pad = safe[2:].lower().zfill(64)
amt_pad = hex(amount_int)[2:].zfill(64)
data = '0x' + selector + asset_pad + to_pad + amt_pad
# Execute through Safe with fixed gas (bypass estimation issues)
eoa = wc.account
safe_txn = {'to': ONRAMP, 'data': data, 'operation': 0, 'value': 0}
# Retry with FULL refresh: re-read the Safe nonce + EOA nonce and re-sign on
# each attempt. A redeem tx mining between attempts makes the Safe nonce
# stale (reverting execTransaction); reusing the stale signature wastes gas.
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
safe_nonce = wc.safe.functions.nonce().call()
packed_sig = get_packed_signature(
sign_safe_transaction(eoa, wc.safe, safe_txn, safe_nonce)
)
nonce = w3.eth.get_transaction_count(eoa.address, 'pending')
txn_data = wc.safe.functions.execTransaction(
safe_txn['to'], safe_txn['value'], safe_txn['data'],
0, 0, 0, 0, ADDRESS_ZERO, ADDRESS_ZERO, packed_sig,
).build_transaction({
'from': eoa.address,
'nonce': nonce,
'gas': 500000,
'gasPrice': w3.eth.gas_price,
'chainId': 137,
})
signed = eoa.sign_transaction(txn_data)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
if receipt['status'] == 1:
logger.info(f"๐ฑ Wrapped ${usdc_balance:.2f} USDC.e โ pUSD (tx: {tx_hash.hex()[:16]}...)")
return usdc_balance
else:
logger.error(f"โ Wrap failed (tx reverted): {tx_hash.hex()[:16]}...")
return 0
except Exception as e:
err_msg = str(e).lower()
if 'nonce too low' in err_msg and attempt < max_retries:
time.sleep(2)
nonce = w3.eth.get_transaction_count(eoa.address, 'pending')
logger.warning(f"โ ๏ธ Wrap nonce retry {attempt}/{max_retries}, new nonce={nonce}")
continue
raise # re-raise if not nonce error or last attempt
return 0 # should not reach here
except Exception as e:
logger.error(f"โ Wrap USDC.eโpUSD failed: {e}")
return 0
def get_balance() -> float | None:
"""Get USDC balance from Polymarket exchange contract"""
client = get_client()
if not client:
return None
try:
from py_clob_client_v2 import BalanceAllowanceParams, AssetType
params = BalanceAllowanceParams(asset_type=AssetType.COLLATERAL) # library bug: params=None crashes
balance = client.get_balance_allowance(params)
raw = float(balance.get("balance", 0)) if balance else 0
# Polymarket CLOB always returns balance in USDC micro-units (6 decimals)
return raw / 1_000_000
except Exception as e:
logger.error(f"Balance check failed: {e}")
return None
def get_wallet_balance() -> float | None:
"""Get on-chain USDC balance from EOA wallet (where redeems land)"""
try:
import requests
from eth_account import Account
eoa = Account.from_key(PRIVATE_KEY).address
addr_padded = eoa[2:].lower().zfill(64)
USDC_NATIVE = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'
# publicnode works reliably (ankr returns stale data)
rpc = 'https://polygon-bor-rpc.publicnode.com'
r = requests.post(rpc, json={
'jsonrpc': '2.0', 'method': 'eth_call',
'params': [{'to': USDC_NATIVE, 'data': '0x70a08231' + addr_padded}, 'latest'],
'id': 1
}, timeout=5, proxies={'http': None, 'https': None})
result = r.json().get('result', '0x0')
return int(result, 16) / 1e6
except Exception as e:
logger.error(f"Wallet balance check failed: {e}")
return None
๐ Git History
058de34fix(audit): chunk 4 - minor robustness, display, calibration5 weeks ago
3de9313fix(audit): chunk 3 - executor robustness, scheduler, redeem, edge logic5 weeks ago
ddaa0a2fix(audit): chunk 2 - kill switch, auth, config safety5 weeks ago
16f2ea8fix(audit): chunk 1 - critical money/correctness bugs5 weeks ago
8fca132chore: initial commit โ version control setup5 weeks ago
Show last diff
Loading...