199 lines
6.7 KiB
Python
199 lines
6.7 KiB
Python
|
|
"""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
|