first commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user