first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,236 @@
|
||||
"""In-memory L2 order book per token.
|
||||
|
||||
Kept deliberately small: a sorted dict per side (price -> size), plus a bit of
|
||||
bookkeeping to detect desync. The opportunity engine consumes immutable
|
||||
`Book` pydantic snapshots rendered from this structure on every update.
|
||||
"""
|
||||
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 enum import Enum
|
||||
from typing import Self
|
||||
|
||||
from sortedcontainers import SortedDict
|
||||
|
||||
from ..models import Book, BookLevel
|
||||
|
||||
REMOVE_AT_ZERO = Decimal(0)
|
||||
|
||||
|
||||
class Side(str, Enum):
|
||||
BID = "BID"
|
||||
ASK = "ASK"
|
||||
|
||||
|
||||
def _to_decimal(v: object) -> Decimal:
|
||||
if isinstance(v, Decimal):
|
||||
return v
|
||||
if isinstance(v, (int, float)):
|
||||
return Decimal(str(v))
|
||||
if isinstance(v, str):
|
||||
return Decimal(v)
|
||||
raise TypeError(f"cannot coerce {type(v).__name__} to Decimal")
|
||||
|
||||
|
||||
def _coerce_side(v: object) -> Side:
|
||||
if isinstance(v, Side):
|
||||
return v
|
||||
s = str(v).strip().upper()
|
||||
if s in ("BUY", "BID", "B"):
|
||||
return Side.BID
|
||||
if s in ("SELL", "ASK", "S", "A"):
|
||||
return Side.ASK
|
||||
raise ValueError(f"unknown side: {v!r}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LevelChange:
|
||||
price: Decimal
|
||||
size: Decimal
|
||||
side: Side
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw: dict[str, object]) -> Self:
|
||||
return cls(
|
||||
price=_to_decimal(raw["price"]),
|
||||
size=_to_decimal(raw["size"]),
|
||||
side=_coerce_side(raw["side"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LiveBook:
|
||||
"""Mutable L2 book for one token. Bids and asks are sorted price ladders."""
|
||||
|
||||
token_id: str
|
||||
bids: SortedDict = field(default_factory=SortedDict) # price (Decimal) -> size (Decimal)
|
||||
asks: SortedDict = field(default_factory=SortedDict)
|
||||
snapshots_applied: int = 0
|
||||
deltas_applied: int = 0
|
||||
last_update: datetime | None = None
|
||||
last_hash: str | None = None
|
||||
|
||||
def apply_snapshot(
|
||||
self,
|
||||
*,
|
||||
bids: list[tuple[Decimal, Decimal]],
|
||||
asks: list[tuple[Decimal, Decimal]],
|
||||
timestamp: datetime | None = None,
|
||||
book_hash: str | None = None,
|
||||
) -> None:
|
||||
self.bids.clear()
|
||||
self.asks.clear()
|
||||
for price, size in bids:
|
||||
if size > 0:
|
||||
self.bids[price] = size
|
||||
for price, size in asks:
|
||||
if size > 0:
|
||||
self.asks[price] = size
|
||||
self.snapshots_applied += 1
|
||||
self.last_update = timestamp or datetime.now(UTC)
|
||||
self.last_hash = book_hash
|
||||
|
||||
def apply_delta(
|
||||
self,
|
||||
changes: list[LevelChange],
|
||||
*,
|
||||
timestamp: datetime | None = None,
|
||||
book_hash: str | None = None,
|
||||
) -> None:
|
||||
for ch in changes:
|
||||
side_map = self.bids if ch.side is Side.BID else self.asks
|
||||
if ch.size <= REMOVE_AT_ZERO:
|
||||
side_map.pop(ch.price, None)
|
||||
else:
|
||||
side_map[ch.price] = ch.size
|
||||
self.deltas_applied += 1
|
||||
self.last_update = timestamp or datetime.now(UTC)
|
||||
self.last_hash = book_hash
|
||||
|
||||
def best_bid(self) -> tuple[Decimal, Decimal] | None:
|
||||
if not self.bids:
|
||||
return None
|
||||
price = self.bids.keys()[-1]
|
||||
return (price, self.bids[price])
|
||||
|
||||
def best_ask(self) -> tuple[Decimal, Decimal] | None:
|
||||
if not self.asks:
|
||||
return None
|
||||
price = self.asks.keys()[0]
|
||||
return (price, self.asks[price])
|
||||
|
||||
def vwap_buy(self, target_size: Decimal) -> tuple[Decimal, Decimal, int] | None:
|
||||
"""Walk asks ascending to fill `target_size`.
|
||||
Returns (vwap_price, filled_size, levels_consumed). If unable to fill any, None.
|
||||
"""
|
||||
if target_size <= 0 or not self.asks:
|
||||
return None
|
||||
remaining = target_size
|
||||
cost = Decimal(0)
|
||||
filled = Decimal(0)
|
||||
levels = 0
|
||||
for price in self.asks.keys():
|
||||
size = self.asks[price]
|
||||
take = min(remaining, size)
|
||||
cost += take * price
|
||||
filled += take
|
||||
levels += 1
|
||||
remaining -= take
|
||||
if remaining <= 0:
|
||||
break
|
||||
if filled <= 0:
|
||||
return None
|
||||
return (cost / filled, filled, levels)
|
||||
|
||||
def to_snapshot(self) -> Book:
|
||||
bids = [
|
||||
BookLevel(price=p, size=s)
|
||||
for p, s in reversed(list(self.bids.items())) # descending
|
||||
]
|
||||
asks = [BookLevel(price=p, size=s) for p, s in self.asks.items()]
|
||||
return Book(
|
||||
token_id=self.token_id,
|
||||
bids=bids,
|
||||
asks=asks,
|
||||
sequence=self.snapshots_applied + self.deltas_applied,
|
||||
updated_at=self.last_update or datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BookUpdate:
|
||||
"""Emitted whenever a token's book is mutated."""
|
||||
|
||||
token_id: str
|
||||
reason: str # "snapshot" | "delta"
|
||||
at: datetime
|
||||
|
||||
|
||||
class BookRegistry:
|
||||
"""Holds LiveBooks and multiplexes update events to subscribers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._books: dict[str, LiveBook] = {}
|
||||
self._subscribers: list[asyncio.Queue[BookUpdate]] = []
|
||||
|
||||
def book(self, token_id: str) -> LiveBook:
|
||||
book = self._books.get(token_id)
|
||||
if book is None:
|
||||
book = LiveBook(token_id=token_id)
|
||||
self._books[token_id] = book
|
||||
return book
|
||||
|
||||
def get(self, token_id: str) -> LiveBook | None:
|
||||
return self._books.get(token_id)
|
||||
|
||||
def tokens(self) -> list[str]:
|
||||
return list(self._books.keys())
|
||||
|
||||
def apply_snapshot(
|
||||
self,
|
||||
token_id: str,
|
||||
*,
|
||||
bids: list[tuple[Decimal, Decimal]],
|
||||
asks: list[tuple[Decimal, Decimal]],
|
||||
timestamp: datetime | None = None,
|
||||
book_hash: str | None = None,
|
||||
) -> None:
|
||||
self.book(token_id).apply_snapshot(
|
||||
bids=bids, asks=asks, timestamp=timestamp, book_hash=book_hash
|
||||
)
|
||||
self._publish(BookUpdate(token_id=token_id, reason="snapshot", at=datetime.now(UTC)))
|
||||
|
||||
def apply_delta(
|
||||
self,
|
||||
token_id: str,
|
||||
changes: list[LevelChange],
|
||||
*,
|
||||
timestamp: datetime | None = None,
|
||||
book_hash: str | None = None,
|
||||
) -> None:
|
||||
self.book(token_id).apply_delta(
|
||||
changes, timestamp=timestamp, book_hash=book_hash
|
||||
)
|
||||
self._publish(BookUpdate(token_id=token_id, reason="delta", at=datetime.now(UTC)))
|
||||
|
||||
async def updates(self, maxsize: int = 1024) -> AsyncIterator[BookUpdate]:
|
||||
queue: asyncio.Queue[BookUpdate] = asyncio.Queue(maxsize=maxsize)
|
||||
self._subscribers.append(queue)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
self._subscribers.remove(queue)
|
||||
|
||||
def _publish(self, update: BookUpdate) -> None:
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put_nowait(update)
|
||||
except asyncio.QueueFull:
|
||||
# If a consumer can't keep up, drop — the engine uses the
|
||||
# *current* book state anyway, so missing a tick is harmless.
|
||||
pass
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Arbitrage CLI — `arb <subcommand>`.
|
||||
|
||||
Subcommands:
|
||||
arb init Create the SQLite schema.
|
||||
arb discover One pass of Gamma REST discovery (or --loop for forever).
|
||||
arb scan Full paper loop: WS + engine + paper executor.
|
||||
arb web Start the FastAPI + HTMX dashboard.
|
||||
arb resolve Mark a resolution (manual fallback).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .config import Mode, settings
|
||||
from .logging_setup import configure_logging
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="arb", description="Polymarket arbitrage")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("init", help="create SQLite schema")
|
||||
|
||||
disc = sub.add_parser("discover", help="one pass of Gamma REST discovery")
|
||||
disc.add_argument("--loop", action="store_true", help="run forever")
|
||||
disc.add_argument("--interval", type=int, default=120, help="seconds between passes")
|
||||
disc.add_argument("--max-pages", type=int, default=20)
|
||||
|
||||
sub.add_parser("scan", help="run the paper scan loop (WS + engine + executor)")
|
||||
|
||||
webp = sub.add_parser("web", help="start the dashboard")
|
||||
webp.add_argument("--host", default=settings.web_host)
|
||||
webp.add_argument("--port", type=int, default=settings.web_port)
|
||||
|
||||
res = sub.add_parser("resolve", help="mark an event as resolved")
|
||||
res.add_argument("event_id")
|
||||
res.add_argument(
|
||||
"--winner",
|
||||
default=None,
|
||||
help="winning token_id (omit or set to 'invalid' for an invalid resolution)",
|
||||
)
|
||||
res.add_argument("--source", default="manual")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
async def _cmd_init() -> None:
|
||||
from .db import init_db
|
||||
|
||||
await init_db()
|
||||
print(f"initialized {settings.db_path}")
|
||||
|
||||
|
||||
async def _cmd_discover(loop: bool, interval: int, max_pages: int) -> None:
|
||||
from .clients.polymarket_rest import discover_once, discovery_loop
|
||||
|
||||
if loop:
|
||||
await discovery_loop(interval_seconds=interval)
|
||||
else:
|
||||
stats = await discover_once(max_pages=max_pages)
|
||||
print(
|
||||
f"seen={stats.events_seen} negRisk={stats.neg_risk_events} "
|
||||
f"upserted={stats.upserted} malformed={stats.skipped_malformed}"
|
||||
)
|
||||
|
||||
|
||||
async def _cmd_scan() -> None:
|
||||
from .book.l2 import BookRegistry
|
||||
from .clients.polymarket_ws import run_market_channels
|
||||
from .engine.live_executor import LiveExecutor
|
||||
from .engine.loop import hydrate_event_index, run_scan_loop
|
||||
from .engine.opportunity import EventIndex, OpportunityEngine
|
||||
from .engine.paper_fills import PaperExecutor
|
||||
|
||||
books = BookRegistry()
|
||||
index = EventIndex()
|
||||
hydrated = await hydrate_event_index(index)
|
||||
if hydrated == 0:
|
||||
print("no events in DB; run `arb discover` first")
|
||||
return
|
||||
token_ids = list(index.by_token_id.keys())
|
||||
|
||||
engine = OpportunityEngine(books=books, index=index)
|
||||
executor = (
|
||||
LiveExecutor(books=books, dry_run=False)
|
||||
if settings.mode == Mode.LIVE
|
||||
else PaperExecutor(books=books)
|
||||
)
|
||||
ws_task = asyncio.create_task(
|
||||
run_market_channels(token_ids, registry=books), name="ws"
|
||||
)
|
||||
try:
|
||||
await run_scan_loop(books=books, index=index, engine=engine, executor=executor)
|
||||
finally:
|
||||
ws_task.cancel()
|
||||
try:
|
||||
await ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
def _cmd_web(host: str, port: int) -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("arbitrage.web.app:app", host=host, port=port, reload=False)
|
||||
|
||||
|
||||
async def _cmd_resolve(event_id: str, winner: str | None, source: str) -> None:
|
||||
from .engine.paper_fills import mark_resolution
|
||||
|
||||
winning = None if (winner is None or winner.lower() == "invalid") else winner
|
||||
updated = await mark_resolution(event_id, winning_token_id=winning, source=source)
|
||||
print(f"resolved event {event_id}: {updated} basket(s) updated")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
configure_logging()
|
||||
args = _parser().parse_args(argv)
|
||||
|
||||
if args.cmd == "init":
|
||||
asyncio.run(_cmd_init())
|
||||
elif args.cmd == "discover":
|
||||
asyncio.run(_cmd_discover(args.loop, args.interval, args.max_pages))
|
||||
elif args.cmd == "scan":
|
||||
asyncio.run(_cmd_scan())
|
||||
elif args.cmd == "web":
|
||||
_cmd_web(args.host, args.port)
|
||||
elif args.cmd == "resolve":
|
||||
asyncio.run(_cmd_resolve(args.event_id, args.winner, args.source))
|
||||
else: # argparse guarantees required=True; belt-and-braces
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Polymarket Gamma REST client.
|
||||
|
||||
Paginates /events, filters to active negRisk categoricals, and normalizes each
|
||||
event into our pydantic `Event` model. The shape of a Gamma event is documented
|
||||
in docs/api/negrisk.md; we read only the fields we need and ignore the rest.
|
||||
|
||||
Refresh cadence and persistence live here too — the whole subsystem is kept in
|
||||
one file since the only consumer is the opportunity engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import orjson
|
||||
from loguru import logger
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from ..config import settings
|
||||
from ..db import db_conn
|
||||
from ..models import Event, Outcome
|
||||
|
||||
GAMMA_EVENTS_PATH = "/events"
|
||||
DEFAULT_PAGE_SIZE = 100
|
||||
DEFAULT_TIMEOUT_S = 20.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DiscoveryStats:
|
||||
pages_fetched: int = 0
|
||||
events_seen: int = 0
|
||||
neg_risk_events: int = 0
|
||||
upserted: int = 0
|
||||
skipped_inactive: int = 0
|
||||
skipped_not_neg_risk: int = 0
|
||||
skipped_malformed: int = 0
|
||||
|
||||
|
||||
class GammaClient:
|
||||
"""Thin async wrapper around Polymarket's Gamma REST API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
page_size: int = DEFAULT_PAGE_SIZE,
|
||||
timeout_s: float = DEFAULT_TIMEOUT_S,
|
||||
) -> None:
|
||||
self._host = (host or settings.gamma_host).rstrip("/")
|
||||
self._page_size = page_size
|
||||
self._timeout_s = timeout_s
|
||||
self._owns_client = client is None
|
||||
self._client = client or httpx.AsyncClient(
|
||||
base_url=self._host,
|
||||
timeout=timeout_s,
|
||||
headers={"accept": "application/json"},
|
||||
proxy=settings.proxy,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> GammaClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def iter_active_event_pages(
|
||||
self,
|
||||
*,
|
||||
order: str = "volume24hr",
|
||||
ascending: bool = False,
|
||||
max_pages: int = 50,
|
||||
) -> AsyncIterator[list[dict[str, Any]]]:
|
||||
"""Yield each page of raw event dicts until a short page or max_pages."""
|
||||
offset = 0
|
||||
for _ in range(max_pages):
|
||||
params = {
|
||||
"closed": "false",
|
||||
"archived": "false",
|
||||
"active": "true",
|
||||
"limit": self._page_size,
|
||||
"offset": offset,
|
||||
"order": order,
|
||||
"ascending": "true" if ascending else "false",
|
||||
}
|
||||
page = await self._get_json(GAMMA_EVENTS_PATH, params)
|
||||
if not isinstance(page, list):
|
||||
logger.warning("Gamma /events returned non-list: {!r}", type(page))
|
||||
return
|
||||
if not page:
|
||||
return
|
||||
yield page
|
||||
if len(page) < self._page_size:
|
||||
return
|
||||
offset += self._page_size
|
||||
|
||||
async def _get_json(self, path: str, params: dict[str, Any]) -> Any:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(4),
|
||||
wait=wait_exponential(multiplier=0.5, max=5.0),
|
||||
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
resp = await self._client.get(path, params=params)
|
||||
resp.raise_for_status()
|
||||
return orjson.loads(resp.content)
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
|
||||
def normalize_event(raw: dict[str, Any]) -> Event | None:
|
||||
"""Translate a raw Gamma event dict into our `Event` model.
|
||||
|
||||
Returns None if the event is not an active negRisk categorical, or if its
|
||||
required fields are missing/malformed.
|
||||
"""
|
||||
if not raw.get("negRisk"):
|
||||
return None
|
||||
markets = raw.get("markets") or []
|
||||
if len(markets) < 2:
|
||||
return None
|
||||
|
||||
event_id = raw.get("negRiskMarketID") or str(raw.get("id") or "")
|
||||
if not event_id:
|
||||
return None
|
||||
|
||||
outcomes: list[Outcome] = []
|
||||
for idx, m in enumerate(markets):
|
||||
if m.get("closed") or m.get("archived"):
|
||||
return None
|
||||
token_ids_raw = m.get("clobTokenIds")
|
||||
if isinstance(token_ids_raw, str):
|
||||
try:
|
||||
token_ids = orjson.loads(token_ids_raw)
|
||||
except orjson.JSONDecodeError:
|
||||
return None
|
||||
else:
|
||||
token_ids = token_ids_raw
|
||||
if not isinstance(token_ids, list) or len(token_ids) < 1:
|
||||
return None
|
||||
yes_token_id = str(token_ids[0])
|
||||
if not yes_token_id:
|
||||
return None
|
||||
name = (
|
||||
m.get("groupItemTitle")
|
||||
or m.get("outcome")
|
||||
or m.get("question")
|
||||
or f"Outcome {idx + 1}"
|
||||
)
|
||||
outcomes.append(Outcome(token_id=yes_token_id, name=str(name), outcome_index=idx))
|
||||
|
||||
if len(outcomes) < 2:
|
||||
return None
|
||||
|
||||
seen: set[str] = set()
|
||||
for o in outcomes:
|
||||
if o.token_id in seen:
|
||||
return None
|
||||
seen.add(o.token_id)
|
||||
|
||||
end_date = _parse_end_date(raw.get("endDate"))
|
||||
|
||||
return Event(
|
||||
id=event_id,
|
||||
slug=str(raw.get("slug") or event_id),
|
||||
title=str(raw.get("title") or raw.get("slug") or event_id),
|
||||
is_neg_risk=True,
|
||||
end_date=end_date,
|
||||
outcomes=tuple(outcomes),
|
||||
)
|
||||
|
||||
|
||||
def _parse_end_date(value: Any) -> datetime | None:
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
async def upsert_events(events: Iterable[Event], *, raw_by_id: dict[str, Any] | None = None) -> int:
|
||||
"""Persist events + outcomes to SQLite. Returns count of events upserted."""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
raw_by_id = raw_by_id or {}
|
||||
count = 0
|
||||
async with db_conn() as conn:
|
||||
for ev in events:
|
||||
raw_json = json.dumps(raw_by_id.get(ev.id)) if ev.id in raw_by_id else None
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO events (id, slug, title, is_neg_risk, end_date, active, raw_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
slug=excluded.slug,
|
||||
title=excluded.title,
|
||||
is_neg_risk=excluded.is_neg_risk,
|
||||
end_date=excluded.end_date,
|
||||
active=1,
|
||||
raw_json=COALESCE(excluded.raw_json, events.raw_json),
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
ev.id,
|
||||
ev.slug,
|
||||
ev.title,
|
||||
1 if ev.is_neg_risk else 0,
|
||||
ev.end_date.isoformat() if ev.end_date else None,
|
||||
raw_json,
|
||||
now,
|
||||
),
|
||||
)
|
||||
for o in ev.outcomes:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO outcomes (token_id, event_id, name, outcome_index, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(token_id) DO UPDATE SET
|
||||
event_id=excluded.event_id,
|
||||
name=excluded.name,
|
||||
outcome_index=excluded.outcome_index,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(o.token_id, ev.id, o.name, o.outcome_index, now),
|
||||
)
|
||||
count += 1
|
||||
await conn.commit()
|
||||
return count
|
||||
|
||||
|
||||
async def mark_inactive(kept_event_ids: set[str]) -> int:
|
||||
"""Mark any previously-active event not in `kept_event_ids` as inactive."""
|
||||
if not kept_event_ids:
|
||||
return 0
|
||||
now = datetime.now(UTC).isoformat()
|
||||
placeholders = ",".join("?" for _ in kept_event_ids)
|
||||
async with db_conn() as conn:
|
||||
cursor = await conn.execute(
|
||||
f"UPDATE events SET active=0, updated_at=? "
|
||||
f"WHERE active=1 AND id NOT IN ({placeholders})",
|
||||
(now, *kept_event_ids),
|
||||
)
|
||||
await conn.commit()
|
||||
return cursor.rowcount or 0
|
||||
|
||||
|
||||
async def discover_once(
|
||||
*,
|
||||
client: GammaClient | None = None,
|
||||
max_pages: int = 20,
|
||||
) -> DiscoveryStats:
|
||||
"""Single pass: walk Gamma, filter to negRisk, persist, mark drops inactive."""
|
||||
stats = DiscoveryStats()
|
||||
owned: GammaClient | None = None
|
||||
if client is None:
|
||||
owned = GammaClient()
|
||||
client = owned
|
||||
|
||||
events: list[Event] = []
|
||||
raw_by_id: dict[str, Any] = {}
|
||||
try:
|
||||
async for page in client.iter_active_event_pages(max_pages=max_pages):
|
||||
stats.pages_fetched += 1
|
||||
for raw in page:
|
||||
stats.events_seen += 1
|
||||
if not raw.get("negRisk"):
|
||||
stats.skipped_not_neg_risk += 1
|
||||
continue
|
||||
stats.neg_risk_events += 1
|
||||
if raw.get("closed") or raw.get("archived"):
|
||||
stats.skipped_inactive += 1
|
||||
continue
|
||||
ev = normalize_event(raw)
|
||||
if ev is None:
|
||||
stats.skipped_malformed += 1
|
||||
continue
|
||||
events.append(ev)
|
||||
raw_by_id[ev.id] = raw
|
||||
finally:
|
||||
if owned is not None:
|
||||
await owned.aclose()
|
||||
|
||||
stats.upserted = await upsert_events(events, raw_by_id=raw_by_id)
|
||||
await mark_inactive({ev.id for ev in events})
|
||||
logger.info(
|
||||
"discovery: seen={}, negRisk={}, upserted={}, malformed={}",
|
||||
stats.events_seen,
|
||||
stats.neg_risk_events,
|
||||
stats.upserted,
|
||||
stats.skipped_malformed,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def discovery_loop(interval_seconds: int = 120) -> None:
|
||||
"""Run discover_once forever, sleeping between passes."""
|
||||
while True:
|
||||
try:
|
||||
await discover_once()
|
||||
except Exception as exc:
|
||||
logger.exception("discovery pass failed: {}", exc)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Polymarket CLOB market-channel WebSocket subscriber.
|
||||
|
||||
Wire format reference (April 2026):
|
||||
- endpoint: wss://ws-subscriptions-clob.polymarket.com/ws/market
|
||||
- subscribe: {"type":"market","assets_ids":[...],"custom_feature_enabled":true}
|
||||
- snapshot: event_type="book", asks/bids are lists of {price,size} strings
|
||||
- delta: event_type="price_change", wrapper="price_changes",
|
||||
side="BUY"|"SELL", size="0" means remove
|
||||
- keepalive: send the literal text frame "PING" every ~10s
|
||||
- desync: no sequence numbers; on hash mismatch or missed heartbeat,
|
||||
drop state and re-subscribe (server re-pushes snapshot)
|
||||
- sharding: cap tokens/socket around 100-200; open more sockets as needed
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import websockets
|
||||
from loguru import logger
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from ..book.l2 import BookRegistry, LevelChange, Side
|
||||
from ..config import settings
|
||||
import socket
|
||||
|
||||
DEFAULT_SHARD_SIZE = 100
|
||||
PING_INTERVAL_S = 10.0
|
||||
RECONNECT_BACKOFF_S = (1.0, 2.0, 4.0, 8.0, 15.0)
|
||||
|
||||
|
||||
async def _tunnel_through_proxy(uri: str) -> socket.socket | None:
|
||||
"""Open an HTTP CONNECT tunnel to the WS host via the configured proxy.
|
||||
|
||||
Returns a connected socket ready for the WS handshake, or None if no proxy.
|
||||
"""
|
||||
if not settings.proxy:
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(uri)
|
||||
proxy_parsed = urlparse(settings.proxy)
|
||||
host, port = parsed.hostname, parsed.port or (443 if parsed.scheme == "wss" else 80)
|
||||
sock = socket.create_connection((proxy_parsed.hostname, proxy_parsed.port), timeout=10)
|
||||
connect_req = (
|
||||
f"CONNECT {host}:{port} HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
f"Proxy-Connection: keep-alive\r\n\r\n"
|
||||
)
|
||||
sock.sendall(connect_req.encode())
|
||||
resp = b""
|
||||
while b"\r\n\r\n" not in resp:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise OSError("proxy closed before CONNECT response")
|
||||
resp += chunk
|
||||
status = resp.split(b"\r\n", 1)[0]
|
||||
if b" 200 " not in status:
|
||||
sock.close()
|
||||
raise OSError(f"proxy CONNECT failed: {status!r}")
|
||||
sock.settimeout(None)
|
||||
return sock
|
||||
|
||||
|
||||
def _to_decimal(v: object, default: Decimal = Decimal(0)) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(v))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _parse_ts_ms(v: Any) -> datetime:
|
||||
try:
|
||||
ms = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return datetime.now(UTC)
|
||||
return datetime.fromtimestamp(ms / 1000.0, tz=UTC)
|
||||
|
||||
|
||||
def _parse_level_list(raw: Any) -> list[tuple[Decimal, Decimal]]:
|
||||
out: list[tuple[Decimal, Decimal]] = []
|
||||
if not isinstance(raw, list):
|
||||
return out
|
||||
for lvl in raw:
|
||||
if not isinstance(lvl, dict):
|
||||
continue
|
||||
price = _to_decimal(lvl.get("price"))
|
||||
size = _to_decimal(lvl.get("size"))
|
||||
if price <= 0:
|
||||
continue
|
||||
out.append((price, size))
|
||||
return out
|
||||
|
||||
|
||||
def _side_from_buy_sell(raw: Any) -> Side | None:
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
s = raw.strip().upper()
|
||||
if s == "BUY":
|
||||
return Side.BID
|
||||
if s == "SELL":
|
||||
return Side.ASK
|
||||
return None
|
||||
|
||||
|
||||
class MarketChannel:
|
||||
"""Maintains a single WS connection for one shard of token ids."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token_ids: list[str],
|
||||
*,
|
||||
registry: BookRegistry,
|
||||
url: str | None = None,
|
||||
) -> None:
|
||||
if not token_ids:
|
||||
raise ValueError("MarketChannel needs at least one token id")
|
||||
self._token_ids = list(token_ids)
|
||||
self._registry = registry
|
||||
self._url = url or settings.ws_url
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
async def run(self) -> None:
|
||||
backoff_cycle = itertools.cycle(RECONNECT_BACKOFF_S)
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await self._connect_and_consume()
|
||||
# Clean exit (e.g. stop requested); break loop.
|
||||
if self._stop.is_set():
|
||||
return
|
||||
delay = 1.0
|
||||
except (TimeoutError, ConnectionClosed, OSError) as exc:
|
||||
delay = next(backoff_cycle)
|
||||
logger.warning(
|
||||
"ws disconnect ({}); reconnecting in {}s", exc.__class__.__name__, delay
|
||||
)
|
||||
except Exception as exc:
|
||||
delay = next(backoff_cycle)
|
||||
logger.exception("ws fatal ({}); reconnecting in {}s", exc, delay)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
async def _connect_and_consume(self) -> None:
|
||||
sock = await _tunnel_through_proxy(self._url)
|
||||
connect_kwargs = {"max_size": 2**22}
|
||||
if sock is not None:
|
||||
connect_kwargs["sock"] = sock
|
||||
async with websockets.connect(self._url, **connect_kwargs) as ws:
|
||||
await self._subscribe(ws)
|
||||
heartbeat = asyncio.create_task(self._heartbeat(ws))
|
||||
try:
|
||||
async for raw in ws:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._handle_message(raw)
|
||||
finally:
|
||||
heartbeat.cancel()
|
||||
try:
|
||||
await heartbeat
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _subscribe(self, ws: ClientConnection) -> None:
|
||||
msg = orjson.dumps(
|
||||
{
|
||||
"type": "market",
|
||||
"assets_ids": self._token_ids,
|
||||
"custom_feature_enabled": True,
|
||||
}
|
||||
)
|
||||
await ws.send(msg)
|
||||
logger.info("ws subscribed to {} tokens", len(self._token_ids))
|
||||
|
||||
async def _heartbeat(self, ws: ClientConnection) -> None:
|
||||
while not self._stop.is_set():
|
||||
await asyncio.sleep(PING_INTERVAL_S)
|
||||
try:
|
||||
await ws.send("PING")
|
||||
except ConnectionClosed:
|
||||
return
|
||||
|
||||
def _handle_message(self, raw: str | bytes) -> None:
|
||||
if isinstance(raw, str):
|
||||
if raw.strip() in ("PONG", "PING"):
|
||||
return
|
||||
payload = orjson.loads(raw)
|
||||
else:
|
||||
# Binary frames shouldn't normally arrive from this channel.
|
||||
try:
|
||||
payload = orjson.loads(raw)
|
||||
except orjson.JSONDecodeError:
|
||||
return
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
self._dispatch(item)
|
||||
elif isinstance(payload, dict):
|
||||
self._dispatch(payload)
|
||||
|
||||
def _dispatch(self, msg: dict[str, Any]) -> None:
|
||||
event_type = msg.get("event_type")
|
||||
if event_type == "book":
|
||||
self._apply_book(msg)
|
||||
elif event_type == "price_change":
|
||||
self._apply_price_change(msg)
|
||||
elif event_type in ("tick_size_change", "last_trade_price", "best_bid_ask"):
|
||||
# Not used by the arb math today; logged at debug.
|
||||
logger.debug("ws {}: {}", event_type, msg)
|
||||
elif event_type in ("new_market", "market_resolved"):
|
||||
logger.info("ws {}: {}", event_type, msg)
|
||||
else:
|
||||
logger.debug("ws unknown event_type={}: {}", event_type, msg)
|
||||
|
||||
def _apply_book(self, msg: dict[str, Any]) -> None:
|
||||
asset_id = msg.get("asset_id")
|
||||
if not isinstance(asset_id, str):
|
||||
return
|
||||
bids = _parse_level_list(msg.get("bids"))
|
||||
asks = _parse_level_list(msg.get("asks"))
|
||||
ts = _parse_ts_ms(msg.get("timestamp"))
|
||||
self._registry.apply_snapshot(
|
||||
asset_id, bids=bids, asks=asks, timestamp=ts, book_hash=msg.get("hash")
|
||||
)
|
||||
|
||||
def _apply_price_change(self, msg: dict[str, Any]) -> None:
|
||||
ts = _parse_ts_ms(msg.get("timestamp"))
|
||||
changes_raw = msg.get("price_changes")
|
||||
if not isinstance(changes_raw, list):
|
||||
return
|
||||
by_asset: dict[str, list[LevelChange]] = {}
|
||||
latest_hash: dict[str, str] = {}
|
||||
for c in changes_raw:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
asset_id = c.get("asset_id")
|
||||
side = _side_from_buy_sell(c.get("side"))
|
||||
if not isinstance(asset_id, str) or side is None:
|
||||
continue
|
||||
price = _to_decimal(c.get("price"))
|
||||
size = _to_decimal(c.get("size"))
|
||||
if price <= 0:
|
||||
continue
|
||||
by_asset.setdefault(asset_id, []).append(
|
||||
LevelChange(price=price, size=size, side=side)
|
||||
)
|
||||
h = c.get("hash")
|
||||
if isinstance(h, str):
|
||||
latest_hash[asset_id] = h
|
||||
for asset_id, changes in by_asset.items():
|
||||
self._registry.apply_delta(
|
||||
asset_id,
|
||||
changes,
|
||||
timestamp=ts,
|
||||
book_hash=latest_hash.get(asset_id),
|
||||
)
|
||||
|
||||
|
||||
def shard_tokens(token_ids: list[str], shard_size: int = DEFAULT_SHARD_SIZE) -> list[list[str]]:
|
||||
return [token_ids[i : i + shard_size] for i in range(0, len(token_ids), shard_size)]
|
||||
|
||||
|
||||
async def run_market_channels(
|
||||
token_ids: list[str],
|
||||
*,
|
||||
registry: BookRegistry,
|
||||
shard_size: int = DEFAULT_SHARD_SIZE,
|
||||
) -> None:
|
||||
"""Run one MarketChannel per shard concurrently. Returns when all exit."""
|
||||
if not token_ids:
|
||||
return
|
||||
channels = [MarketChannel(shard, registry=registry) for shard in shard_tokens(token_ids, shard_size)]
|
||||
try:
|
||||
await asyncio.gather(*(ch.run() for ch in channels))
|
||||
finally:
|
||||
for ch in channels:
|
||||
ch.stop()
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Mode(str, Enum):
|
||||
PAPER = "paper"
|
||||
LIVE = "live"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
env_prefix="ARB_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
mode: Mode = Mode.PAPER
|
||||
|
||||
clob_host: str = "https://clob.polymarket.com"
|
||||
gamma_host: str = "https://gamma-api.polymarket.com"
|
||||
data_host: str = "https://data-api.polymarket.com"
|
||||
ws_url: str = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
|
||||
polygon_rpc: str = "https://polygon-rpc.com"
|
||||
proxy: str | None = None
|
||||
|
||||
private_key: SecretStr | None = None
|
||||
funder_address: str | None = None
|
||||
signature_type: int = 0
|
||||
api_key: SecretStr | None = None
|
||||
api_secret: SecretStr | None = None
|
||||
api_passphrase: SecretStr | None = None
|
||||
|
||||
min_net_edge_bps: int = 50
|
||||
max_basket_usd: Decimal = Field(default=Decimal("50"))
|
||||
max_open_baskets: int = 3
|
||||
max_open_baskets_per_event: int = 1
|
||||
daily_loss_stop_usd: Decimal = Field(default=Decimal("100"))
|
||||
kill_switch_file: Path = Path("./KILL")
|
||||
resolution_skip_hours: int = 24
|
||||
|
||||
paper_latency_ms: int = 250
|
||||
|
||||
db_path: Path = Path("./arbitrage.db")
|
||||
web_host: str = "127.0.0.1"
|
||||
web_port: int = 8000
|
||||
log_level: str = "INFO"
|
||||
|
||||
@property
|
||||
def is_live(self) -> bool:
|
||||
return self.mode == Mode.LIVE
|
||||
|
||||
def require_live_credentials(self) -> None:
|
||||
if not self.is_live:
|
||||
return
|
||||
missing = [
|
||||
name
|
||||
for name, val in [
|
||||
("ARB_PRIVATE_KEY", self.private_key),
|
||||
("ARB_FUNDER_ADDRESS", self.funder_address),
|
||||
]
|
||||
if not val
|
||||
]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"Live mode requires: {', '.join(missing)}. Set them in .env or run paper mode."
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from .config import settings
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT,
|
||||
title TEXT,
|
||||
is_neg_risk INTEGER NOT NULL,
|
||||
end_date TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
raw_json TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outcomes (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
outcome_index INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_outcomes_event ON outcomes(event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opportunities (
|
||||
id TEXT PRIMARY KEY,
|
||||
detected_at TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
event_title TEXT NOT NULL,
|
||||
sum_vwap_asks TEXT NOT NULL,
|
||||
net_edge_bps INTEGER NOT NULL,
|
||||
max_baskets TEXT NOT NULL,
|
||||
expected_profit_usd TEXT NOT NULL,
|
||||
legs_json TEXT NOT NULL,
|
||||
acted_on INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_opps_detected ON opportunities(detected_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_opps_event ON opportunities(event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS baskets (
|
||||
id TEXT PRIMARY KEY,
|
||||
opportunity_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
is_paper INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
basket_count TEXT NOT NULL,
|
||||
total_cost_usd TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
redeemed_at TEXT,
|
||||
redeemed_payout_usd TEXT,
|
||||
realized_pnl_usd TEXT,
|
||||
FOREIGN KEY (opportunity_id) REFERENCES opportunities(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_baskets_status ON baskets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_baskets_event ON baskets(event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
basket_id TEXT NOT NULL,
|
||||
token_id TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
price TEXT NOT NULL,
|
||||
size TEXT NOT NULL,
|
||||
fee_usd TEXT NOT NULL,
|
||||
filled_at TEXT NOT NULL,
|
||||
FOREIGN KEY (basket_id) REFERENCES baskets(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fills_basket ON fills(basket_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
basket_id TEXT NOT NULL,
|
||||
token_id TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
price TEXT NOT NULL,
|
||||
size TEXT NOT NULL,
|
||||
order_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
clob_order_id TEXT,
|
||||
tx_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_orders_basket ON live_orders(basket_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resolutions (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
winning_outcome_token_id TEXT,
|
||||
resolved_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS denylist (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
reason TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_pnl (
|
||||
date TEXT PRIMARY KEY,
|
||||
paper_pnl_usd TEXT NOT NULL DEFAULT '0',
|
||||
live_pnl_usd TEXT NOT NULL DEFAULT '0',
|
||||
baskets_opened INTEGER NOT NULL DEFAULT 0,
|
||||
baskets_redeemed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
async def init_db(db_path: Path | None = None) -> None:
|
||||
path = db_path or settings.db_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(path) as conn:
|
||||
await conn.execute("PRAGMA journal_mode=WAL")
|
||||
await conn.execute("PRAGMA foreign_keys=ON")
|
||||
await conn.executescript(SCHEMA_SQL)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def db_conn(db_path: Path | None = None) -> AsyncIterator[aiosqlite.Connection]:
|
||||
path = db_path or settings.db_path
|
||||
async with aiosqlite.connect(path) as conn:
|
||||
await conn.execute("PRAGMA journal_mode=WAL")
|
||||
await conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
yield conn
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Executor protocol.
|
||||
|
||||
Paper and live executors share the same surface so the rest of the engine is
|
||||
mode-agnostic. The Opportunity -> Basket lifecycle is identical until the
|
||||
very bottom of the stack, where one simulates and the other signs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from ..models import Basket, Opportunity
|
||||
|
||||
|
||||
class Executor(Protocol):
|
||||
async def execute(self, opp: Opportunity) -> Basket | None:
|
||||
"""Try to open a basket from an opportunity.
|
||||
|
||||
Returns the persisted basket on success (even for a `failed` basket —
|
||||
that still got persisted for forensics). Returns None if the executor
|
||||
rejected the opportunity before touching storage (e.g. risk gate).
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,353 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Scan loop: book updates -> engine.evaluate -> executor.execute.
|
||||
|
||||
One small glue function so the CLI and tests can both spin up the full
|
||||
pipeline. Keeps the engine/executor decoupled — either side is swappable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from ..book.l2 import BookRegistry
|
||||
from ..db import db_conn
|
||||
from .executor import Executor
|
||||
from .opportunity import EventIndex, OpportunityEngine
|
||||
|
||||
|
||||
async def hydrate_event_index(index: EventIndex) -> int:
|
||||
"""Load all active negRisk events from SQLite into the in-memory index."""
|
||||
from ..models import Event, Outcome
|
||||
|
||||
async with db_conn() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, slug, title, is_neg_risk, end_date
|
||||
FROM events
|
||||
WHERE active=1
|
||||
"""
|
||||
)
|
||||
event_rows = await cur.fetchall()
|
||||
count = 0
|
||||
for row in event_rows:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT token_id, name, outcome_index
|
||||
FROM outcomes WHERE event_id=? ORDER BY outcome_index
|
||||
""",
|
||||
(row[0],),
|
||||
)
|
||||
outs = await cur.fetchall()
|
||||
if len(outs) < 2:
|
||||
continue
|
||||
from datetime import datetime
|
||||
|
||||
end_date = None
|
||||
if row[4]:
|
||||
try:
|
||||
end_date = datetime.fromisoformat(row[4])
|
||||
except ValueError:
|
||||
end_date = None
|
||||
ev = Event(
|
||||
id=row[0],
|
||||
slug=row[1],
|
||||
title=row[2],
|
||||
is_neg_risk=bool(row[3]),
|
||||
end_date=end_date,
|
||||
outcomes=tuple(
|
||||
Outcome(token_id=o[0], name=o[1], outcome_index=o[2]) for o in outs
|
||||
),
|
||||
)
|
||||
index.upsert(ev)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def run_scan_loop(
|
||||
*,
|
||||
books: BookRegistry,
|
||||
index: EventIndex,
|
||||
engine: OpportunityEngine,
|
||||
executor: Executor,
|
||||
) -> None:
|
||||
"""Drive engine + executor off the book registry's update stream."""
|
||||
engine_task = asyncio.create_task(engine.run(), name="engine.run")
|
||||
logger.info("scan loop started ({} events hydrated)", len(index.by_event_id))
|
||||
try:
|
||||
async for opp in engine.opportunities():
|
||||
try:
|
||||
await executor.execute(opp)
|
||||
except Exception as exc:
|
||||
logger.exception("executor failed on opp {}: {}", opp.id, exc)
|
||||
finally:
|
||||
engine_task.cancel()
|
||||
try:
|
||||
await engine_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -0,0 +1,198 @@
|
||||
"""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
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Paper-fill executor.
|
||||
|
||||
When an opportunity arrives, snapshot the book, wait `paper_latency_ms` to
|
||||
model getting beaten by faster bots, and only fill against levels that survive
|
||||
that delay. The simulator biases PnL *downward* relative to naive "fill at
|
||||
observation time" paper trading.
|
||||
|
||||
Persistence: writes one `baskets` row (is_paper=1) plus one `fills` row per leg.
|
||||
Status transitions:
|
||||
- detected -> open (created, legs in flight)
|
||||
- all legs filled at size -> pending_resolution
|
||||
- any leg short -> failed (persisted for forensics; no resolution step)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from ..book.l2 import BookRegistry, LiveBook
|
||||
from ..config import settings
|
||||
from ..db import db_conn
|
||||
from ..models import Basket, BasketStatus, Fill, Opportunity, Side
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaperFillResult:
|
||||
filled: Decimal
|
||||
vwap_price: Decimal
|
||||
levels_consumed: int
|
||||
|
||||
|
||||
def simulate_leg_fill(book: LiveBook, target_size: Decimal) -> PaperFillResult:
|
||||
"""Walk `book.asks` at the *current* moment and fill up to `target_size`.
|
||||
Returns the actually-filled size (may be < target_size if depth vanished).
|
||||
"""
|
||||
if target_size <= 0 or not book.asks:
|
||||
return PaperFillResult(Decimal(0), Decimal(0), 0)
|
||||
remaining = target_size
|
||||
cost = Decimal(0)
|
||||
filled = Decimal(0)
|
||||
levels = 0
|
||||
for price in list(book.asks.keys()):
|
||||
size = book.asks.get(price, Decimal(0))
|
||||
if size <= 0:
|
||||
continue
|
||||
take = min(remaining, size)
|
||||
cost += take * price
|
||||
filled += take
|
||||
levels += 1
|
||||
remaining -= take
|
||||
if remaining <= 0:
|
||||
break
|
||||
vwap = (cost / filled) if filled > 0 else Decimal(0)
|
||||
return PaperFillResult(filled, vwap, levels)
|
||||
|
||||
|
||||
class PaperExecutor:
|
||||
"""Runs fill simulations + persistence for paper baskets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
books: BookRegistry,
|
||||
latency_ms: int | None = None,
|
||||
fee_rate: Decimal = Decimal(0),
|
||||
) -> None:
|
||||
self._books = books
|
||||
self._latency_ms = latency_ms if latency_ms is not None else settings.paper_latency_ms
|
||||
self._fee_rate = fee_rate
|
||||
|
||||
@property
|
||||
def latency_ms(self) -> int:
|
||||
return self._latency_ms
|
||||
|
||||
async def execute(self, opp: Opportunity) -> Basket | None:
|
||||
await asyncio.sleep(self._latency_ms / 1000.0)
|
||||
return await self._simulate_and_persist(opp)
|
||||
|
||||
async def execute_now(self, opp: Opportunity) -> Basket | None:
|
||||
"""Skip the sleep — used by tests that want deterministic fills."""
|
||||
return await self._simulate_and_persist(opp)
|
||||
|
||||
async def _simulate_and_persist(self, opp: Opportunity) -> Basket | None:
|
||||
target = opp.max_baskets
|
||||
if target <= 0:
|
||||
return None
|
||||
|
||||
fills: list[Fill] = []
|
||||
total_cost = Decimal(0)
|
||||
short_legs = 0
|
||||
min_filled: Decimal | None = None
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for leg in opp.legs:
|
||||
book = self._books.get(leg.token_id)
|
||||
if book is None:
|
||||
short_legs += 1
|
||||
continue
|
||||
result = simulate_leg_fill(book, target)
|
||||
if result.filled < target:
|
||||
short_legs += 1
|
||||
if min_filled is None or result.filled < min_filled:
|
||||
min_filled = result.filled
|
||||
fee = result.vwap_price * result.filled * self._fee_rate
|
||||
fills.append(
|
||||
Fill(
|
||||
token_id=leg.token_id,
|
||||
side=Side.BUY,
|
||||
price=result.vwap_price,
|
||||
size=result.filled,
|
||||
fee_usd=fee,
|
||||
filled_at=now,
|
||||
)
|
||||
)
|
||||
total_cost += result.vwap_price * result.filled + fee
|
||||
|
||||
basket_count = min_filled if min_filled is not None else Decimal(0)
|
||||
if short_legs > 0 or basket_count <= 0:
|
||||
status = BasketStatus.FAILED
|
||||
else:
|
||||
status = BasketStatus.PENDING_RESOLUTION
|
||||
|
||||
basket = Basket(
|
||||
opportunity_id=opp.id,
|
||||
event_id=opp.event_id,
|
||||
is_paper=True,
|
||||
created_at=now,
|
||||
basket_count=basket_count,
|
||||
total_cost_usd=total_cost,
|
||||
status=status,
|
||||
fills=fills,
|
||||
)
|
||||
await self._persist(opp, basket)
|
||||
logger.info(
|
||||
"paper basket {} status={} count={} cost={}",
|
||||
basket.id,
|
||||
basket.status.value,
|
||||
basket.basket_count,
|
||||
basket.total_cost_usd,
|
||||
)
|
||||
return basket
|
||||
|
||||
async def _persist(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 (?, ?, ?, 1, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
basket.id,
|
||||
basket.opportunity_id,
|
||||
basket.event_id,
|
||||
basket.created_at.isoformat(),
|
||||
str(basket.basket_count),
|
||||
str(basket.total_cost_usd),
|
||||
basket.status.value,
|
||||
),
|
||||
)
|
||||
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 mark_resolution(
|
||||
event_id: str,
|
||||
*,
|
||||
winning_token_id: str | None,
|
||||
resolved_at: datetime | None = None,
|
||||
source: str = "manual",
|
||||
) -> int:
|
||||
"""Apply a resolution to all open paper baskets for the event.
|
||||
|
||||
Updates `resolutions` row, flips matching baskets to redeemed/invalid,
|
||||
and writes realized PnL. Returns number of baskets updated.
|
||||
"""
|
||||
resolved_at = resolved_at or datetime.now(UTC)
|
||||
async with db_conn() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO resolutions (event_id, winning_outcome_token_id, resolved_at, source)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(event_id) DO UPDATE SET
|
||||
winning_outcome_token_id=excluded.winning_outcome_token_id,
|
||||
resolved_at=excluded.resolved_at,
|
||||
source=excluded.source
|
||||
""",
|
||||
(event_id, winning_token_id, resolved_at.isoformat(), source),
|
||||
)
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT id, basket_count, total_cost_usd, status
|
||||
FROM baskets
|
||||
WHERE event_id=? AND is_paper=1 AND status=?
|
||||
""",
|
||||
(event_id, BasketStatus.PENDING_RESOLUTION.value),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
updated = 0
|
||||
for row in rows:
|
||||
basket_id = row[0]
|
||||
basket_count = Decimal(row[1])
|
||||
cost = Decimal(row[2])
|
||||
if winning_token_id is None:
|
||||
payout = Decimal(0)
|
||||
new_status = BasketStatus.INVALID.value
|
||||
else:
|
||||
payout = basket_count * Decimal(1)
|
||||
new_status = BasketStatus.REDEEMED.value
|
||||
pnl = payout - cost
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE baskets
|
||||
SET status=?, redeemed_at=?, redeemed_payout_usd=?, realized_pnl_usd=?
|
||||
WHERE id=?
|
||||
""",
|
||||
(
|
||||
new_status,
|
||||
resolved_at.isoformat(),
|
||||
str(payout),
|
||||
str(pnl),
|
||||
basket_id,
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
await conn.commit()
|
||||
return updated
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
logger.remove()
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level=settings.log_level,
|
||||
format=(
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> "
|
||||
"| <level>{level: <8}</level> "
|
||||
"| <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> "
|
||||
"- <level>{message}</level>"
|
||||
),
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
)
|
||||
logger.add(
|
||||
"logs/arbitrage.jsonl",
|
||||
level="DEBUG",
|
||||
rotation="1 day",
|
||||
retention="7 days",
|
||||
serialize=True,
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Self
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
def _uuid() -> str:
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
class Side(str, Enum):
|
||||
BUY = "BUY"
|
||||
SELL = "SELL"
|
||||
|
||||
|
||||
class OrderType(str, Enum):
|
||||
GTC = "GTC"
|
||||
FAK = "FAK"
|
||||
FOK = "FOK"
|
||||
GTD = "GTD"
|
||||
|
||||
|
||||
class BasketStatus(str, Enum):
|
||||
OPEN = "open"
|
||||
PARTIAL = "partial"
|
||||
FAILED = "failed"
|
||||
PENDING_RESOLUTION = "pending_resolution"
|
||||
REDEEMED = "redeemed"
|
||||
INVALID = "invalid"
|
||||
|
||||
|
||||
class Outcome(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
token_id: str
|
||||
name: str
|
||||
outcome_index: int
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str
|
||||
slug: str
|
||||
title: str
|
||||
is_neg_risk: bool
|
||||
end_date: datetime | None
|
||||
outcomes: tuple[Outcome, ...]
|
||||
|
||||
@property
|
||||
def n_outcomes(self) -> int:
|
||||
return len(self.outcomes)
|
||||
|
||||
|
||||
class BookLevel(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
price: Decimal
|
||||
size: Decimal
|
||||
|
||||
|
||||
class Book(BaseModel):
|
||||
"""L2 order book for a single token. Bids descending, asks ascending."""
|
||||
|
||||
token_id: str
|
||||
bids: list[BookLevel] = Field(default_factory=list)
|
||||
asks: list[BookLevel] = Field(default_factory=list)
|
||||
sequence: int = 0
|
||||
updated_at: datetime
|
||||
|
||||
@property
|
||||
def best_bid(self) -> BookLevel | None:
|
||||
return self.bids[0] if self.bids else None
|
||||
|
||||
@property
|
||||
def best_ask(self) -> BookLevel | None:
|
||||
return self.asks[0] if self.asks else None
|
||||
|
||||
def vwap_buy(self, target_size: Decimal) -> tuple[Decimal, Decimal] | None:
|
||||
"""Walk asks to fill `target_size`. Returns (vwap_price, filled_size).
|
||||
If the book can't fill the full size, returns the partial fill."""
|
||||
if target_size <= 0 or not self.asks:
|
||||
return None
|
||||
remaining = target_size
|
||||
cost = Decimal(0)
|
||||
filled = Decimal(0)
|
||||
for level in self.asks:
|
||||
take = min(remaining, level.size)
|
||||
cost += take * level.price
|
||||
filled += take
|
||||
remaining -= take
|
||||
if remaining <= 0:
|
||||
break
|
||||
if filled <= 0:
|
||||
return None
|
||||
return (cost / filled, filled)
|
||||
|
||||
|
||||
class OpportunityLeg(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
token_id: str
|
||||
outcome_name: str
|
||||
outcome_index: int
|
||||
vwap_price: Decimal
|
||||
size: Decimal
|
||||
levels_consumed: int
|
||||
|
||||
|
||||
class Opportunity(BaseModel):
|
||||
"""A snapshot of edge math for one event at one moment."""
|
||||
|
||||
id: str = Field(default_factory=_uuid)
|
||||
detected_at: datetime
|
||||
event_id: str
|
||||
event_title: str
|
||||
legs: tuple[OpportunityLeg, ...]
|
||||
sum_vwap_asks: Decimal
|
||||
fees_per_share: Decimal
|
||||
gas_per_basket_usd: Decimal
|
||||
gross_edge_usd_per_basket: Decimal
|
||||
net_edge_usd_per_basket: Decimal
|
||||
net_edge_bps: int
|
||||
max_baskets: Decimal
|
||||
expected_profit_usd: Decimal
|
||||
|
||||
@classmethod
|
||||
def from_legs(
|
||||
cls,
|
||||
*,
|
||||
detected_at: datetime,
|
||||
event: Event,
|
||||
legs: tuple[OpportunityLeg, ...],
|
||||
fees_per_share: Decimal,
|
||||
gas_per_basket_usd: Decimal,
|
||||
max_baskets: Decimal,
|
||||
) -> Self:
|
||||
sum_asks = sum((leg.vwap_price for leg in legs), Decimal(0))
|
||||
gross_per_basket = Decimal(1) - sum_asks
|
||||
n = Decimal(event.n_outcomes)
|
||||
net_per_basket = gross_per_basket - (fees_per_share * n) - gas_per_basket_usd
|
||||
net_bps = int((net_per_basket / Decimal(1)) * Decimal(10_000)) if net_per_basket else 0
|
||||
expected = net_per_basket * max_baskets
|
||||
return cls(
|
||||
detected_at=detected_at,
|
||||
event_id=event.id,
|
||||
event_title=event.title,
|
||||
legs=legs,
|
||||
sum_vwap_asks=sum_asks,
|
||||
fees_per_share=fees_per_share,
|
||||
gas_per_basket_usd=gas_per_basket_usd,
|
||||
gross_edge_usd_per_basket=gross_per_basket,
|
||||
net_edge_usd_per_basket=net_per_basket,
|
||||
net_edge_bps=net_bps,
|
||||
max_baskets=max_baskets,
|
||||
expected_profit_usd=expected,
|
||||
)
|
||||
|
||||
|
||||
class Fill(BaseModel):
|
||||
token_id: str
|
||||
side: Side
|
||||
price: Decimal
|
||||
size: Decimal
|
||||
fee_usd: Decimal
|
||||
filled_at: datetime
|
||||
|
||||
|
||||
class Basket(BaseModel):
|
||||
id: str = Field(default_factory=_uuid)
|
||||
opportunity_id: str
|
||||
event_id: str
|
||||
is_paper: bool
|
||||
created_at: datetime
|
||||
basket_count: Decimal
|
||||
total_cost_usd: Decimal
|
||||
status: BasketStatus
|
||||
fills: list[Fill] = Field(default_factory=list)
|
||||
redeemed_at: datetime | None = None
|
||||
redeemed_payout_usd: Decimal | None = None
|
||||
realized_pnl_usd: Decimal | None = None
|
||||
|
||||
|
||||
class Resolution(BaseModel):
|
||||
event_id: str
|
||||
winning_outcome_token_id: str | None
|
||||
resolved_at: datetime
|
||||
source: str
|
||||
@@ -0,0 +1,184 @@
|
||||
"""FastAPI + HTMX dashboard.
|
||||
|
||||
Single page. Server-rendered via Jinja. HTMX polls the fragment endpoints
|
||||
every few seconds so there's no client-side state and no build step.
|
||||
Endpoints:
|
||||
|
||||
GET / full page
|
||||
GET /fragments/opportunities table of recent opportunities
|
||||
GET /fragments/baskets table of open + recent baskets
|
||||
GET /fragments/pnl paper pnl summary + mode indicator
|
||||
POST /kill touch the kill switch file (immediate halt)
|
||||
POST /unkill remove the kill switch file
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from ..config import settings
|
||||
from ..db import db_conn, init_db
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="arbitrage dashboard", lifespan=_lifespan)
|
||||
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||
# Python 3.14 + Jinja2 LRUCache regression: disable caching.
|
||||
templates.env.cache = None
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request, "index.html", {"mode": settings.mode.value}
|
||||
)
|
||||
|
||||
@app.get("/fragments/opportunities", response_class=HTMLResponse)
|
||||
async def opps(request: Request) -> HTMLResponse:
|
||||
rows = await _recent_opportunities(limit=25)
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/opportunities.html", {"rows": rows}
|
||||
)
|
||||
|
||||
@app.get("/fragments/baskets", response_class=HTMLResponse)
|
||||
async def baskets(request: Request) -> HTMLResponse:
|
||||
rows = await _recent_baskets(limit=25)
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/baskets.html", {"rows": rows}
|
||||
)
|
||||
|
||||
@app.get("/fragments/pnl", response_class=HTMLResponse)
|
||||
async def pnl(request: Request) -> HTMLResponse:
|
||||
summary = await _paper_pnl_summary()
|
||||
kill_active = settings.kill_switch_file.exists()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"fragments/pnl.html",
|
||||
{
|
||||
"mode": settings.mode.value,
|
||||
"kill_active": kill_active,
|
||||
**summary,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/kill", response_class=HTMLResponse)
|
||||
async def kill(request: Request) -> HTMLResponse:
|
||||
settings.kill_switch_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings.kill_switch_file.touch(exist_ok=True)
|
||||
return await pnl(request)
|
||||
|
||||
@app.post("/unkill", response_class=HTMLResponse)
|
||||
async def unkill(request: Request) -> HTMLResponse:
|
||||
path = settings.kill_switch_file
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return await pnl(request)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _recent_opportunities(limit: int) -> list[dict]:
|
||||
async with db_conn() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT detected_at, event_title, sum_vwap_asks, net_edge_bps,
|
||||
max_baskets, expected_profit_usd, acted_on
|
||||
FROM opportunities
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"detected_at": r[0],
|
||||
"event_title": r[1],
|
||||
"sum_vwap_asks": r[2],
|
||||
"net_edge_bps": r[3],
|
||||
"max_baskets": r[4],
|
||||
"expected_profit_usd": r[5],
|
||||
"acted_on": bool(r[6]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _recent_baskets(limit: int) -> list[dict]:
|
||||
async with db_conn() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT b.id, b.created_at, e.title, b.is_paper, b.basket_count,
|
||||
b.total_cost_usd, b.status, b.realized_pnl_usd
|
||||
FROM baskets b
|
||||
LEFT JOIN events e ON e.id = b.event_id
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0][:8],
|
||||
"created_at": r[1],
|
||||
"event_title": r[2] or "?",
|
||||
"is_paper": bool(r[3]),
|
||||
"basket_count": r[4],
|
||||
"total_cost_usd": r[5],
|
||||
"status": r[6],
|
||||
"realized_pnl_usd": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _paper_pnl_summary() -> dict:
|
||||
async with db_conn() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status='redeemed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status='pending_resolution' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status='invalid' THEN 1 ELSE 0 END), 0)
|
||||
FROM baskets WHERE is_paper=1
|
||||
"""
|
||||
)
|
||||
counts = await cur.fetchone()
|
||||
cur = await conn.execute(
|
||||
"SELECT realized_pnl_usd FROM baskets "
|
||||
"WHERE is_paper=1 AND realized_pnl_usd IS NOT NULL"
|
||||
)
|
||||
pnl_rows = await cur.fetchall()
|
||||
total = Decimal(0)
|
||||
for (val,) in pnl_rows:
|
||||
if val is None:
|
||||
continue
|
||||
try:
|
||||
total += Decimal(val)
|
||||
except (ArithmeticError, ValueError):
|
||||
pass
|
||||
return {
|
||||
"redeemed": counts[0],
|
||||
"pending": counts[1],
|
||||
"failed": counts[2],
|
||||
"invalid": counts[3],
|
||||
"realized_pnl_usd": f"{total:.4f}",
|
||||
}
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,29 @@
|
||||
<h2>baskets</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id</th>
|
||||
<th>event</th>
|
||||
<th>mode</th>
|
||||
<th class="num">count</th>
|
||||
<th class="num">cost</th>
|
||||
<th>status</th>
|
||||
<th class="num">pnl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in rows %}
|
||||
<tr>
|
||||
<td>{{ b.id }}</td>
|
||||
<td>{{ b.event_title }}</td>
|
||||
<td>{{ "paper" if b.is_paper else "live" }}</td>
|
||||
<td class="num">{{ b.basket_count }}</td>
|
||||
<td class="num">{{ b.total_cost_usd }}</td>
|
||||
<td><span class="badge {{ b.status }}">{{ b.status }}</span></td>
|
||||
<td class="num {% if b.realized_pnl_usd and b.realized_pnl_usd.startswith('-') %}neg{% elif b.realized_pnl_usd %}pos{% endif %}">{{ b.realized_pnl_usd or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" style="color:#8b949e;">no baskets yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,29 @@
|
||||
<h2>recent opportunities</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>detected</th>
|
||||
<th>event</th>
|
||||
<th class="num">Σ asks</th>
|
||||
<th class="num">net bps</th>
|
||||
<th class="num">max baskets</th>
|
||||
<th class="num">expected $</th>
|
||||
<th>acted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.detected_at }}</td>
|
||||
<td>{{ r.event_title }}</td>
|
||||
<td class="num">{{ r.sum_vwap_asks }}</td>
|
||||
<td class="num {% if r.net_edge_bps > 0 %}pos{% else %}neg{% endif %}">{{ r.net_edge_bps }}</td>
|
||||
<td class="num">{{ r.max_baskets }}</td>
|
||||
<td class="num">{{ r.expected_profit_usd }}</td>
|
||||
<td>{% if r.acted_on %}<span class="badge redeemed">yes</span>{% else %}<span class="badge">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" style="color:#8b949e;">no opportunities yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,17 @@
|
||||
<h2>paper pnl</h2>
|
||||
<table>
|
||||
<tr><th>realized pnl (usd)</th><td class="num {% if realized_pnl_usd.startswith('-') %}neg{% else %}pos{% endif %}">{{ realized_pnl_usd }}</td></tr>
|
||||
<tr><th>redeemed</th><td class="num">{{ redeemed }}</td></tr>
|
||||
<tr><th>pending resolution</th><td class="num">{{ pending }}</td></tr>
|
||||
<tr><th>failed</th><td class="num">{{ failed }}</td></tr>
|
||||
<tr><th>invalid</th><td class="num">{{ invalid }}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
mode: <strong>{{ mode }}</strong>
|
||||
{% if kill_active %}
|
||||
· <span class="badge failed">KILL SWITCH ACTIVE</span>
|
||||
<button class="unkill" hx-post="/unkill" hx-target="#pnl" hx-swap="innerHTML">arm (remove kill)</button>
|
||||
{% else %}
|
||||
<button class="kill" hx-post="/kill" hx-target="#pnl" hx-swap="innerHTML">kill</button>
|
||||
{% endif %}
|
||||
</p>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>arbitrage — {{ mode }}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
margin: 0; padding: 1.5rem; background: #0e1117; color: #e6edf3; }
|
||||
h1 { font-size: 1.1rem; margin: 0 0 1rem; letter-spacing: 0.02em; }
|
||||
h1 .mode { padding: 0.15em 0.5em; border-radius: 4px; font-size: 0.8em;
|
||||
margin-left: 0.6em; background: #1f6feb; color: white; }
|
||||
h1 .mode.live { background: #da3633; }
|
||||
.grid { display: grid; gap: 1.25rem; grid-template-columns: 1fr; }
|
||||
@media (min-width: 960px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||
section { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||
padding: 1rem; }
|
||||
section h2 { font-size: 0.85rem; margin: 0 0 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: #8b949e; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 0.4rem 0.55rem; border-bottom: 1px solid #21262d; }
|
||||
th { color: #8b949e; font-weight: 600; }
|
||||
.num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.pos { color: #3fb950; }
|
||||
.neg { color: #f85149; }
|
||||
.badge { padding: 0.1em 0.45em; border-radius: 4px; font-size: 0.75em; background: #30363d; }
|
||||
.badge.redeemed { background: #1f6f3e; }
|
||||
.badge.failed { background: #8b1a1a; }
|
||||
.badge.pending_resolution { background: #5a4200; }
|
||||
.badge.invalid { background: #3b1f5a; }
|
||||
button { background: #21262d; color: #e6edf3; border: 1px solid #30363d;
|
||||
padding: 0.4em 0.9em; border-radius: 4px; cursor: pointer; }
|
||||
button.kill { background: #8b1a1a; border-color: #da3633; }
|
||||
button.unkill { background: #1f6f3e; border-color: #3fb950; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>arbitrage
|
||||
<span class="mode {% if mode == 'live' %}live{% endif %}">{{ mode }}</span>
|
||||
</h1>
|
||||
|
||||
<div class="grid">
|
||||
<section id="pnl"
|
||||
hx-get="/fragments/pnl" hx-trigger="load, every 3s" hx-swap="innerHTML">
|
||||
loading...
|
||||
</section>
|
||||
|
||||
<section id="baskets"
|
||||
hx-get="/fragments/baskets" hx-trigger="load, every 3s" hx-swap="innerHTML">
|
||||
loading...
|
||||
</section>
|
||||
|
||||
<section id="opps" style="grid-column: 1 / -1;"
|
||||
hx-get="/fragments/opportunities" hx-trigger="load, every 2s" hx-swap="innerHTML">
|
||||
loading...
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user