Files
polymarket_arbitrage/arbitrage/engine/live_executor.py
T

354 lines
14 KiB
Python
Raw Normal View History

2026-07-22 18:53:44 +08:00
"""Live executor — deferred behind MODE=live.
Same `Executor` surface as PaperExecutor. Signs EIP-712 orders via
py-clob-client, submits FAK across all legs in parallel, aborts + unwinds on
partial fill, and calls NegRiskAdapter.redeemPositions once a complete set is
held.
Status: functional skeleton. The signing + order submission path is wired up,
but each side-effect is gated by a `dry_run` flag so nothing is broadcast until
the operator explicitly flips it. The risk gate is enforced here; it's the
last thing between an Opportunity and real capital.
See docs/api/order-signing.md and docs/api/negrisk.md for wire details.
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from loguru import logger
from ..book.l2 import BookRegistry
from ..config import Mode, settings
from ..db import db_conn
from ..models import Basket, BasketStatus, Fill, Opportunity, OrderType, Side
@dataclass(slots=True)
class RiskLimits:
max_basket_usd: Decimal
max_open_baskets: int
max_open_baskets_per_event: int
daily_loss_stop_usd: Decimal
kill_switch_file: Path
@classmethod
def from_settings(cls) -> RiskLimits:
return cls(
max_basket_usd=settings.max_basket_usd,
max_open_baskets=settings.max_open_baskets,
max_open_baskets_per_event=settings.max_open_baskets_per_event,
daily_loss_stop_usd=settings.daily_loss_stop_usd,
kill_switch_file=settings.kill_switch_file,
)
class RiskDenied(Exception):
"""Raised when a risk gate refuses an opportunity."""
async def risk_gate(opp: Opportunity, limits: RiskLimits) -> None:
"""Apply hard caps. Raises RiskDenied with a reason if any cap is hit."""
if limits.kill_switch_file.exists():
raise RiskDenied(f"kill switch present: {limits.kill_switch_file}")
cost = opp.sum_vwap_asks * opp.max_baskets
if cost > limits.max_basket_usd:
raise RiskDenied(f"basket cost ${cost} > max ${limits.max_basket_usd}")
today = datetime.now(UTC).date().isoformat()
async with db_conn() as conn:
cursor = await conn.execute(
"""
SELECT COUNT(*) FROM baskets
WHERE is_paper=0 AND status IN (?, ?, ?)
""",
(
BasketStatus.OPEN.value,
BasketStatus.PARTIAL.value,
BasketStatus.PENDING_RESOLUTION.value,
),
)
(open_global,) = await cursor.fetchone()
if open_global >= limits.max_open_baskets:
raise RiskDenied(f"{open_global} live baskets already open")
cursor = await conn.execute(
"""
SELECT COUNT(*) FROM baskets
WHERE event_id=? AND is_paper=0 AND status IN (?, ?, ?)
""",
(
opp.event_id,
BasketStatus.OPEN.value,
BasketStatus.PARTIAL.value,
BasketStatus.PENDING_RESOLUTION.value,
),
)
(open_per_event,) = await cursor.fetchone()
if open_per_event >= limits.max_open_baskets_per_event:
raise RiskDenied(f"event {opp.event_id} already has {open_per_event} open")
cursor = await conn.execute(
"SELECT live_pnl_usd FROM daily_pnl WHERE date=?", (today,)
)
row = await cursor.fetchone()
if row is not None:
pnl = Decimal(row[0])
if pnl <= -limits.daily_loss_stop_usd:
raise RiskDenied(f"daily loss stop hit: pnl={pnl}")
class LiveExecutor:
"""Signs and submits orders. Requires MODE=live and wallet credentials."""
def __init__(
self,
*,
books: BookRegistry,
limits: RiskLimits | None = None,
dry_run: bool = True,
) -> None:
if settings.mode != Mode.LIVE:
raise RuntimeError(
"LiveExecutor instantiated but ARB_MODE is not live — refusing."
)
settings.require_live_credentials()
self._books = books
self._limits = limits or RiskLimits.from_settings()
self._dry_run = dry_run
self._clob = None # py_clob_client.ClobClient, lazy-init
def _ensure_clob(self):
if self._clob is not None:
return self._clob
# Imported lazily so paper-mode users don't need py-clob-client installed.
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import ApiCreds
pk = settings.private_key.get_secret_value() if settings.private_key else None
if pk is None:
raise RuntimeError("ARB_PRIVATE_KEY is required for live mode")
creds = None
if settings.api_key and settings.api_secret and settings.api_passphrase:
creds = ApiCreds(
api_key=settings.api_key.get_secret_value(),
api_secret=settings.api_secret.get_secret_value(),
api_passphrase=settings.api_passphrase.get_secret_value(),
)
self._clob = ClobClient(
host=settings.clob_host,
key=pk,
chain_id=137,
signature_type=settings.signature_type,
funder=settings.funder_address,
creds=creds,
)
if creds is None:
self._clob.set_api_creds(self._clob.create_or_derive_api_creds())
return self._clob
async def execute(self, opp: Opportunity) -> Basket | None:
try:
await risk_gate(opp, self._limits)
except RiskDenied as exc:
logger.warning("risk denied opp {}: {}", opp.id, exc)
return None
now = datetime.now(UTC)
basket = Basket(
opportunity_id=opp.id,
event_id=opp.event_id,
is_paper=False,
created_at=now,
basket_count=opp.max_baskets,
total_cost_usd=Decimal(0),
status=BasketStatus.OPEN,
fills=[],
)
await self._persist_basket_open(opp, basket)
fills, total_cost, shortfall = await self._submit_parallel(opp, basket)
basket.fills = fills
basket.total_cost_usd = total_cost
if shortfall:
logger.error("partial fill detected on basket {}; unwinding", basket.id)
await self._unwind(basket, shortfall)
basket.status = BasketStatus.FAILED
else:
basket.status = BasketStatus.PENDING_RESOLUTION
await self._redeem_or_defer(basket, opp)
await self._persist_basket_final(basket)
return basket
async def _submit_parallel(
self, opp: Opportunity, basket: Basket
) -> tuple[list[Fill], Decimal, dict[str, Decimal]]:
tasks = [self._submit_leg(opp, leg, basket.id) for leg in opp.legs]
results = await asyncio.gather(*tasks, return_exceptions=True)
fills: list[Fill] = []
total_cost = Decimal(0)
shortfall: dict[str, Decimal] = {}
for leg, res in zip(opp.legs, results, strict=True):
if isinstance(res, Exception):
logger.error("leg {} raised: {}", leg.token_id, res)
shortfall[leg.token_id] = leg.size
continue
fill, short = res
fills.append(fill)
total_cost += fill.price * fill.size
if short > 0:
shortfall[leg.token_id] = short
return fills, total_cost, shortfall
async def _submit_leg(self, opp: Opportunity, leg, basket_id: str):
if self._dry_run:
logger.info("[dry_run] would FAK buy token={} price={} size={}",
leg.token_id, leg.vwap_price, leg.size)
fill = Fill(
token_id=leg.token_id, side=Side.BUY,
price=leg.vwap_price, size=leg.size,
fee_usd=Decimal(0), filled_at=datetime.now(UTC),
)
return fill, Decimal(0)
client = self._ensure_clob()
from py_clob_client.clob_types import OrderArgs
from py_clob_client.clob_types import OrderType as ClobOrderType
args = OrderArgs(
token_id=leg.token_id,
price=float(leg.vwap_price),
size=float(leg.size),
side="BUY",
)
# neg_risk=True is critical — routes to NegRiskCtfExchange.
signed = await asyncio.to_thread(
client.create_order, args, options={"neg_risk": True}
)
resp = await asyncio.to_thread(
client.post_order, signed, ClobOrderType.FAK
)
await self._persist_live_order(basket_id, leg, resp)
filled_size = Decimal(str(resp.get("making_amount") or resp.get("size_matched") or 0))
price = Decimal(str(resp.get("price") or leg.vwap_price))
short = leg.size - filled_size
fill = Fill(
token_id=leg.token_id, side=Side.BUY,
price=price, size=filled_size,
fee_usd=Decimal(str(resp.get("fee") or 0)),
filled_at=datetime.now(UTC),
)
return fill, max(short, Decimal(0))
async def _unwind(self, basket: Basket, shortfall: dict[str, Decimal]) -> None:
"""Sell any legs we over-filled relative to the shortfalled ones."""
short_legs = set(shortfall.keys())
for fill in basket.fills:
if fill.token_id in short_legs or fill.size <= 0:
continue
if self._dry_run:
logger.info("[dry_run] would market-sell token={} size={}",
fill.token_id, fill.size)
continue
client = self._ensure_clob()
from py_clob_client.clob_types import OrderArgs
from py_clob_client.clob_types import OrderType as ClobOrderType
args = OrderArgs(
token_id=fill.token_id,
price=0.0, # market
size=float(fill.size),
side="SELL",
)
signed = await asyncio.to_thread(
client.create_order, args, options={"neg_risk": True}
)
await asyncio.to_thread(client.post_order, signed, ClobOrderType.FAK)
async def _redeem_or_defer(self, basket: Basket, opp: Opportunity) -> None:
"""Once the full YES set is held, call NegRiskAdapter.redeemPositions.
Deferred (no-op) in MVP — the patient path is to wait for UMA and
call redeem from a separate resolution worker. This keeps the hot
path small and avoids gas on every successful basket.
"""
logger.info("basket {} pending resolution; redeem deferred to watcher", basket.id)
async def _persist_basket_open(self, opp: Opportunity, basket: Basket) -> None:
legs_json = json.dumps([leg.model_dump(mode="json") for leg in opp.legs])
async with db_conn() as conn:
await conn.execute(
"""
INSERT OR IGNORE INTO opportunities
(id, detected_at, event_id, event_title, sum_vwap_asks,
net_edge_bps, max_baskets, expected_profit_usd, legs_json, acted_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
""",
(
opp.id, opp.detected_at.isoformat(), opp.event_id, opp.event_title,
str(opp.sum_vwap_asks), opp.net_edge_bps, str(opp.max_baskets),
str(opp.expected_profit_usd), legs_json,
),
)
await conn.execute(
"""
INSERT INTO baskets
(id, opportunity_id, event_id, is_paper, created_at, basket_count,
total_cost_usd, status)
VALUES (?, ?, ?, 0, ?, ?, ?, ?)
""",
(basket.id, basket.opportunity_id, basket.event_id,
basket.created_at.isoformat(), str(basket.basket_count),
str(basket.total_cost_usd), basket.status.value),
)
await conn.commit()
async def _persist_basket_final(self, basket: Basket) -> None:
async with db_conn() as conn:
await conn.execute(
"""
UPDATE baskets SET total_cost_usd=?, status=? WHERE id=?
""",
(str(basket.total_cost_usd), basket.status.value, basket.id),
)
for fill in basket.fills:
await conn.execute(
"""
INSERT INTO fills
(basket_id, token_id, side, price, size, fee_usd, filled_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(basket.id, fill.token_id, fill.side.value, str(fill.price),
str(fill.size), str(fill.fee_usd), fill.filled_at.isoformat()),
)
await conn.commit()
async def _persist_live_order(self, basket_id: str, leg, resp: dict) -> None:
now = datetime.now(UTC).isoformat()
async with db_conn() as conn:
await conn.execute(
"""
INSERT INTO live_orders
(id, basket_id, token_id, side, price, size, order_type, status,
clob_order_id, tx_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(resp.get("orderId") or resp.get("id") or f"{basket_id}-{leg.token_id}"),
basket_id, leg.token_id, Side.BUY.value,
str(leg.vwap_price), str(leg.size),
OrderType.FAK.value,
str(resp.get("status") or "submitted"),
resp.get("orderId") or resp.get("id"),
resp.get("transactionHash"),
now, now,
),
)
await conn.commit()