"""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()