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