"""NegRisk opportunity engine. On every book update for a token that belongs to a known event, recompute the sum of VWAP best-asks across all outcomes of that event and emit an `Opportunity` when the depth-clipped net edge exceeds the threshold. The math deliberately walks the book rather than trusting top-of-book — the executable edge on a 100-share basket is often smaller than the quoted top. """ from __future__ import annotations import asyncio from collections.abc import AsyncIterator from dataclasses import dataclass, field from datetime import UTC, datetime from decimal import Decimal from loguru import logger from ..book.l2 import BookRegistry, BookUpdate, LiveBook from ..config import settings from ..models import Event, Opportunity, OpportunityLeg BPS = Decimal(10_000) @dataclass(frozen=True, slots=True) class EngineConfig: min_net_edge_bps: int fees_per_share_usd: Decimal gas_per_basket_usd: Decimal max_basket_usd: Decimal min_basket_count: Decimal = Decimal(1) size_grid: tuple[Decimal, ...] = ( Decimal(10), Decimal(25), Decimal(50), Decimal(100), Decimal(250), Decimal(500), Decimal(1000), ) @classmethod def from_settings(cls) -> EngineConfig: return cls( min_net_edge_bps=settings.min_net_edge_bps, # CLOB taker fee is typically 0 on Polymarket; keep a hook for non-zero. fees_per_share_usd=Decimal("0.0"), # Approx sum of gas for split/buy legs + redeem on Polygon, USD-denominated. # Sized conservatively at ~$0.10 until we wire a real gas oracle. gas_per_basket_usd=Decimal("0.10"), max_basket_usd=settings.max_basket_usd, ) @dataclass class EventIndex: """Registry mapping token_id -> Event so engine can look up siblings.""" by_event_id: dict[str, Event] = field(default_factory=dict) by_token_id: dict[str, str] = field(default_factory=dict) def upsert(self, event: Event) -> None: self.by_event_id[event.id] = event for o in event.outcomes: self.by_token_id[o.token_id] = event.id def event_for_token(self, token_id: str) -> Event | None: eid = self.by_token_id.get(token_id) return self.by_event_id.get(eid) if eid else None class OpportunityEngine: def __init__( self, *, books: BookRegistry, index: EventIndex, config: EngineConfig | None = None, ) -> None: self._books = books self._index = index self._config = config or EngineConfig.from_settings() self._out: asyncio.Queue[Opportunity] = asyncio.Queue(maxsize=256) @property def config(self) -> EngineConfig: return self._config async def run(self) -> None: async for update in self._books.updates(): try: self._handle(update) except Exception as exc: logger.exception("engine handler failed: {}", exc) def _handle(self, update: BookUpdate) -> None: event = self._index.event_for_token(update.token_id) if event is None: return opp = self.evaluate(event) if opp is not None: self._out.put_nowait(opp) async def opportunities(self) -> AsyncIterator[Opportunity]: while True: yield await self._out.get() def evaluate(self, event: Event) -> Opportunity | None: legs_books: list[tuple[int, str, str, LiveBook]] = [] for o in event.outcomes: book = self._books.get(o.token_id) if book is None or not book.asks: return None legs_books.append((o.outcome_index, o.token_id, o.name, book)) best_opp: Opportunity | None = None best_profit = Decimal("-1") for candidate_k in self._candidate_sizes(legs_books): legs, cost_sum = self._walk_legs(legs_books, candidate_k) if legs is None: continue gross = Decimal(1) * candidate_k - cost_sum # per-basket gross = 1 - Σ vwap gross_per_basket = gross / candidate_k n = Decimal(len(legs)) fee_cost_per_basket = self._config.fees_per_share_usd * n gas_amortized = self._config.gas_per_basket_usd / candidate_k net_per_basket = gross_per_basket - fee_cost_per_basket - gas_amortized if net_per_basket <= 0: continue bps = int((net_per_basket / Decimal(1)) * BPS) if bps < self._config.min_net_edge_bps: continue expected_profit = net_per_basket * candidate_k if expected_profit <= best_profit: continue candidate = Opportunity.from_legs( detected_at=datetime.now(UTC), event=event, legs=tuple(legs), fees_per_share=self._config.fees_per_share_usd, gas_per_basket_usd=gas_amortized, max_baskets=candidate_k, ) best_opp = candidate best_profit = expected_profit return best_opp def _candidate_sizes( self, legs_books: list[tuple[int, str, str, LiveBook]] ) -> list[Decimal]: depth = min(sum(lb.asks.values()) for *_, lb in legs_books) if depth <= 0: return [] est_cost_per_share = sum( (lb.asks.keys()[0] for *_, lb in legs_books), Decimal(0) ) budget_cap = ( self._config.max_basket_usd / est_cost_per_share if est_cost_per_share > 0 else depth ) ceiling = min(depth, budget_cap) if ceiling < self._config.min_basket_count: return [] candidates = [s for s in self._config.size_grid if s <= ceiling] if not candidates or candidates[-1] != ceiling: candidates.append(ceiling) return candidates def _walk_legs( self, legs_books: list[tuple[int, str, str, LiveBook]], size: Decimal, ) -> tuple[list[OpportunityLeg] | None, Decimal]: legs: list[OpportunityLeg] = [] total_cost = Decimal(0) for idx, token_id, name, book in legs_books: res = book.vwap_buy(size) if res is None: return None, Decimal(0) vwap, filled, levels = res if filled < size: return None, Decimal(0) total_cost += vwap * size legs.append( OpportunityLeg( token_id=token_id, outcome_name=name, outcome_index=idx, vwap_price=vwap, size=size, levels_consumed=levels, ) ) return legs, total_cost