Getting there

This commit is contained in:
Daniel Sapkota
2026-07-05 17:50:04 -04:00
parent bfacef317b
commit c7b686116e
92 changed files with 8062 additions and 7964 deletions
View File
+287
View File
@@ -0,0 +1,287 @@
"""ExecutionGateway: the only component that sends actions to the CLOB.
Wraps the synchronous py-clob-client-v2 (which owns the hard V2 EIP-712 signing,
pUSD balance adjustment, and tick/fee caching) and offloads its blocking network
calls to a thread pool so the asyncio hot path never stalls. Every quote goes out
**post-only** (the maker-only mandate, enforced at the exchange).
A `paper=True` gateway shares the same path but fabricates order ids instead of
posting — so paper mode exercises the full pipeline.
"""
from __future__ import annotations
import asyncio
import itertools
import time
from dataclasses import asdict
from typing import Any
import httpx
from polymaker.config import Config
from polymaker.domain import MarketMeta, OpenOrder, OrderState, Quote, Side
from polymaker.execution.ratelimit import TokenBucket
from polymaker.journal import Journal
from polymaker.logging import get_logger
log = get_logger("execution.gateway")
def _tick_str(tick: float) -> str:
return f"{tick:g}"
class ExecutionGateway:
def __init__(
self,
cfg: Config,
journal: Journal | None = None,
*,
paper: bool = False,
) -> None:
self._cfg = cfg
self._paper = paper
self._journal = journal
self._client: Any = None # py_clob_client_v2.ClobClient
self._creds: Any = None
self._address: str = "" # signer EOA
self._funder: str = "" # funds/positions live here (proxy/deposit wallet)
self._data_host = cfg.wallet.data_api_host
# rate budgets: fraction of documented POST/DELETE ceilings (per second)
f = cfg.execution.rate_budget_fraction
self._order_bucket = TokenBucket(rate_per_s=200.0 * f, burst=500.0 * f)
self._cancel_bucket = TokenBucket(rate_per_s=200.0 * f, burst=500.0 * f)
self._paper_ids = itertools.count(1)
@property
def paper(self) -> bool:
return self._paper
@property
def creds(self) -> Any:
return self._creds
@property
def address(self) -> str:
"""The signing EOA address."""
return self._address
@property
def funder(self) -> str:
"""The address holding funds/positions (proxy/deposit wallet, or the EOA)."""
return self._funder or self._address
# ── lifecycle ───────────────────────────────────────────────────────
async def connect(self) -> None:
"""Build the client and derive L2 API creds (network). No-op fields in paper."""
sec = self._cfg.secrets
if self._paper and not sec.has_wallet:
# paper mode runs the full pipeline without a wallet (no orders posted)
self._address = sec.browser_address or "0xPAPER"
self._funder = sec.browser_address or self._address
log.info("gateway_connected", address=self._address[:10], paper=True)
return
if not sec.has_wallet:
raise RuntimeError("no wallet configured (set PK and BROWSER_ADDRESS in .env)")
def _build() -> tuple[Any, Any, str]:
from py_clob_client_v2.client import ClobClient
client = ClobClient(
host=self._cfg.wallet.clob_host,
chain_id=self._cfg.wallet.chain_id,
key=sec.pk,
signature_type=self._cfg.wallet.signature_type,
funder=sec.browser_address,
)
creds = client.create_or_derive_api_key()
client.set_api_creds(creds)
return client, creds, client.get_address()
self._client, self._creds, self._address = await asyncio.to_thread(_build)
# funds/positions live on the funder (proxy/deposit wallet); fall back to EOA
self._funder = sec.browser_address or self._address
log.info("gateway_connected", signer=self._address[:10], funder=self._funder[:10],
paper=self._paper)
# ── placement ───────────────────────────────────────────────────────
async def place(self, quotes: list[Quote], meta: MarketMeta) -> list[OpenOrder]:
if not quotes:
return []
await self._order_bucket.acquire(len(quotes))
ts = time.time()
self._journal_write("orders_out", [asdict(q) for q in quotes], ts)
if self._paper:
return [self._paper_order(q) for q in quotes]
def _place() -> list[OpenOrder]:
from py_clob_client_v2.clob_types import (
OrderArgsV2,
OrderType,
PartialCreateOrderOptions,
PostOrdersV2Args,
)
opts = PartialCreateOrderOptions(tick_size=_tick_str(meta.tick_size), neg_risk=meta.neg_risk)
args = []
for q in quotes:
signed = self._client.create_order(
OrderArgsV2(token_id=q.token_id, price=q.price, size=q.size, side=q.side.value),
options=opts,
)
args.append(PostOrdersV2Args(order=signed, orderType=OrderType.GTC))
resp = self._client.post_orders(args, post_only=self._cfg.execution.post_only)
return self._parse_place_response(resp, quotes)
try:
return await asyncio.to_thread(_place)
except Exception as exc: # noqa: BLE001 - surface + continue; engine handles error rate
log.error("place_failed", err=str(exc), n=len(quotes))
return []
def _paper_order(self, q: Quote) -> OpenOrder:
oid = f"paper-{next(self._paper_ids)}"
return OpenOrder(oid, q.token_id, q.side, q.price, q.size, OrderState.LIVE)
def _parse_place_response(self, resp: Any, quotes: list[Quote]) -> list[OpenOrder]:
"""Map a batch post response to OpenOrders. Tolerant of shape variants;
the user-WS order events + REST snapshot reconcile anything we miss."""
items = resp if isinstance(resp, list) else resp.get("orders", resp.get("data", []))
out: list[OpenOrder] = []
for q, item in zip(quotes, items if isinstance(items, list) else [], strict=False):
oid = _first(item, "orderID", "orderId", "order_id", "id", "hash")
if not oid:
log.warning("place_response_missing_id", item=str(item)[:120])
continue
out.append(OpenOrder(str(oid), q.token_id, q.side, q.price, q.size, OrderState.LIVE))
return out
# ── cancellation ────────────────────────────────────────────────────
async def cancel(self, order_ids: list[str]) -> None:
if not order_ids or self._paper:
return
await self._cancel_bucket.acquire(1)
def _cancel() -> None:
self._client.cancel_orders(order_ids)
try:
await asyncio.to_thread(_cancel)
except Exception as exc: # noqa: BLE001
log.error("cancel_failed", err=str(exc), n=len(order_ids))
async def cancel_asset(self, asset_id: str) -> None:
if self._paper:
return
def _cancel() -> None:
from py_clob_client_v2.clob_types import OrderMarketCancelParams
self._client.cancel_market_orders(OrderMarketCancelParams(asset_id=asset_id))
await asyncio.to_thread(_cancel)
async def cancel_all(self) -> None:
if self._paper or self._client is None:
return
await asyncio.to_thread(self._client.cancel_all)
log.info("cancel_all_sent")
# ── heartbeat (dead-man switch) ─────────────────────────────────────
async def heartbeat(self, hb_id: str = "") -> None:
if self._paper or self._client is None:
return
try:
await asyncio.to_thread(self._client.post_heartbeat, hb_id)
except Exception as exc: # noqa: BLE001
log.warning("heartbeat_failed", err=str(exc))
# ── reads ───────────────────────────────────────────────────────────
async def open_orders(self) -> list[OpenOrder]:
if self._paper or self._client is None:
return []
def _get() -> list[OpenOrder]:
raw = self._client.get_open_orders()
rows = raw if isinstance(raw, list) else raw.get("data", raw.get("orders", []))
out = []
for r in rows:
try:
side = Side(str(r["side"]).upper())
remaining = float(r.get("original_size", r.get("size", 0))) - float(
r.get("size_matched", 0)
)
out.append(
OpenOrder(
str(_first(r, "id", "orderID", "order_id")),
str(r["asset_id"]),
side,
float(r["price"]),
remaining,
OrderState.LIVE,
)
)
except (KeyError, ValueError, TypeError):
continue
return out
try:
return await asyncio.to_thread(_get)
except Exception as exc: # noqa: BLE001
log.warning("open_orders_failed", err=str(exc))
return []
async def positions(self) -> dict[str, tuple[float, float]]:
"""{token_id: (size, avg_price)} from the data API (reconcile use).
Queries the FUNDER (where positions live), not the signer EOA.
"""
user = self.funder
if not user or not user.startswith("0x") or user == "0xPAPER":
return {}
try:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(f"{self._data_host}/positions", params={"user": user})
r.raise_for_status()
return {
str(p["asset"]): (float(p["size"]), float(p.get("avgPrice", 0)))
for p in r.json()
if float(p.get("size", 0)) > 0
}
except (httpx.HTTPError, KeyError, ValueError) as exc:
log.warning("positions_failed", err=str(exc))
return {}
async def balance_allowance(self) -> dict[str, Any]:
"""Collateral balance/allowance snapshot (for `doctor`)."""
if self._client is None:
return {}
def _get() -> dict[str, Any]:
from py_clob_client_v2.clob_types import AssetType, BalanceAllowanceParams
result: dict[str, Any] = self._client.get_balance_allowance(
BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)
)
return result
try:
return await asyncio.to_thread(_get)
except Exception as exc: # noqa: BLE001
log.warning("balance_allowance_failed", err=str(exc))
return {}
def _journal_write(self, kind: str, payload: Any, ts: float) -> None:
if self._journal is not None:
self._journal.write(kind, payload, ts)
def _first(d: Any, *keys: str) -> Any:
if not isinstance(d, dict):
return None
for k in keys:
if k in d and d[k]:
return d[k]
return None
+42
View File
@@ -0,0 +1,42 @@
"""Async token-bucket rate budgeter.
The CLOB API throttles (queues) excess requests rather than 429-ing, so the real
risk is silent latency injection when the market is moving. We self-limit to a
fraction of the documented ceilings and expose pressure as a signal so the
engine can shed low-edge reprices first.
"""
from __future__ import annotations
import asyncio
import time
class TokenBucket:
def __init__(self, rate_per_s: float, burst: float | None = None) -> None:
self.rate = max(rate_per_s, 0.001)
self.capacity = burst if burst is not None else max(1.0, rate_per_s)
self._tokens = self.capacity
self._last = time.monotonic()
self._lock = asyncio.Lock()
def _refill(self) -> None:
now = time.monotonic()
self._tokens = min(self.capacity, self._tokens + (now - self._last) * self.rate)
self._last = now
async def acquire(self, n: float = 1.0) -> None:
async with self._lock:
while True:
self._refill()
if self._tokens >= n:
self._tokens -= n
return
deficit = n - self._tokens
await asyncio.sleep(deficit / self.rate)
@property
def pressure(self) -> float:
"""0 = plenty of budget, 1 = empty (callers about to wait)."""
self._refill()
return 1.0 - min(1.0, self._tokens / self.capacity)
+64
View File
@@ -0,0 +1,64 @@
"""Pure reconciliation: desired TargetQuotes vs live orders -> minimal actions.
The strategy emits a target quote set; this computes the smallest cancel/place
set to reach it, applying churn tolerances so we don't burn queue position for
sub-tick or sub-threshold size changes (v1's should_cancel instinct, generalized).
No I/O — the gateway executes the returned plan.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from polymaker.domain import OpenOrder, Quote, TargetQuotes
_EPS = 1e-9
@dataclass(frozen=True, slots=True)
class ReconcilePlan:
to_cancel: list[str] = field(default_factory=list) # order ids
to_place: list[Quote] = field(default_factory=list)
@property
def is_noop(self) -> bool:
return not self.to_cancel and not self.to_place
def reconcile(
targets: TargetQuotes,
live: list[OpenOrder],
*,
tick: float,
reprice_ticks: int,
resize_frac: float,
) -> ReconcilePlan:
"""Diff targets against live orders. Keep live orders that already satisfy a
target within tolerance; cancel the rest; place targets with no match."""
live_by_key: dict[tuple[str, str], list[OpenOrder]] = defaultdict(list)
for o in live:
live_by_key[(o.token_id, o.side.value)].append(o)
keep: set[str] = set()
to_place: list[Quote] = []
price_tol = reprice_ticks * tick + _EPS
for q in targets.quotes:
candidates = live_by_key.get((q.token_id, q.side.value), [])
match: OpenOrder | None = None
for o in candidates:
if o.order_id in keep:
continue
price_close = abs(o.price - q.price) <= price_tol
size_close = q.size <= 0 or abs(o.size - q.size) <= resize_frac * q.size + _EPS
if price_close and size_close:
match = o
break
if match is not None:
keep.add(match.order_id)
else:
to_place.append(q)
to_cancel = [o.order_id for o in live if o.order_id not in keep]
return ReconcilePlan(to_cancel=to_cancel, to_place=to_place)