first commit
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled

This commit is contained in:
2026-07-22 18:53:44 +08:00
commit 9259325d8a
60 changed files with 11081 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
View File
+236
View File
@@ -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
+138
View File
@@ -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())
View File
+317
View File
@@ -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)
+282
View File
@@ -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()
+76
View File
@@ -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
View File
@@ -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
View File
+22
View File
@@ -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).
"""
...
+353
View File
@@ -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()
+87
View File
@@ -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
+198
View File
@@ -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
+269
View File
@@ -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
+30
View File
@@ -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,
)
+193
View File
@@ -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
View File
View File
+184
View File
@@ -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 %}
&middot; <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>
+60
View File
@@ -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>