Files
polymarket_arbitrage/arbitrage/engine/paper_fills.py
T
gavindiaz 9259325d8a
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled
first commit
2026-07-22 18:53:44 +08:00

270 lines
9.0 KiB
Python

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