Getting there
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""polymaker — maker-only market-making bot for Polymarket CLOB V2."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Async Gamma API client for market discovery.
|
||||
|
||||
Gamma (https://gamma-api.polymarket.com, no auth) returns everything the v1
|
||||
scanner burned two extra REST calls per market to compute: best bid/ask,
|
||||
liquidity, volume, reward params, fee schedule, tick size, tokens. We filter
|
||||
server-side by the politics tag and liquidity/volume, so a full political-market
|
||||
sweep is a handful of paginated requests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from polymaker.domain import MarketMeta, TokenMeta
|
||||
from polymaker.logging import get_logger
|
||||
|
||||
log = get_logger("catalog.gamma")
|
||||
|
||||
POLITICS_TAG_SLUG = "politics"
|
||||
|
||||
|
||||
class GammaClient:
|
||||
"""Thin async wrapper over the Gamma REST endpoints we use."""
|
||||
|
||||
def __init__(self, host: str = "https://gamma-api.polymarket.com", timeout: float = 20.0) -> None:
|
||||
self._host = host.rstrip("/")
|
||||
self._client = httpx.AsyncClient(base_url=self._host, timeout=timeout)
|
||||
|
||||
async def __aenter__(self) -> GammaClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def resolve_tag_id(self, slug: str) -> str | None:
|
||||
try:
|
||||
r = await self._client.get(f"/tags/slug/{slug}")
|
||||
r.raise_for_status()
|
||||
return str(r.json()["id"])
|
||||
except (httpx.HTTPError, KeyError, json.JSONDecodeError):
|
||||
log.warning("tag_resolve_failed", slug=slug)
|
||||
return None
|
||||
|
||||
async def iter_markets(
|
||||
self,
|
||||
*,
|
||||
tag_id: str | None = None,
|
||||
related_tags: bool = True,
|
||||
min_liquidity: float = 0.0,
|
||||
min_volume_24hr: float = 0.0,
|
||||
limit: int = 100, # Gamma caps a page at 100 regardless of a higher ask
|
||||
max_pages: int = 25,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Yield raw active/open market dicts, offset-paginated.
|
||||
|
||||
Uses the offset `/markets` endpoint because it reliably supports
|
||||
`tag_id` filtering today. (Keyset is the go-forward per docs; switch when
|
||||
it supports tag filtering. See docs/scoping/03-api-layer.md §4.)
|
||||
"""
|
||||
offset = 0
|
||||
for _ in range(max_pages):
|
||||
params: dict[str, Any] = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"active": "true",
|
||||
"closed": "false",
|
||||
"order": "volume24hr",
|
||||
"ascending": "false",
|
||||
}
|
||||
if tag_id:
|
||||
params["tag_id"] = tag_id
|
||||
params["related_tags"] = "true" if related_tags else "false"
|
||||
if min_liquidity > 0:
|
||||
params["liquidity_num_min"] = min_liquidity
|
||||
if min_volume_24hr > 0:
|
||||
params["volume_num_min"] = min_volume_24hr
|
||||
|
||||
r = await self._client.get("/markets", params=params)
|
||||
r.raise_for_status()
|
||||
batch = r.json()
|
||||
if not batch:
|
||||
return
|
||||
for m in batch:
|
||||
yield m
|
||||
if len(batch) < limit:
|
||||
return
|
||||
offset += limit
|
||||
|
||||
|
||||
def parse_market(raw: dict[str, Any], reward_rates: dict[str, float] | None = None) -> MarketMeta | None:
|
||||
"""Convert a Gamma market dict into our MarketMeta, or None if unusable."""
|
||||
try:
|
||||
if not raw.get("acceptingOrders", False):
|
||||
return None
|
||||
token_ids = _json_list(raw.get("clobTokenIds"))
|
||||
outcomes = _json_list(raw.get("outcomes"))
|
||||
if len(token_ids) != 2 or len(outcomes) != 2:
|
||||
return None # only binary markets
|
||||
|
||||
condition_id = raw["conditionId"]
|
||||
rate_map = reward_rates or {}
|
||||
fee = raw.get("feeSchedule") or {}
|
||||
taker_rate = float(fee.get("rate", 0.0) or 0.0)
|
||||
|
||||
event_id = None
|
||||
events = raw.get("events") or []
|
||||
if events:
|
||||
event_id = str(events[0].get("id")) if events[0].get("id") is not None else None
|
||||
|
||||
return MarketMeta(
|
||||
condition_id=condition_id,
|
||||
question=raw.get("question", ""),
|
||||
slug=raw.get("slug", ""),
|
||||
tokens=(
|
||||
TokenMeta(str(token_ids[0]), str(outcomes[0])),
|
||||
TokenMeta(str(token_ids[1]), str(outcomes[1])),
|
||||
),
|
||||
tick_size=float(raw.get("orderPriceMinTickSize", 0.001) or 0.001),
|
||||
neg_risk=bool(raw.get("negRisk", False)),
|
||||
min_order_size=float(raw.get("orderMinSize", 5) or 5),
|
||||
rewards_min_size=float(raw.get("rewardsMinSize", 0) or 0),
|
||||
rewards_max_spread=float(raw.get("rewardsMaxSpread", 0) or 0),
|
||||
rewards_daily_rate=float(rate_map.get(condition_id, 0.0)),
|
||||
maker_fee_bps=0, # V2: makers pay zero
|
||||
taker_fee_bps=int(round(taker_rate * 10000)),
|
||||
fees_enabled=bool(raw.get("feesEnabled", False)),
|
||||
rebate_rate=float(fee.get("rebateRate", 0.0) or 0.0),
|
||||
end_date_iso=raw.get("endDate"),
|
||||
event_id=event_id,
|
||||
best_bid=float(raw.get("bestBid", 0) or 0),
|
||||
best_ask=float(raw.get("bestAsk", 0) or 0),
|
||||
liquidity_num=float(raw.get("liquidityNum", 0) or 0),
|
||||
volume_num=float(raw.get("volumeNum", 0) or 0),
|
||||
)
|
||||
except (KeyError, ValueError, TypeError) as exc:
|
||||
log.warning("parse_market_failed", err=str(exc), slug=raw.get("slug"))
|
||||
return None
|
||||
|
||||
|
||||
def _json_list(value: Any) -> list[Any]:
|
||||
"""clobTokenIds / outcomes arrive as JSON-encoded strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_reward_rates(
|
||||
clob_host: str = "https://clob.polymarket.com", timeout: float = 20.0
|
||||
) -> dict[str, float]:
|
||||
"""Build {condition_id: daily USDC reward rate} from CLOB sampling-markets.
|
||||
|
||||
These are the rewards-enabled markets; the daily rate isn't on Gamma.
|
||||
"""
|
||||
usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
|
||||
rates: dict[str, float] = {}
|
||||
async with httpx.AsyncClient(base_url=clob_host.rstrip("/"), timeout=timeout) as client:
|
||||
cursor = ""
|
||||
for _ in range(50):
|
||||
r = await client.get("/sampling-markets", params={"next_cursor": cursor})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
for m in data.get("data", []):
|
||||
cid = m.get("condition_id")
|
||||
rate = 0.0
|
||||
for ri in (m.get("rewards") or {}).get("rates") or []:
|
||||
if str(ri.get("asset_address", "")).lower() == usdc:
|
||||
rate = float(ri.get("rewards_daily_rate", 0) or 0)
|
||||
break
|
||||
if cid:
|
||||
rates[cid] = rate
|
||||
cursor = data.get("next_cursor") or ""
|
||||
if not cursor or cursor == "LTE=": # "LTE=" is the documented end sentinel
|
||||
break
|
||||
return rates
|
||||
@@ -0,0 +1,63 @@
|
||||
"""The scanner: sweep Gamma for political markets, score, persist to SQLite.
|
||||
|
||||
Replaces the v1 data_updater (hour-long crawl of every order book, written to
|
||||
Google Sheets). A politics-filtered sweep here is seconds and one process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from polymaker.catalog.gamma import (
|
||||
POLITICS_TAG_SLUG,
|
||||
GammaClient,
|
||||
fetch_reward_rates,
|
||||
parse_market,
|
||||
)
|
||||
from polymaker.catalog.scoring import score_market
|
||||
from polymaker.catalog.store import CatalogStore
|
||||
from polymaker.domain import MarketMeta
|
||||
from polymaker.logging import get_logger
|
||||
|
||||
log = get_logger("catalog.scanner")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScanConfig:
|
||||
tag_slug: str = POLITICS_TAG_SLUG
|
||||
min_liquidity: float = 1000.0
|
||||
min_volume_24hr: float = 0.0
|
||||
rewards_only: bool = True # keep only markets in the liquidity-rewards program
|
||||
gamma_host: str = "https://gamma-api.polymarket.com"
|
||||
clob_host: str = "https://clob.polymarket.com"
|
||||
|
||||
|
||||
async def run_scan(store: CatalogStore, cfg: ScanConfig) -> list[MarketMeta]:
|
||||
"""Fetch, parse, filter, score, and persist. Returns the kept markets."""
|
||||
reward_rates = await fetch_reward_rates(cfg.clob_host)
|
||||
log.info("reward_rates_loaded", n=len(reward_rates))
|
||||
|
||||
kept: list[MarketMeta] = []
|
||||
async with GammaClient(cfg.gamma_host) as gamma:
|
||||
tag_id = store.cached_tag(cfg.tag_slug) or await gamma.resolve_tag_id(cfg.tag_slug)
|
||||
if tag_id:
|
||||
store.cache_tag(cfg.tag_slug, tag_id)
|
||||
|
||||
seen = 0
|
||||
async for raw in gamma.iter_markets(
|
||||
tag_id=tag_id,
|
||||
min_liquidity=cfg.min_liquidity,
|
||||
min_volume_24hr=cfg.min_volume_24hr,
|
||||
):
|
||||
seen += 1
|
||||
meta = parse_market(raw, reward_rates)
|
||||
if meta is None:
|
||||
continue
|
||||
if cfg.rewards_only and meta.rewards_daily_rate <= 0:
|
||||
continue
|
||||
kept.append(meta)
|
||||
|
||||
for m in kept:
|
||||
store.upsert_market(m, score_market(m))
|
||||
log.info("scan_complete", seen=seen, kept=len(kept), tag=cfg.tag_slug)
|
||||
return kept
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Market attractiveness scoring for the scanner.
|
||||
|
||||
Combines the v1 reward-density intuition with the new maker-rebate income
|
||||
stream and penalizes spread/extremes. Higher score = more attractive to make.
|
||||
Pure functions over MarketMeta.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from polymaker.domain import MarketMeta
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarketScore:
|
||||
condition_id: str
|
||||
reward_density: float # est. reward $/day per $100 of two-sided liquidity
|
||||
rebate_potential: float # est. daily rebate $ available to makers
|
||||
spread: float
|
||||
extremity: float # 0 = mid ~0.5 (good), 1 = near 0/1 (bad payoff asymmetry)
|
||||
score: float
|
||||
|
||||
|
||||
def _mid(m: MarketMeta) -> float:
|
||||
if m.best_bid > 0 and m.best_ask > 0:
|
||||
return (m.best_bid + m.best_ask) / 2.0
|
||||
return 0.5
|
||||
|
||||
|
||||
def reward_density(m: MarketMeta, quote_size_usdc: float = 100.0) -> float:
|
||||
"""Rough reward $/day if we hold ~quote_size two-sided in-band.
|
||||
|
||||
The exact per-order S((v-s)/v)^2 scoring depends on live competition; for
|
||||
ranking we use daily_rate scaled by how much of the (small) market our
|
||||
typical size represents, capped. This mirrors v1's gm_reward_per_100 as a
|
||||
relative ranking signal, not an absolute forecast.
|
||||
"""
|
||||
if m.rewards_daily_rate <= 0 or m.rewards_max_spread <= 0:
|
||||
return 0.0
|
||||
liq = max(m.liquidity_num, quote_size_usdc)
|
||||
our_share = min(1.0, quote_size_usdc / liq)
|
||||
return m.rewards_daily_rate * our_share
|
||||
|
||||
|
||||
def rebate_potential(m: MarketMeta) -> float:
|
||||
"""Est. daily maker-rebate pool: taker_fee_rate * rebate_rate * daily volume."""
|
||||
if not m.fees_enabled or m.rebate_rate <= 0 or m.taker_fee_bps <= 0:
|
||||
return 0.0
|
||||
daily_vol = m.volume_num # best proxy available from catalog; refined live
|
||||
taker_rate = m.taker_fee_bps / 10000.0
|
||||
# taker fee peaks at p*(1-p); use mid as the representative point
|
||||
mid = _mid(m)
|
||||
fee_factor = mid * (1.0 - mid)
|
||||
return daily_vol * taker_rate * fee_factor * m.rebate_rate * 0.01 # 1% daily-vol proxy
|
||||
|
||||
|
||||
def extremity(m: MarketMeta) -> float:
|
||||
"""0 near 0.5 (balanced), ->1 near the 0/1 boundary (skip these)."""
|
||||
mid = _mid(m)
|
||||
return min(1.0, abs(mid - 0.5) / 0.5)
|
||||
|
||||
|
||||
def score_market(m: MarketMeta) -> MarketScore:
|
||||
rd = reward_density(m)
|
||||
rp = rebate_potential(m)
|
||||
ext = extremity(m)
|
||||
spread = max(0.0, m.best_ask - m.best_bid) if (m.best_bid and m.best_ask) else 1.0
|
||||
|
||||
# income terms are additive; extremity and wide spreads discount the score
|
||||
income = rd + rp
|
||||
penalty = (1.0 - 0.5 * ext) * (1.0 / (1.0 + spread * 20.0))
|
||||
return MarketScore(
|
||||
condition_id=m.condition_id,
|
||||
reward_density=round(rd, 3),
|
||||
rebate_potential=round(rp, 3),
|
||||
spread=round(spread, 4),
|
||||
extremity=round(ext, 3),
|
||||
score=round(income * penalty, 4),
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""SQLite persistence for the market catalog and scan results.
|
||||
|
||||
Replaces the v1 "All Markets" / "Volatility Markets" Google Sheets. One local
|
||||
file (state.db), queryable by the CLI. WAL mode so the running bot and a
|
||||
`polymaker markets` query don't block each other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from polymaker.catalog.scoring import MarketScore, score_market
|
||||
from polymaker.domain import MarketMeta, TokenMeta
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS markets (
|
||||
condition_id TEXT PRIMARY KEY,
|
||||
question TEXT,
|
||||
slug TEXT,
|
||||
meta_json TEXT NOT NULL,
|
||||
score REAL DEFAULT 0,
|
||||
score_json TEXT,
|
||||
scanned_ts REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_markets_score ON markets(score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_markets_slug ON markets(slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
slug TEXT PRIMARY KEY,
|
||||
tag_id TEXT NOT NULL,
|
||||
ts REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class CatalogStore:
|
||||
"""Owns the markets/tags tables in state.db."""
|
||||
|
||||
def __init__(self, db_path: str | Path = "state.db") -> None:
|
||||
self.path = str(db_path)
|
||||
self._conn = sqlite3.connect(self.path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def upsert_market(self, meta: MarketMeta, score: MarketScore | None = None) -> None:
|
||||
sc = score or score_market(meta)
|
||||
self._conn.execute(
|
||||
"""INSERT INTO markets(condition_id, question, slug, meta_json, score, score_json, scanned_ts)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
ON CONFLICT(condition_id) DO UPDATE SET
|
||||
question=excluded.question, slug=excluded.slug, meta_json=excluded.meta_json,
|
||||
score=excluded.score, score_json=excluded.score_json, scanned_ts=excluded.scanned_ts""",
|
||||
(
|
||||
meta.condition_id,
|
||||
meta.question,
|
||||
meta.slug,
|
||||
_dump_meta(meta),
|
||||
sc.score,
|
||||
json.dumps(asdict(sc)),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def upsert_many(self, metas: list[MarketMeta]) -> int:
|
||||
for m in metas:
|
||||
self.upsert_market(m)
|
||||
return len(metas)
|
||||
|
||||
def get(self, condition_id: str) -> MarketMeta | None:
|
||||
row = self._conn.execute(
|
||||
"SELECT meta_json FROM markets WHERE condition_id=?", (condition_id,)
|
||||
).fetchone()
|
||||
return _load_meta(row["meta_json"]) if row else None
|
||||
|
||||
def get_by_slug(self, slug: str) -> MarketMeta | None:
|
||||
row = self._conn.execute(
|
||||
"SELECT meta_json FROM markets WHERE slug=?", (slug,)
|
||||
).fetchone()
|
||||
return _load_meta(row["meta_json"]) if row else None
|
||||
|
||||
def top(self, limit: int = 50) -> list[tuple[MarketMeta, MarketScore]]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT meta_json, score_json FROM markets ORDER BY score DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
meta = _load_meta(row["meta_json"])
|
||||
sc = MarketScore(**json.loads(row["score_json"])) if row["score_json"] else score_market(meta)
|
||||
out.append((meta, sc))
|
||||
return out
|
||||
|
||||
def cache_tag(self, slug: str, tag_id: str) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO tags(slug, tag_id, ts) VALUES(?,?,?)",
|
||||
(slug, tag_id, time.time()),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def cached_tag(self, slug: str) -> str | None:
|
||||
row = self._conn.execute("SELECT tag_id FROM tags WHERE slug=?", (slug,)).fetchone()
|
||||
return row["tag_id"] if row else None
|
||||
|
||||
|
||||
def _dump_meta(meta: MarketMeta) -> str:
|
||||
d = asdict(meta)
|
||||
d["tokens"] = [asdict(t) for t in meta.tokens]
|
||||
return json.dumps(d)
|
||||
|
||||
|
||||
def _load_meta(blob: str) -> MarketMeta:
|
||||
d = json.loads(blob)
|
||||
d["tokens"] = tuple(TokenMeta(**t) for t in d["tokens"])
|
||||
return MarketMeta(**d)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""polymaker command-line interface.
|
||||
|
||||
polymaker scan sweep Gamma for political markets -> SQLite
|
||||
polymaker markets rank/browse the catalog
|
||||
polymaker markets-add <slug> append a market to config/markets.toml
|
||||
polymaker status positions / open orders / PnL (reads SQLite)
|
||||
polymaker doctor preflight: wallet auth, balances, WS reachability
|
||||
polymaker run [--paper] start the market maker
|
||||
polymaker cancel-all panic button
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from polymaker import __version__
|
||||
from polymaker.config import Config
|
||||
|
||||
app = typer.Typer(
|
||||
name="polymaker",
|
||||
help="Maker-only market maker for Polymarket CLOB V2.",
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
)
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command()
|
||||
def version() -> None:
|
||||
"""Print the polymaker version."""
|
||||
console.print(f"polymaker {__version__}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def scan(
|
||||
config_dir: str = typer.Option("config", help="config directory"),
|
||||
min_liquidity: float = typer.Option(1000.0, help="minimum market liquidity (USDC)"),
|
||||
all_markets: bool = typer.Option(False, "--all", help="include non-rewards markets"),
|
||||
) -> None:
|
||||
"""Sweep Gamma for political markets, score, and persist to SQLite."""
|
||||
from polymaker.catalog.scanner import ScanConfig, run_scan
|
||||
from polymaker.catalog.store import CatalogStore
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
store = CatalogStore(cfg.paths.db)
|
||||
|
||||
async def _go() -> int:
|
||||
metas = await run_scan(store, ScanConfig(min_liquidity=min_liquidity, rewards_only=not all_markets))
|
||||
return len(metas)
|
||||
|
||||
n = asyncio.run(_go())
|
||||
console.print(f"[green]Scanned and stored {n} markets.[/green] Run [bold]polymaker markets[/bold] to browse.")
|
||||
store.close()
|
||||
|
||||
|
||||
@app.command()
|
||||
def markets(
|
||||
config_dir: str = typer.Option("config", help="config directory"),
|
||||
limit: int = typer.Option(25, help="rows to show"),
|
||||
) -> None:
|
||||
"""Show the top scored markets from the catalog."""
|
||||
from polymaker.catalog.store import CatalogStore
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
store = CatalogStore(cfg.paths.db)
|
||||
rows = store.top(limit)
|
||||
if not rows:
|
||||
console.print("[yellow]Catalog empty. Run `polymaker scan` first.[/yellow]")
|
||||
raise typer.Exit()
|
||||
|
||||
table = Table(title="Political markets by score")
|
||||
for col in ("score", "reward/day", "rebate/day", "spread", "tick", "neg", "question"):
|
||||
table.add_column(col, justify="right" if col != "question" else "left")
|
||||
for meta, sc in rows:
|
||||
table.add_row(
|
||||
f"{sc.score:.2f}", f"{meta.rewards_daily_rate:.0f}", f"{sc.rebate_potential:.0f}",
|
||||
f"{sc.spread:.3f}", f"{meta.tick_size:g}", "Y" if meta.neg_risk else "-",
|
||||
meta.question[:60],
|
||||
)
|
||||
console.print(table)
|
||||
console.print("\nAdd one with: [bold]polymaker markets-add <slug>[/bold] (slugs are in the catalog)")
|
||||
|
||||
|
||||
@app.command(name="markets-add")
|
||||
def markets_add(
|
||||
slug: str,
|
||||
profile: str = typer.Option("political-longdated", help="strategy profile"),
|
||||
config_dir: str = typer.Option("config", help="config directory"),
|
||||
) -> None:
|
||||
"""Append a market (by slug) to config/markets.toml."""
|
||||
from polymaker.catalog.store import CatalogStore
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
store = CatalogStore(cfg.paths.db)
|
||||
meta = store.get_by_slug(slug)
|
||||
store.close()
|
||||
if meta is None:
|
||||
console.print(f"[red]No market with slug {slug!r} in the catalog. Run `polymaker scan`.[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
path = Path(config_dir) / "markets.toml"
|
||||
block = f'\n[[markets]]\nslug = "{slug}"\nprofile = "{profile}"\nenabled = true\n'
|
||||
with path.open("a") as fh:
|
||||
fh.write(block)
|
||||
console.print(f"[green]Added[/green] {meta.question[:60]!r} to {path}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(config_dir: str = typer.Option("config", help="config directory")) -> None:
|
||||
"""Show positions, open orders, and marks from the local state DB."""
|
||||
from polymaker.state.store import StateStore
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
store = StateStore(cfg.paths.db)
|
||||
snap = store.snapshot()
|
||||
console.print(f"[bold]Open orders:[/bold] {snap['open_orders']}")
|
||||
positions: dict[str, Any] = snap["positions"] # type: ignore[assignment]
|
||||
if not positions:
|
||||
console.print("[dim]No open positions.[/dim]")
|
||||
else:
|
||||
table = Table(title="Positions")
|
||||
table.add_column("token")
|
||||
table.add_column("size", justify="right")
|
||||
table.add_column("avg", justify="right")
|
||||
for tok, p in positions.items():
|
||||
table.add_row(tok[:16] + "…", f"{p['size']:.2f}", f"{p['avg_price']:.3f}")
|
||||
console.print(table)
|
||||
store.close()
|
||||
|
||||
|
||||
@app.command()
|
||||
def doctor(config_dir: str = typer.Option("config", help="config directory")) -> None:
|
||||
"""Preflight checks: config, wallet auth, balance/allowance, WS reachability."""
|
||||
from polymaker.doctor import run_doctor
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
ok = asyncio.run(run_doctor(cfg, console))
|
||||
raise typer.Exit(0 if ok else 1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def run(
|
||||
config_dir: str = typer.Option("config", help="config directory"),
|
||||
paper: bool = typer.Option(False, "--paper", help="paper mode: full pipeline, no orders posted"),
|
||||
) -> None:
|
||||
"""Start the market maker."""
|
||||
from polymaker.engine import Engine
|
||||
from polymaker.logging import configure
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
configure(json_file=Path(cfg.paths.log_dir) / ("paper.jsonl" if paper else "live.jsonl"))
|
||||
if cfg.engine.loop == "uvloop":
|
||||
try:
|
||||
import uvloop
|
||||
|
||||
uvloop.install()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
engine = Engine(cfg, paper=paper)
|
||||
|
||||
async def _go() -> None:
|
||||
try:
|
||||
await engine.run_forever()
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
await engine.shutdown()
|
||||
|
||||
console.print(f"[bold green]Starting polymaker[/bold green] ({'PAPER' if paper else 'LIVE'})…")
|
||||
try:
|
||||
asyncio.run(_go())
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Stopped.[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def livetest(
|
||||
config_dir: str = typer.Option("config", help="config directory"),
|
||||
notional: float = typer.Option(5.0, help="order notional in USDC"),
|
||||
) -> None:
|
||||
"""Live wallet round-trip: place a deep post-only order and cancel it (~$5)."""
|
||||
from polymaker.livetest import run_livetest
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
ok = asyncio.run(run_livetest(cfg, console, notional))
|
||||
raise typer.Exit(0 if ok else 1)
|
||||
|
||||
|
||||
@app.command(name="cancel-all")
|
||||
def cancel_all(config_dir: str = typer.Option("config", help="config directory")) -> None:
|
||||
"""Cancel all open orders for the wallet (panic button)."""
|
||||
from polymaker.execution.gateway import ExecutionGateway
|
||||
|
||||
cfg = Config.load(config_dir)
|
||||
gw = ExecutionGateway(cfg)
|
||||
|
||||
async def _go() -> None:
|
||||
await gw.connect()
|
||||
await gw.cancel_all()
|
||||
|
||||
asyncio.run(_go())
|
||||
console.print("[green]Sent cancel-all.[/green]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Configuration: pydantic models over local TOML files + .env secrets.
|
||||
|
||||
Replaces the v1 Google Sheets config entirely. Three files under config/:
|
||||
config.toml engine/wallet/risk/execution settings
|
||||
strategy.toml named parameter profiles
|
||||
markets.toml the trade list (market -> profile + overrides)
|
||||
|
||||
Secrets (private key, wallet address) come only from the environment / .env.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WalletConfig(BaseModel):
|
||||
chain_id: int = 137
|
||||
signature_type: int = 2
|
||||
clob_host: str = "https://clob.polymarket.com"
|
||||
gamma_host: str = "https://gamma-api.polymarket.com"
|
||||
data_api_host: str = "https://data-api.polymarket.com"
|
||||
polygon_rpc: str = "https://polygon-rpc.com"
|
||||
|
||||
|
||||
class EngineConfig(BaseModel):
|
||||
debounce_ms: int = 200
|
||||
reconcile_interval_s: float = 30.0
|
||||
catalog_refresh_s: float = 900.0
|
||||
heartbeat: bool = True
|
||||
heartbeat_interval_s: float = 5.0
|
||||
journal: bool = True
|
||||
loop: str = "uvloop"
|
||||
|
||||
|
||||
class RiskConfig(BaseModel):
|
||||
max_total_exposure_usdc: float = 5000.0
|
||||
max_event_group_loss_usdc: float = 1000.0
|
||||
max_market_notional_usdc: float = 800.0
|
||||
daily_loss_kill_usdc: float = 250.0
|
||||
ws_stale_halt_s: float = 10.0
|
||||
max_order_error_rate: float = 0.25
|
||||
|
||||
|
||||
class ExecutionConfig(BaseModel):
|
||||
rate_budget_fraction: float = 0.25
|
||||
post_only: bool = True
|
||||
max_orders_per_batch: int = 15
|
||||
|
||||
|
||||
class PathsConfig(BaseModel):
|
||||
db: str = "state.db"
|
||||
journal_dir: str = "journal"
|
||||
log_dir: str = "logs"
|
||||
|
||||
|
||||
class StrategyProfile(BaseModel):
|
||||
"""One named parameter set. Every knob the quoter uses lives here."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
# fair value
|
||||
micro_levels: int = 3
|
||||
flow_ewma_halflife_s: float = 120.0
|
||||
# spread / skew
|
||||
gamma: float = 0.5
|
||||
delta_min_ticks: int = 2
|
||||
c_vol: float = 1.2
|
||||
c_tox: float = 2.0
|
||||
# vol horizons
|
||||
vol_short_halflife_s: float = 10.0
|
||||
vol_long_halflife_s: float = 900.0
|
||||
# sizing / inventory
|
||||
base_size_usdc: float = 50.0
|
||||
q_max_usdc: float = 500.0
|
||||
q_soft_frac: float = 0.6
|
||||
layers: int = 2
|
||||
layer_step_ticks: int = 2
|
||||
# placement / churn
|
||||
reprice_ticks: int = 2
|
||||
resize_frac: float = 0.15
|
||||
min_edge_ticks: int = 1
|
||||
# regime
|
||||
event_cooloff_s: float = 60.0
|
||||
event_jump_ticks: int = 8
|
||||
event_sweep_levels: int = 3
|
||||
trend_flow_z: float = 1.5
|
||||
# lifecycle
|
||||
end_date_taper_days: float = 7.0
|
||||
reduce_only_hours: float = 24.0
|
||||
halt_before_hours: float = 2.0
|
||||
# exits
|
||||
exit_urgency_s: float = 900.0
|
||||
merge_min_size: float = 20.0
|
||||
|
||||
def with_overrides(self, overrides: dict[str, Any]) -> StrategyProfile:
|
||||
"""Return a copy with per-market override values applied."""
|
||||
if not overrides:
|
||||
return self
|
||||
data = self.model_dump()
|
||||
for k, v in overrides.items():
|
||||
if k in data:
|
||||
data[k] = v
|
||||
return StrategyProfile(**data)
|
||||
|
||||
|
||||
# Keys allowed on a market entry that are NOT profile overrides.
|
||||
_MARKET_RESERVED = {"slug", "condition_id", "profile", "enabled"}
|
||||
|
||||
|
||||
class MarketEntry(BaseModel):
|
||||
"""One line of the trade list. Extra keys are treated as profile overrides."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
slug: str | None = None
|
||||
condition_id: str | None = None
|
||||
profile: str = "political-longdated"
|
||||
enabled: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _need_identifier(self) -> MarketEntry:
|
||||
if not self.slug and not self.condition_id:
|
||||
raise ValueError("market entry needs a slug or condition_id")
|
||||
return self
|
||||
|
||||
@property
|
||||
def overrides(self) -> dict[str, Any]:
|
||||
extra = self.model_extra or {}
|
||||
return {k: v for k, v in extra.items() if k not in _MARKET_RESERVED}
|
||||
|
||||
@property
|
||||
def ref(self) -> str:
|
||||
return self.slug or self.condition_id or "?"
|
||||
|
||||
|
||||
class Secrets(BaseSettings):
|
||||
"""Loaded from environment / .env. Never written to disk by us."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
pk: str = Field(default="", alias="PK")
|
||||
browser_address: str = Field(default="", alias="BROWSER_ADDRESS")
|
||||
polygon_rpc: str | None = Field(default=None, alias="POLYGON_RPC")
|
||||
alert_webhook_url: str | None = Field(default=None, alias="ALERT_WEBHOOK_URL")
|
||||
|
||||
@property
|
||||
def has_wallet(self) -> bool:
|
||||
return bool(self.pk and self.browser_address)
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Fully-resolved configuration tree."""
|
||||
|
||||
wallet: WalletConfig = WalletConfig()
|
||||
engine: EngineConfig = EngineConfig()
|
||||
risk: RiskConfig = RiskConfig()
|
||||
execution: ExecutionConfig = ExecutionConfig()
|
||||
paths: PathsConfig = PathsConfig()
|
||||
profiles: dict[str, StrategyProfile] = {}
|
||||
markets: list[MarketEntry] = []
|
||||
secrets: Secrets = Field(default_factory=Secrets)
|
||||
config_dir: Path = Path("config")
|
||||
|
||||
@property
|
||||
def proxy(self) -> str | None:
|
||||
# Standard proxy env var; ALL_PROXY lets you route through an SSH tunnel
|
||||
# (e.g. simulate colocation during local testing). httpx and web3 honor
|
||||
# it automatically once load_dotenv() has run.
|
||||
return os.environ.get("ALL_PROXY") or os.environ.get("HTTPS_PROXY")
|
||||
|
||||
@property
|
||||
def enabled_markets(self) -> list[MarketEntry]:
|
||||
return [m for m in self.markets if m.enabled]
|
||||
|
||||
def profile_for(self, entry: MarketEntry) -> StrategyProfile:
|
||||
base = self.profiles.get(entry.profile)
|
||||
if base is None:
|
||||
raise KeyError(f"unknown strategy profile: {entry.profile!r}")
|
||||
return base.with_overrides(entry.overrides)
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_dir: str | Path = "config", *, load_env: bool = True) -> Config:
|
||||
cdir = Path(config_dir)
|
||||
if load_env:
|
||||
load_dotenv()
|
||||
main = _read_toml(cdir / "config.toml")
|
||||
strat = _read_toml(cdir / "strategy.toml")
|
||||
mkts = _read_toml(cdir / "markets.toml")
|
||||
|
||||
profiles = {
|
||||
name: StrategyProfile(**params)
|
||||
for name, params in (strat.get("profiles") or {}).items()
|
||||
}
|
||||
markets = [MarketEntry(**m) for m in (mkts.get("markets") or [])]
|
||||
|
||||
return cls(
|
||||
wallet=WalletConfig(**main.get("wallet", {})),
|
||||
engine=EngineConfig(**main.get("engine", {})),
|
||||
risk=RiskConfig(**main.get("risk", {})),
|
||||
execution=ExecutionConfig(**main.get("execution", {})),
|
||||
paths=PathsConfig(**main.get("paths", {})),
|
||||
profiles=profiles,
|
||||
markets=markets,
|
||||
secrets=Secrets(),
|
||||
config_dir=cdir,
|
||||
)
|
||||
|
||||
def reload_markets(self) -> Config:
|
||||
"""Re-read markets.toml only (used by the hot-reload path)."""
|
||||
mkts = _read_toml(self.config_dir / "markets.toml")
|
||||
self.markets = [MarketEntry(**m) for m in (mkts.get("markets") or [])]
|
||||
return self
|
||||
|
||||
|
||||
def _read_toml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Preflight checks for `polymaker doctor`.
|
||||
|
||||
Verifies the environment is ready to trade WITHOUT posting any order:
|
||||
config + secrets, CLOB/Gamma reachable, wallet auth (L1->L2 creds), collateral
|
||||
balance + positions ON THE FUNDER (deposit/developer wallet, where funds live),
|
||||
a live market-WS book frame, and an authenticated user-WS connection. This is
|
||||
the gate before the live $5 round-trip (docs/scoping/06 Phase 2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from rich.console import Console
|
||||
|
||||
from polymaker.config import Config
|
||||
|
||||
MARKET_WS = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
|
||||
USER_WS = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
|
||||
|
||||
|
||||
async def run_doctor(cfg: Config, console: Console) -> bool:
|
||||
ok = True
|
||||
|
||||
def check(label: str, passed: bool, detail: str = "") -> None:
|
||||
nonlocal ok
|
||||
mark = "[green]✓[/green]" if passed else "[red]✗[/red]"
|
||||
console.print(f" {mark} {label}" + (f" [dim]{detail}[/dim]" if detail else ""))
|
||||
ok = ok and passed
|
||||
|
||||
console.print("[bold]polymaker doctor[/bold]")
|
||||
|
||||
# ── config + secrets ────────────────────────────────────────────────
|
||||
check("config loads", True, f"{len(cfg.profiles)} profiles, {len(cfg.markets)} markets")
|
||||
check("PK + BROWSER_ADDRESS set", cfg.secrets.has_wallet,
|
||||
"put them in .env" if not cfg.secrets.has_wallet
|
||||
else f"funder {cfg.secrets.browser_address[:10]}…, sig_type {cfg.wallet.signature_type}")
|
||||
|
||||
# ── REST reachability ───────────────────────────────────────────────
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
try:
|
||||
r = await c.get(f"{cfg.wallet.clob_host}/ok")
|
||||
check("CLOB reachable", r.status_code == 200, cfg.wallet.clob_host)
|
||||
except httpx.HTTPError as e:
|
||||
check("CLOB reachable", False, str(e))
|
||||
try:
|
||||
r = await c.get(f"{cfg.wallet.gamma_host}/markets", params={"limit": 1})
|
||||
check("Gamma reachable", r.status_code == 200, cfg.wallet.gamma_host)
|
||||
except httpx.HTTPError as e:
|
||||
check("Gamma reachable", False, str(e))
|
||||
|
||||
# ── wallet auth + balance + positions (on the FUNDER) ───────────────
|
||||
creds: Any = None
|
||||
funder = ""
|
||||
held_tokens: list[str] = []
|
||||
if cfg.secrets.has_wallet:
|
||||
try:
|
||||
from polymaker.execution.gateway import ExecutionGateway
|
||||
|
||||
gw = ExecutionGateway(cfg)
|
||||
await gw.connect()
|
||||
creds = gw.creds
|
||||
funder = gw.funder
|
||||
check("wallet auth (L2 creds derived)", bool(gw.creds),
|
||||
f"signer {gw.address[:10]}… signs for funder {funder[:10]}…")
|
||||
|
||||
ba = await gw.balance_allowance()
|
||||
bal = _extract_balance(ba)
|
||||
check("collateral (pUSD) balance readable", bal is not None,
|
||||
f"≈{bal:.2f} pUSD on funder {funder[:10]}…" if bal is not None else "check allowances")
|
||||
if bal is not None and bal <= 0:
|
||||
console.print(" [yellow]! balance is 0 — deposit USDC (mints pUSD) and set "
|
||||
"allowances from the deposit wallet (trade once in the UI)[/yellow]")
|
||||
|
||||
positions = await gw.positions()
|
||||
held_tokens = list(positions)
|
||||
total_shares = sum(sz for sz, _ in positions.values())
|
||||
check("positions readable (on funder)", True,
|
||||
f"{len(positions)} positions, {total_shares:.0f} shares total")
|
||||
except Exception as e: # noqa: BLE001
|
||||
check("wallet auth (L2 creds derived)", False, str(e))
|
||||
console.print(" [yellow]! signature-type mismatch? deposit wallets use sig_type=3 "
|
||||
"(config.toml). See docs 03 §9.[/yellow]")
|
||||
else:
|
||||
console.print(" [yellow]! skipping wallet checks (no secrets)[/yellow]")
|
||||
|
||||
if cfg.proxy:
|
||||
console.print(f" [dim]· routing via proxy {cfg.proxy.split('@')[-1]}[/dim]")
|
||||
|
||||
# ── live market WS: receive an actual book frame ────────────────────
|
||||
token = held_tokens[0] if held_tokens else await _top_political_token(cfg)
|
||||
if token:
|
||||
passed, detail = await _market_ws_book(token, cfg.proxy)
|
||||
check("market WS live book frame", passed, detail)
|
||||
else:
|
||||
check("market WS live book frame", False, "no token to subscribe to")
|
||||
|
||||
# ── live user WS: authenticate ──────────────────────────────────────
|
||||
if creds is not None:
|
||||
markets = [cfg.markets[0].condition_id] if cfg.markets and cfg.markets[0].condition_id else []
|
||||
passed, detail = await _user_ws_auth(creds, markets, cfg.proxy)
|
||||
check("user WS authenticated", passed, detail)
|
||||
else:
|
||||
console.print(" [dim]· skipping user WS (needs wallet creds)[/dim]")
|
||||
|
||||
console.print(f"\n[bold]{'READY' if ok else 'NOT READY'}[/bold]")
|
||||
return ok
|
||||
|
||||
|
||||
async def _market_ws_book(token: str, proxy: str | None = None) -> tuple[bool, str]:
|
||||
"""Subscribe to a token and confirm a real `book` frame arrives."""
|
||||
kw: dict[str, Any] = {"ping_interval": 5, "ping_timeout": None, "open_timeout": 10}
|
||||
if proxy:
|
||||
kw["proxy"] = proxy
|
||||
try:
|
||||
async with websockets.connect(MARKET_WS, **kw) as ws:
|
||||
await ws.send(json.dumps({"assets_ids": [token], "type": "market"}))
|
||||
for _ in range(12):
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=8)
|
||||
data = json.loads(raw)
|
||||
for m in data if isinstance(data, list) else [data]:
|
||||
if isinstance(m, dict) and m.get("event_type") == "book":
|
||||
nb, na = len(m.get("bids", [])), len(m.get("asks", []))
|
||||
return True, f"book received: {nb} bids / {na} asks"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)[:80]
|
||||
return False, "no book frame within timeout"
|
||||
|
||||
|
||||
async def _user_ws_auth(creds: Any, markets: list[str], proxy: str | None = None) -> tuple[bool, str]:
|
||||
"""Authenticate on the user channel and confirm the server accepts it."""
|
||||
kw: dict[str, Any] = {"ping_interval": 5, "ping_timeout": None, "open_timeout": 10}
|
||||
if proxy:
|
||||
kw["proxy"] = proxy
|
||||
try:
|
||||
async with websockets.connect(USER_WS, **kw) as ws:
|
||||
await ws.send(json.dumps({
|
||||
"type": "user",
|
||||
"auth": {"apiKey": creds.api_key, "secret": creds.api_secret,
|
||||
"passphrase": creds.api_passphrase},
|
||||
"markets": markets,
|
||||
}))
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=4)
|
||||
low = raw.lower() if isinstance(raw, str) else ""
|
||||
if "auth" in low and any(w in low for w in ("fail", "error", "invalid", "unauthor")):
|
||||
return False, "auth rejected by server"
|
||||
return True, "connected, receiving events"
|
||||
except TimeoutError:
|
||||
# no message but the socket stayed open => auth accepted, just idle
|
||||
return True, "connected (idle — no events yet)"
|
||||
except websockets.ConnectionClosed:
|
||||
return False, "connection closed (auth likely rejected)"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)[:80]
|
||||
|
||||
|
||||
async def _top_political_token(cfg: Config) -> str | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(f"{cfg.wallet.gamma_host}/markets",
|
||||
params={"limit": 1, "closed": "false", "tag_id": 2,
|
||||
"order": "volume24hr", "ascending": "false"})
|
||||
toks = json.loads(r.json()[0]["clobTokenIds"])
|
||||
return str(toks[0])
|
||||
except (httpx.HTTPError, KeyError, IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_balance(ba: dict[str, Any]) -> float | None:
|
||||
if not isinstance(ba, dict):
|
||||
return None
|
||||
for k in ("balance", "collateral", "amount"):
|
||||
if k in ba:
|
||||
try:
|
||||
v = float(ba[k])
|
||||
return v / 1e6 if v > 1e6 else v
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Core domain types shared across polymaker.
|
||||
|
||||
These are plain, immutable-ish dataclasses and enums with no I/O. Everything the
|
||||
strategy, execution, and state layers speak is defined here so the boundaries
|
||||
between components are typed rather than dict-shaped (the v1 failure mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Side(str, Enum):
|
||||
"""Order side. Values match the CLOB API's string form."""
|
||||
|
||||
BUY = "BUY"
|
||||
SELL = "SELL"
|
||||
|
||||
@property
|
||||
def opposite(self) -> Side:
|
||||
return Side.SELL if self is Side.BUY else Side.BUY
|
||||
|
||||
|
||||
class Regime(str, Enum):
|
||||
"""Per-market quoting regime (see docs/scoping/04-strategy.md §5)."""
|
||||
|
||||
QUIET = "QUIET" # farming posture: in-band, layered, full size
|
||||
TRENDING = "TRENDING" # persistent one-sided flow: lean + widen + half size
|
||||
EVENT = "EVENT" # sweep/jump detected: pull quotes, cool off
|
||||
REDUCE_ONLY = "REDUCE_ONLY" # inventory cap / end-date: exit quotes only
|
||||
HALTED = "HALTED" # stale data / resolved / kill switch: cancel all
|
||||
|
||||
|
||||
class OrderState(str, Enum):
|
||||
"""Lifecycle of one of our orders."""
|
||||
|
||||
DRAFT = "DRAFT"
|
||||
POSTED = "POSTED"
|
||||
LIVE = "LIVE"
|
||||
PARTIALLY_FILLED = "PARTIALLY_FILLED"
|
||||
CANCELED = "CANCELED"
|
||||
REJECTED = "REJECTED"
|
||||
DONE = "DONE"
|
||||
|
||||
|
||||
class TradeState(str, Enum):
|
||||
"""Lifecycle of an on-chain match, mirroring the user-WS status ladder."""
|
||||
|
||||
MATCHED = "MATCHED"
|
||||
MINED = "MINED"
|
||||
CONFIRMED = "CONFIRMED"
|
||||
RETRYING = "RETRYING"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
# ── Market metadata ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenMeta:
|
||||
token_id: str
|
||||
outcome: str # e.g. "Yes" / "No" / candidate name
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarketMeta:
|
||||
"""Static-ish metadata for a tradable market, sourced from Gamma/CLOB."""
|
||||
|
||||
condition_id: str
|
||||
question: str
|
||||
slug: str
|
||||
tokens: tuple[TokenMeta, TokenMeta]
|
||||
tick_size: float
|
||||
neg_risk: bool
|
||||
min_order_size: float # exchange minimum order size (shares)
|
||||
# liquidity-rewards params
|
||||
rewards_min_size: float
|
||||
rewards_max_spread: float # in cents (e.g. 3.0 == 3c band)
|
||||
rewards_daily_rate: float
|
||||
# fees
|
||||
maker_fee_bps: int
|
||||
taker_fee_bps: int
|
||||
fees_enabled: bool
|
||||
# lifecycle / grouping
|
||||
end_date_iso: str | None
|
||||
event_id: str | None # neg-risk event group; siblings share this
|
||||
# fraction of taker fees rebated to makers (V2 maker rebates)
|
||||
rebate_rate: float = 0.0
|
||||
# market-data references (may be stale; not authoritative for quoting)
|
||||
best_bid: float = 0.0
|
||||
best_ask: float = 0.0
|
||||
liquidity_num: float = 0.0
|
||||
volume_num: float = 0.0
|
||||
|
||||
@property
|
||||
def yes(self) -> TokenMeta:
|
||||
return self.tokens[0]
|
||||
|
||||
@property
|
||||
def no(self) -> TokenMeta:
|
||||
return self.tokens[1]
|
||||
|
||||
def other_token(self, token_id: str) -> str:
|
||||
a, b = self.tokens
|
||||
return b.token_id if token_id == a.token_id else a.token_id
|
||||
|
||||
@property
|
||||
def price_decimals(self) -> int:
|
||||
"""Number of decimal places implied by the tick size."""
|
||||
s = f"{self.tick_size:f}".rstrip("0")
|
||||
return len(s.split(".")[1]) if "." in s else 0
|
||||
|
||||
|
||||
# ── Live trading state ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Position:
|
||||
token_id: str
|
||||
size: float = 0.0 # signed shares held (long only in practice; >= 0)
|
||||
avg_price: float = 0.0
|
||||
|
||||
@property
|
||||
def is_flat(self) -> bool:
|
||||
return self.size <= 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OpenOrder:
|
||||
"""One of our resting orders as we currently believe it exists."""
|
||||
|
||||
order_id: str
|
||||
token_id: str
|
||||
side: Side
|
||||
price: float
|
||||
size: float # remaining (original - matched)
|
||||
state: OrderState = OrderState.LIVE
|
||||
created_ts: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
def notional(self) -> float:
|
||||
return self.price * self.size
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Fill:
|
||||
token_id: str
|
||||
side: Side
|
||||
price: float
|
||||
size: float
|
||||
trade_id: str
|
||||
ts: float = field(default_factory=time.time)
|
||||
is_maker: bool = True
|
||||
|
||||
|
||||
# ── Strategy output ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Quote:
|
||||
"""One intended resting order the strategy wants live."""
|
||||
|
||||
token_id: str
|
||||
side: Side
|
||||
price: float
|
||||
size: float
|
||||
|
||||
def key(self, price_decimals: int) -> tuple[str, Side, float]:
|
||||
"""Identity used to match against live orders (side + rounded price)."""
|
||||
return (self.token_id, self.side, round(self.price, price_decimals))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TargetQuotes:
|
||||
"""The full desired resting-order set for a market at a point in time."""
|
||||
|
||||
condition_id: str
|
||||
regime: Regime
|
||||
quotes: tuple[Quote, ...] = ()
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return len(self.quotes) == 0
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Engine: wires every component into a single async event loop.
|
||||
|
||||
Data flow per market:
|
||||
market WS -> OrderBook -> (wake) -> Quoter task -> strategy (pure) -> reconcile
|
||||
-> ExecutionGateway ; user WS -> StateStore ; periodic REST reconcile + heartbeat.
|
||||
|
||||
One lightweight quoter task per market, woken by book/fill events and debounced.
|
||||
The strategy layer is pure; the engine owns all the state and I/O around it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from polymaker.catalog.gamma import GammaClient, fetch_reward_rates, parse_market
|
||||
from polymaker.catalog.store import CatalogStore
|
||||
from polymaker.config import Config, StrategyProfile
|
||||
from polymaker.domain import Fill, MarketMeta
|
||||
from polymaker.execution.gateway import ExecutionGateway
|
||||
from polymaker.execution.reconciler import reconcile
|
||||
from polymaker.journal import Journal
|
||||
from polymaker.logging import get_logger
|
||||
from polymaker.marketdata.parse import TradePrint
|
||||
from polymaker.marketdata.service import MarketDataService
|
||||
from polymaker.merge import Merger
|
||||
from polymaker.risk.manager import RiskManager
|
||||
from polymaker.state.store import StateStore
|
||||
from polymaker.state.tracker import UserEventProcessor
|
||||
from polymaker.strategy.estimators import (
|
||||
FlowEstimator,
|
||||
MarketEstimators,
|
||||
MarkoutTracker,
|
||||
VolEstimator,
|
||||
)
|
||||
from polymaker.strategy.quoting import QuoteInputs, compute_fair_value, construct_quotes
|
||||
from polymaker.strategy.regime import RegimeInputs, RegimeMachine
|
||||
from polymaker.userstream.client import UserStream
|
||||
|
||||
log = get_logger("engine")
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(self, cfg: Config, *, paper: bool = False) -> None:
|
||||
self.cfg = cfg
|
||||
self.paper = paper
|
||||
self._running = False
|
||||
|
||||
self.journal = Journal(cfg.paths.journal_dir, enabled=cfg.engine.journal,
|
||||
day="paper" if paper else "live")
|
||||
self.state = StateStore(cfg.paths.db)
|
||||
self.catalog = CatalogStore(cfg.paths.db)
|
||||
self.gateway = ExecutionGateway(cfg, self.journal, paper=paper)
|
||||
self.risk = RiskManager(cfg.risk, self.state)
|
||||
self.merger = Merger(cfg)
|
||||
|
||||
self.md = MarketDataService(on_dirty=self._on_dirty, on_trade=self._on_trade,
|
||||
journal=self.journal, proxy=cfg.proxy)
|
||||
self.user_proc = UserEventProcessor(self.state, on_change=self._wake_cid,
|
||||
on_fill=self._on_fill)
|
||||
self.user: UserStream | None = None
|
||||
|
||||
# per-market state
|
||||
self.metas: dict[str, MarketMeta] = {}
|
||||
self.profiles: dict[str, StrategyProfile] = {}
|
||||
self.est: dict[str, MarketEstimators] = {}
|
||||
self.regime_m: dict[str, RegimeMachine] = {}
|
||||
self._dirty: dict[str, asyncio.Event] = {}
|
||||
self._sweep: dict[str, bool] = {}
|
||||
self._merging: set[str] = set()
|
||||
self._token_cid: dict[str, str] = {}
|
||||
self._tasks: list[asyncio.Task[Any]] = []
|
||||
|
||||
# ── lifecycle ───────────────────────────────────────────────────────
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
await self.gateway.connect()
|
||||
await self._resolve_markets()
|
||||
if not self.metas:
|
||||
log.warning("no_markets_selected", hint="add markets to config/markets.toml, run `polymaker scan`")
|
||||
await self._startup_reconcile()
|
||||
|
||||
# subscribe feeds
|
||||
self.md.set_markets([(cid, [m.yes.token_id, m.no.token_id]) for cid, m in self.metas.items()])
|
||||
self.user = UserStream(
|
||||
self.gateway.creds, self.gateway.address, self.user_proc,
|
||||
other_token=self._other_token, condition_of_token=self._cid_of_token,
|
||||
journal=self.journal, proxy=self.cfg.proxy,
|
||||
)
|
||||
self.user.set_markets(list(self.metas))
|
||||
|
||||
# launch tasks
|
||||
self._tasks.append(asyncio.create_task(self.md.run(), name="market_ws"))
|
||||
if not self.paper:
|
||||
self._tasks.append(asyncio.create_task(self.user.run(), name="user_ws"))
|
||||
self._tasks.append(asyncio.create_task(self._heartbeat_loop(), name="heartbeat"))
|
||||
self._tasks.append(asyncio.create_task(self._reconcile_loop(), name="reconcile"))
|
||||
for cid in self.metas:
|
||||
self._tasks.append(asyncio.create_task(self._quoter(cid), name=f"quote:{cid[:8]}"))
|
||||
self.risk.reset_day()
|
||||
log.info("engine_started", markets=len(self.metas), paper=self.paper)
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
await self.start()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await asyncio.gather(*self._tasks)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._running = False
|
||||
log.info("engine_shutdown")
|
||||
self.md.stop()
|
||||
if self.user:
|
||||
self.user.stop()
|
||||
for t in self._tasks:
|
||||
t.cancel()
|
||||
with contextlib.suppress(Exception):
|
||||
await self.gateway.cancel_all()
|
||||
self.journal.close()
|
||||
self.state.close()
|
||||
self.catalog.close()
|
||||
|
||||
# ── market resolution ───────────────────────────────────────────────
|
||||
async def _resolve_markets(self) -> None:
|
||||
reward_rates: dict[str, float] | None = None
|
||||
async with GammaClient(self.cfg.wallet.gamma_host) as gamma:
|
||||
for entry in self.cfg.enabled_markets:
|
||||
meta = self.catalog.get_by_slug(entry.slug) if entry.slug else None
|
||||
if meta is None and entry.condition_id:
|
||||
meta = self.catalog.get(entry.condition_id)
|
||||
if meta is None: # fall back to a live Gamma fetch
|
||||
if reward_rates is None:
|
||||
reward_rates = await fetch_reward_rates(self.cfg.wallet.clob_host)
|
||||
meta = await self._fetch_meta(gamma, entry.slug, entry.condition_id, reward_rates)
|
||||
if meta is None:
|
||||
log.warning("market_unresolved", ref=entry.ref)
|
||||
continue
|
||||
self.metas[meta.condition_id] = meta
|
||||
self.profiles[meta.condition_id] = self.cfg.profile_for(entry)
|
||||
self.est[meta.condition_id] = self._make_estimators(self.profiles[meta.condition_id])
|
||||
self.regime_m[meta.condition_id] = RegimeMachine()
|
||||
self._dirty[meta.condition_id] = asyncio.Event()
|
||||
for tok in (meta.yes.token_id, meta.no.token_id):
|
||||
self._token_cid[tok] = meta.condition_id
|
||||
|
||||
async def _fetch_meta(
|
||||
self, gamma: GammaClient, slug: str | None, condition_id: str | None,
|
||||
reward_rates: dict[str, float],
|
||||
) -> MarketMeta | None:
|
||||
tag_id = self.catalog.cached_tag("politics")
|
||||
async for raw in gamma.iter_markets(tag_id=tag_id, max_pages=25):
|
||||
if (slug and raw.get("slug") == slug) or (condition_id and raw.get("conditionId") == condition_id):
|
||||
m = parse_market(raw, reward_rates)
|
||||
if m:
|
||||
self.catalog.upsert_market(m)
|
||||
return m
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _make_estimators(p: StrategyProfile) -> MarketEstimators:
|
||||
return MarketEstimators(
|
||||
vol=VolEstimator(p.vol_short_halflife_s, p.vol_long_halflife_s),
|
||||
flow=FlowEstimator(p.flow_ewma_halflife_s),
|
||||
markout=MarkoutTracker(),
|
||||
)
|
||||
|
||||
async def _startup_reconcile(self) -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.gateway.cancel_all() # clean slate; heartbeat covers crashes
|
||||
positions = await self.gateway.positions()
|
||||
if positions:
|
||||
self.state.reconcile_positions(positions)
|
||||
log.info("startup_positions", n=len(positions))
|
||||
|
||||
# ── callbacks ───────────────────────────────────────────────────────
|
||||
def _on_dirty(self, condition_id: str, token_id: str) -> None:
|
||||
ev = self._dirty.get(condition_id)
|
||||
if ev is not None:
|
||||
ev.set()
|
||||
|
||||
def _wake_cid(self, condition_id: str) -> None:
|
||||
ev = self._dirty.get(condition_id)
|
||||
if ev is not None:
|
||||
ev.set()
|
||||
|
||||
def _on_trade(self, tp: TradePrint) -> None:
|
||||
cid = self._token_cid.get(tp.asset_id)
|
||||
if cid is None:
|
||||
return
|
||||
self.est[cid].flow.update(tp.aggressor, tp.size, tp.ts)
|
||||
# crude sweep flag: a single print larger than 3x base size
|
||||
base = self.profiles[cid].base_size_usdc / max(tp.price, 0.01)
|
||||
if tp.size >= 3 * base:
|
||||
self._sweep[cid] = True
|
||||
|
||||
def _on_fill(self, fill: Fill) -> None:
|
||||
self.risk.note_fill(fill)
|
||||
cid = self._token_cid.get(fill.token_id)
|
||||
if cid is None:
|
||||
return
|
||||
est = self.est[cid]
|
||||
fv = est.last_fv if est.last_fv is not None else fill.price
|
||||
token_fv = fv if fill.token_id == self.metas[cid].yes.token_id else (1.0 - fv)
|
||||
est.markout.record_fill(fill.side, token_fv, fill.ts)
|
||||
|
||||
# ── quoter ──────────────────────────────────────────────────────────
|
||||
async def _quoter(self, cid: str) -> None:
|
||||
debounce = self.cfg.engine.debounce_ms / 1000.0
|
||||
ev = self._dirty[cid]
|
||||
while self._running:
|
||||
try:
|
||||
await ev.wait()
|
||||
await asyncio.sleep(debounce) # coalesce a burst of book updates
|
||||
ev.clear()
|
||||
await self._recompute(cid)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("quoter_error", cid=cid[:8], err=str(exc))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def _recompute(self, cid: str) -> None:
|
||||
meta = self.metas[cid]
|
||||
p = self.profiles[cid]
|
||||
yes_book = self.md.book(meta.yes.token_id)
|
||||
no_book = self.md.book(meta.no.token_id)
|
||||
if yes_book is None or yes_book.is_empty:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
micro = yes_book.microprice(p.micro_levels)
|
||||
if micro is None:
|
||||
return
|
||||
est = self.est[cid]
|
||||
est.flow.decay_to(now)
|
||||
fv = compute_fair_value(micro, est.flow.z, meta.tick_size)
|
||||
prev_fv = est.last_fv
|
||||
est.on_fair_value(fv, now)
|
||||
|
||||
self.risk.update_mark(meta.yes.token_id, fv)
|
||||
self.risk.update_mark(meta.no.token_id, 1.0 - fv)
|
||||
|
||||
pos_yes = self.state.position(meta.yes.token_id)
|
||||
pos_no = self.state.position(meta.no.token_id)
|
||||
q_max = p.q_max_usdc
|
||||
inv_util = abs(pos_yes.size - pos_no.size) * fv / q_max if q_max > 0 else 0.0
|
||||
hours_to_end = _hours_to_end(meta.end_date_iso, now)
|
||||
ws_stale = (now - self.md.last_update_ts(meta.yes.token_id)) > self.cfg.risk.ws_stale_halt_s
|
||||
|
||||
rd = self.risk.evaluate(meta, ws_stale=ws_stale,
|
||||
event_group_cost=self._event_group_cost(meta))
|
||||
regime = self.regime_m[cid].decide(
|
||||
RegimeInputs(
|
||||
now=now, tick=meta.tick_size, fv=fv, prev_fv=prev_fv,
|
||||
vol_ratio=est.vol.ratio, flow_z=est.flow.z, inventory_util=inv_util,
|
||||
hours_to_end=hours_to_end, sweep_flagged=self._sweep.pop(cid, False),
|
||||
ws_stale=ws_stale, risk_halt=rd.halt, risk_reduce_only=rd.reduce_only,
|
||||
),
|
||||
p,
|
||||
)
|
||||
|
||||
tq = construct_quotes(QuoteInputs(
|
||||
meta=meta, regime=regime, fv=fv, vol_short=est.vol.short,
|
||||
toxicity=est.markout.toxicity, yes_view=yes_book.view(),
|
||||
no_view=(no_book.view() if no_book else _empty_view()),
|
||||
pos_yes=pos_yes, pos_no=pos_no, profile=p, now=now,
|
||||
risk_size_scale=rd.size_scale,
|
||||
))
|
||||
|
||||
live = self.state.orders_for(meta.yes.token_id) + self.state.orders_for(meta.no.token_id)
|
||||
plan = reconcile(tq, live, tick=meta.tick_size,
|
||||
reprice_ticks=p.reprice_ticks, resize_frac=p.resize_frac)
|
||||
if plan.is_noop:
|
||||
self._maybe_merge(cid, meta, p, pos_yes.size, pos_no.size)
|
||||
return
|
||||
|
||||
if plan.to_cancel:
|
||||
await self.gateway.cancel(plan.to_cancel)
|
||||
for oid in plan.to_cancel:
|
||||
self.state.remove_order(oid)
|
||||
if plan.to_place:
|
||||
placed = await self.gateway.place(plan.to_place, meta)
|
||||
self.risk.note_order_result(bool(placed) or not plan.to_place)
|
||||
for o in placed:
|
||||
self.state.upsert_order(o)
|
||||
log.info("requote", cid=cid[:8], regime=regime.value, fv=round(fv, 4),
|
||||
place=len(plan.to_place), cancel=len(plan.to_cancel),
|
||||
pos_yes=round(pos_yes.size, 1), pos_no=round(pos_no.size, 1))
|
||||
self._maybe_merge(cid, meta, p, pos_yes.size, pos_no.size)
|
||||
|
||||
def _maybe_merge(self, cid: str, meta: MarketMeta, p: StrategyProfile,
|
||||
yes_size: float, no_size: float) -> None:
|
||||
amount = min(yes_size, no_size)
|
||||
if amount < p.merge_min_size or cid in self._merging or self.paper:
|
||||
return
|
||||
self._merging.add(cid)
|
||||
self._tasks.append(asyncio.create_task(self._merge_task(cid, meta, amount)))
|
||||
|
||||
async def _merge_task(self, cid: str, meta: MarketMeta, amount: float) -> None:
|
||||
try:
|
||||
raw = int(amount * 1e6)
|
||||
await asyncio.to_thread(self.merger.merge, meta.condition_id, raw, meta.neg_risk)
|
||||
finally:
|
||||
self._merging.discard(cid)
|
||||
|
||||
# ── background loops ────────────────────────────────────────────────
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
if not self.cfg.engine.heartbeat:
|
||||
return
|
||||
while self._running:
|
||||
await self.gateway.heartbeat()
|
||||
await asyncio.sleep(self.cfg.engine.heartbeat_interval_s)
|
||||
|
||||
async def _reconcile_loop(self) -> None:
|
||||
while self._running:
|
||||
await asyncio.sleep(self.cfg.engine.reconcile_interval_s)
|
||||
try:
|
||||
positions = await self.gateway.positions()
|
||||
if positions:
|
||||
self.state.reconcile_positions(positions)
|
||||
live = await self.gateway.open_orders()
|
||||
if live or not self.paper:
|
||||
by_token: dict[str, list[Any]] = {}
|
||||
for o in live:
|
||||
by_token.setdefault(o.token_id, []).append(o)
|
||||
for tok, orders in by_token.items():
|
||||
if self.state.inflight(tok) == 0:
|
||||
self.state.replace_open_orders(tok, orders)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("reconcile_error", err=str(exc))
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────
|
||||
def _other_token(self, token_id: str) -> str | None:
|
||||
cid = self._token_cid.get(token_id)
|
||||
return self.metas[cid].other_token(token_id) if cid else None
|
||||
|
||||
def _cid_of_token(self, token_id: str) -> str | None:
|
||||
return self._token_cid.get(token_id)
|
||||
|
||||
def _event_group_cost(self, meta: MarketMeta) -> float:
|
||||
if not meta.event_id:
|
||||
return 0.0
|
||||
cost = 0.0
|
||||
for m in self.metas.values():
|
||||
if m.event_id == meta.event_id:
|
||||
for tok in (m.yes.token_id, m.no.token_id):
|
||||
pos = self.state.position(tok)
|
||||
cost += pos.size * pos.avg_price
|
||||
return cost
|
||||
|
||||
|
||||
def _hours_to_end(end_date_iso: str | None, now: float) -> float | None:
|
||||
if not end_date_iso:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(end_date_iso.replace("Z", "+00:00"))
|
||||
return max(0.0, (dt.timestamp() - now) / 3600.0)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _empty_view() -> Any:
|
||||
from polymaker.marketdata.orderbook import BookView
|
||||
|
||||
return BookView(None, 0.0, None, 0.0, None, None, 0.0, 0.0)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Append-only JSONL event journal.
|
||||
|
||||
Captures raw WS-in and orders-out so the replay backtester (docs 04 §9) can
|
||||
reconstruct books and re-run the strategy. Also the substrate for post-mortems.
|
||||
Cheap: one line per event, flushed, rotated by day.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Journal:
|
||||
def __init__(self, directory: str | Path, *, enabled: bool = True, day: str = "live") -> None:
|
||||
self.enabled = enabled
|
||||
self._fh = None
|
||||
if enabled:
|
||||
d = Path(directory)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = (d / f"{day}.jsonl").open("a", buffering=1)
|
||||
|
||||
def write(self, kind: str, payload: Any, ts: float) -> None:
|
||||
if not self.enabled or self._fh is None:
|
||||
return
|
||||
self._fh.write(json.dumps({"ts": ts, "kind": kind, "data": payload}, default=str) + "\n")
|
||||
|
||||
def close(self) -> None:
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Live wallet round-trip test — the Phase-2 wallet spike (docs 06).
|
||||
|
||||
Proves the full V2 order path against the real exchange with minimal risk:
|
||||
places ONE post-only BUY well below the touch (so it rests and cannot fill),
|
||||
confirms it appears in open orders, then cancels it. Post-only guarantees it
|
||||
never takes; the deep price + immediate cancel means ~zero economic risk.
|
||||
|
||||
This is where the known py-clob-client-v2 signature-type-2 (Safe/proxy) issues
|
||||
would surface — the command reports each step so failures are diagnosable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from polymaker.config import Config
|
||||
from polymaker.domain import Quote, Side
|
||||
from polymaker.execution.gateway import ExecutionGateway
|
||||
|
||||
|
||||
async def run_livetest(cfg: Config, console: Console, notional_usdc: float = 5.0) -> bool:
|
||||
from polymaker.catalog.store import CatalogStore
|
||||
|
||||
if not cfg.secrets.has_wallet:
|
||||
console.print("[red]No wallet in .env. Set PK and BROWSER_ADDRESS first.[/red]")
|
||||
return False
|
||||
|
||||
# pick a liquid market from the catalog (or fall back to a live scan)
|
||||
store = CatalogStore(cfg.paths.db)
|
||||
rows = store.top(20)
|
||||
store.close()
|
||||
if not rows:
|
||||
console.print("[yellow]Catalog empty — run `polymaker scan` first.[/yellow]")
|
||||
return False
|
||||
# choose a market with a mid comfortably in (0.15, 0.85) so a deep bid is valid
|
||||
meta = None
|
||||
for m, _sc in rows:
|
||||
mid = (m.best_bid + m.best_ask) / 2 if (m.best_bid and m.best_ask) else 0.0
|
||||
if 0.15 < mid < 0.85:
|
||||
meta = m
|
||||
break
|
||||
meta = meta or rows[0][0]
|
||||
|
||||
console.print(f"[bold]Live round-trip test[/bold] on: {meta.question[:60]}")
|
||||
gw = ExecutionGateway(cfg)
|
||||
try:
|
||||
await gw.connect()
|
||||
console.print(f" [green]✓[/green] wallet auth — address {gw.address[:12]}…")
|
||||
except Exception as e: # noqa: BLE001
|
||||
console.print(f" [red]✗ wallet auth failed:[/red] {e}")
|
||||
console.print(" [yellow]Auth/signature-type mismatch (docs 03 §9). If your account has a "
|
||||
"'deposit address', set signature_type=3 (POLY_1271) in config.toml and use the "
|
||||
"deposit address as BROWSER_ADDRESS. Errors like 'maker address not allowed, use "
|
||||
"the deposit wallet flow' or 'signer must be the API key address' mean the type is "
|
||||
"wrong for this wallet.[/yellow]")
|
||||
return False
|
||||
|
||||
ba = await gw.balance_allowance()
|
||||
console.print(f" balance/allowance: {ba}")
|
||||
|
||||
# a deep resting price: well below best bid, snapped to tick, floored at 2 ticks
|
||||
tick = meta.tick_size
|
||||
best_bid = meta.best_bid or 0.30
|
||||
price = max(2 * tick, round((best_bid - 0.10) / tick) * tick)
|
||||
size = round(max(meta.min_order_size, notional_usdc / price), 2)
|
||||
console.print(f" placing post-only BUY {size} @ {price} on YES token "
|
||||
f"(~${price * size:.2f}, deep — will not fill)")
|
||||
|
||||
placed = await gw.place([Quote(meta.yes.token_id, Side.BUY, price, size)], meta)
|
||||
if not placed:
|
||||
console.print(" [red]✗ order not placed (see logs for the API error)[/red]")
|
||||
return False
|
||||
oid = placed[0].order_id
|
||||
console.print(f" [green]✓[/green] placed — order id {oid[:16]}…")
|
||||
|
||||
await asyncio.sleep(2.0)
|
||||
live = await gw.open_orders()
|
||||
found = any(o.order_id == oid for o in live)
|
||||
console.print(f" [{'green' if found else 'yellow'}]{'✓' if found else '?'}[/] "
|
||||
f"read back open orders: {len(live)} live, ours {'present' if found else 'not seen yet'}")
|
||||
|
||||
await gw.cancel([oid])
|
||||
console.print(" [green]✓[/green] cancel sent")
|
||||
await asyncio.sleep(1.5)
|
||||
after = await gw.open_orders()
|
||||
still = any(o.order_id == oid for o in after)
|
||||
console.print(f" [{'green' if not still else 'red'}]{'✓' if not still else '✗'}[/] "
|
||||
f"order {'cancelled' if not still else 'STILL LIVE — cancel manually!'}")
|
||||
|
||||
ok = bool(placed) and not still
|
||||
console.print(f"\n[bold]{'ROUND-TRIP OK' if ok else 'CHECK LOGS'}[/bold]")
|
||||
return ok
|
||||
@@ -0,0 +1,66 @@
|
||||
"""structlog configuration: human console in dev, JSON to file in prod."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def configure(
|
||||
*,
|
||||
level: str = "INFO",
|
||||
json_file: Path | None = None,
|
||||
console: bool = True,
|
||||
) -> None:
|
||||
"""Set up structlog + stdlib logging once at process start."""
|
||||
shared: list[Any] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
structlog.configure(
|
||||
processors=[*shared, structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.setLevel(level)
|
||||
|
||||
if console:
|
||||
ch = logging.StreamHandler(sys.stderr)
|
||||
ch.setFormatter(
|
||||
structlog.stdlib.ProcessorFormatter(
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()),
|
||||
]
|
||||
)
|
||||
)
|
||||
root.addHandler(ch)
|
||||
|
||||
if json_file is not None:
|
||||
json_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fh = logging.FileHandler(json_file)
|
||||
fh.setFormatter(
|
||||
structlog.stdlib.ProcessorFormatter(
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
structlog.processors.JSONRenderer(),
|
||||
]
|
||||
)
|
||||
)
|
||||
root.addHandler(fh)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
return structlog.get_logger(name) # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Order book maintenance and analytics for a single market.
|
||||
|
||||
We keep the YES-token book canonical (bids/asks as SortedDicts keyed by price).
|
||||
The NO-token view is derived by the identity no_price = 1 - yes_price, with
|
||||
bids/asks swapped — so we only ever maintain one book per market.
|
||||
|
||||
All methods are synchronous and side-effect-free reads except the explicit
|
||||
apply_* mutators. Nothing here does I/O; the WS layer drives it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sortedcontainers import SortedDict
|
||||
|
||||
from polymaker.domain import Side
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BookLevel:
|
||||
price: float
|
||||
size: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BookView:
|
||||
"""A resolved best/second/depth snapshot for one outcome token."""
|
||||
|
||||
best_bid: float | None
|
||||
best_bid_size: float
|
||||
best_ask: float | None
|
||||
best_ask_size: float
|
||||
second_bid: float | None
|
||||
second_ask: float | None
|
||||
bid_depth: float # summed size within band, bid side
|
||||
ask_depth: float # summed size within band, ask side
|
||||
|
||||
@property
|
||||
def mid(self) -> float | None:
|
||||
if self.best_bid is None or self.best_ask is None:
|
||||
return None
|
||||
return (self.best_bid + self.best_ask) / 2.0
|
||||
|
||||
@property
|
||||
def spread(self) -> float | None:
|
||||
if self.best_bid is None or self.best_ask is None:
|
||||
return None
|
||||
return self.best_ask - self.best_bid
|
||||
|
||||
@property
|
||||
def imbalance(self) -> float:
|
||||
"""(bid_depth - ask_depth) / total in [-1, 1]; 0 if empty."""
|
||||
total = self.bid_depth + self.ask_depth
|
||||
return (self.bid_depth - self.ask_depth) / total if total > 0 else 0.0
|
||||
|
||||
|
||||
class OrderBook:
|
||||
"""YES-canonical L2 book for one market."""
|
||||
|
||||
__slots__ = ("bids", "asks", "tick_size", "last_update_ts", "book_hash")
|
||||
|
||||
def __init__(self, tick_size: float = 0.001) -> None:
|
||||
# price -> size. bids and asks both ascending in price.
|
||||
self.bids: SortedDict[float, float] = SortedDict()
|
||||
self.asks: SortedDict[float, float] = SortedDict()
|
||||
self.tick_size = tick_size
|
||||
self.last_update_ts: float = 0.0
|
||||
self.book_hash: str | None = None
|
||||
|
||||
# ── mutation ────────────────────────────────────────────────────────
|
||||
def apply_snapshot(
|
||||
self,
|
||||
bids: list[tuple[float, float]],
|
||||
asks: list[tuple[float, float]],
|
||||
ts: float,
|
||||
book_hash: str | None = None,
|
||||
) -> None:
|
||||
self.bids = SortedDict({p: s for p, s in bids if s > 0})
|
||||
self.asks = SortedDict({p: s for p, s in asks if s > 0})
|
||||
self.last_update_ts = ts
|
||||
self.book_hash = book_hash
|
||||
|
||||
def apply_delta(self, side: Side, price: float, size: float, ts: float) -> None:
|
||||
book = self.bids if side is Side.BUY else self.asks
|
||||
if size <= 0:
|
||||
book.pop(price, None)
|
||||
else:
|
||||
book[price] = size
|
||||
self.last_update_ts = ts
|
||||
|
||||
def set_tick_size(self, tick_size: float) -> None:
|
||||
self.tick_size = tick_size
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return len(self.bids) == 0 or len(self.asks) == 0
|
||||
|
||||
# ── raw best (YES side) ─────────────────────────────────────────────
|
||||
def best_bid(self) -> BookLevel | None:
|
||||
if not self.bids:
|
||||
return None
|
||||
p = self.bids.peekitem(-1) # highest bid
|
||||
return BookLevel(p[0], p[1])
|
||||
|
||||
def best_ask(self) -> BookLevel | None:
|
||||
if not self.asks:
|
||||
return None
|
||||
p = self.asks.peekitem(0) # lowest ask
|
||||
return BookLevel(p[0], p[1])
|
||||
|
||||
# ── analytics ───────────────────────────────────────────────────────
|
||||
def microprice(self, levels: int = 3) -> float | None:
|
||||
"""Depth-weighted mid over the top `levels`, pulled toward the thin side.
|
||||
|
||||
Uses size at the opposite side as the weight for each price (standard
|
||||
microprice intuition: price is dragged toward the side with less size).
|
||||
Returns None if either side is empty.
|
||||
"""
|
||||
bb = self.best_bid()
|
||||
ba = self.best_ask()
|
||||
if bb is None or ba is None:
|
||||
return None
|
||||
bid_sz = self._top_size(self.bids, levels, from_high=True)
|
||||
ask_sz = self._top_size(self.asks, levels, from_high=False)
|
||||
total = bid_sz + ask_sz
|
||||
if total <= 0:
|
||||
return (bb.price + ba.price) / 2.0
|
||||
# weight best_ask by bid size and best_bid by ask size
|
||||
return (ba.price * bid_sz + bb.price * ask_sz) / total
|
||||
|
||||
def best_with_min_size(
|
||||
self, side: Side, min_size: float
|
||||
) -> tuple[float | None, float, float | None]:
|
||||
"""First level (from the touch) with size > min_size.
|
||||
|
||||
Returns (price, size, top_price) where top_price is the actual touch
|
||||
(used to detect dust at the front). Mirrors v1's find_best_price_with_size
|
||||
but without the second-best bookkeeping the new strategy doesn't need.
|
||||
"""
|
||||
if side is Side.BUY:
|
||||
items = reversed(self.bids.items()) # high -> low
|
||||
else:
|
||||
items = iter(self.asks.items()) # low -> high
|
||||
top_price: float | None = None
|
||||
for price, size in items:
|
||||
if top_price is None:
|
||||
top_price = price
|
||||
if size > min_size:
|
||||
return price, size, top_price
|
||||
return None, 0.0, top_price
|
||||
|
||||
def depth_within(self, side: Side, lo: float, hi: float) -> float:
|
||||
"""Sum of sizes with price in [lo, hi] on the given side."""
|
||||
book = self.bids if side is Side.BUY else self.asks
|
||||
# SortedDict.irange gives keys in [lo, hi]
|
||||
return float(sum(book[p] for p in book.irange(lo, hi)))
|
||||
|
||||
def view(self, band_frac: float = 0.05, min_size: float = 0.0) -> BookView:
|
||||
"""Resolved YES-side view with best/second and in-band depth."""
|
||||
bb = self._nth_bid(0, min_size)
|
||||
ba = self._nth_ask(0, min_size)
|
||||
sb = self._nth_bid(1, min_size)
|
||||
sa = self._nth_ask(1, min_size)
|
||||
mid = None
|
||||
bid_depth = ask_depth = 0.0
|
||||
if bb is not None and ba is not None:
|
||||
mid = (bb.price + ba.price) / 2.0
|
||||
bid_depth = self.depth_within(Side.BUY, bb.price, mid * (1 + band_frac))
|
||||
ask_depth = self.depth_within(Side.SELL, mid * (1 - band_frac), ba.price)
|
||||
return BookView(
|
||||
best_bid=bb.price if bb else None,
|
||||
best_bid_size=bb.size if bb else 0.0,
|
||||
best_ask=ba.price if ba else None,
|
||||
best_ask_size=ba.size if ba else 0.0,
|
||||
second_bid=sb.price if sb else None,
|
||||
second_ask=sa.price if sa else None,
|
||||
bid_depth=bid_depth,
|
||||
ask_depth=ask_depth,
|
||||
)
|
||||
|
||||
# ── internals ───────────────────────────────────────────────────────
|
||||
def _nth_bid(self, n: int, min_size: float) -> BookLevel | None:
|
||||
count = 0
|
||||
for price in reversed(self.bids):
|
||||
if self.bids[price] > min_size:
|
||||
if count == n:
|
||||
return BookLevel(price, self.bids[price])
|
||||
count += 1
|
||||
return None
|
||||
|
||||
def _nth_ask(self, n: int, min_size: float) -> BookLevel | None:
|
||||
count = 0
|
||||
for price in self.asks:
|
||||
if self.asks[price] > min_size:
|
||||
if count == n:
|
||||
return BookLevel(price, self.asks[price])
|
||||
count += 1
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _top_size(book: SortedDict[float, float], levels: int, *, from_high: bool) -> float:
|
||||
keys = list(reversed(book)) if from_high else list(book)
|
||||
return float(sum(book[k] for k in keys[:levels]))
|
||||
|
||||
|
||||
def to_no_price(yes_price: float) -> float:
|
||||
"""Convert a YES price to the equivalent NO price."""
|
||||
return 1.0 - yes_price
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Pure parsers for market-WS wire messages -> structured updates.
|
||||
|
||||
Kept separate from the socket so they're unit-testable against captured frames.
|
||||
Verified against live frames on 2026-07-05 (docs/scoping/03-api-layer.md §5):
|
||||
|
||||
book: {market, asset_id, bids:[{price,size}], asks:[...], timestamp, hash, tick_size}
|
||||
price_change:{market, timestamp, price_changes:[{asset_id, price, size, side, hash}]}
|
||||
last_trade_price:{market, asset_id, price, size, side, timestamp, fee_rate_bps}
|
||||
tick_size_change:{market, asset_id, old_tick_size, new_tick_size}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from polymaker.domain import Side
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BookUpdate:
|
||||
asset_id: str
|
||||
condition_id: str
|
||||
bids: list[tuple[float, float]]
|
||||
asks: list[tuple[float, float]]
|
||||
ts: float
|
||||
book_hash: str | None
|
||||
tick_size: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PriceChange:
|
||||
asset_id: str
|
||||
condition_id: str
|
||||
side: Side # BUY -> bid side, SELL -> ask side
|
||||
price: float
|
||||
size: float
|
||||
ts: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradePrint:
|
||||
asset_id: str
|
||||
condition_id: str
|
||||
aggressor: Side
|
||||
price: float
|
||||
size: float
|
||||
ts: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TickSizeChange:
|
||||
asset_id: str
|
||||
tick_size: float
|
||||
|
||||
|
||||
def _ts(msg: dict[str, Any]) -> float:
|
||||
raw = msg.get("timestamp")
|
||||
if raw is None:
|
||||
return 0.0
|
||||
try:
|
||||
v = float(raw)
|
||||
return v / 1000.0 if v > 1e12 else v # ms -> s
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _levels(items: Any) -> list[tuple[float, float]]:
|
||||
out: list[tuple[float, float]] = []
|
||||
for it in items or []:
|
||||
try:
|
||||
out.append((float(it["price"]), float(it["size"])))
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def parse_book(msg: dict[str, Any]) -> BookUpdate | None:
|
||||
try:
|
||||
tick = msg.get("tick_size")
|
||||
return BookUpdate(
|
||||
asset_id=str(msg["asset_id"]),
|
||||
condition_id=str(msg.get("market", "")),
|
||||
bids=_levels(msg.get("bids")),
|
||||
asks=_levels(msg.get("asks")),
|
||||
ts=_ts(msg),
|
||||
book_hash=msg.get("hash"),
|
||||
tick_size=float(tick) if tick is not None else None,
|
||||
)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_price_changes(msg: dict[str, Any]) -> list[PriceChange]:
|
||||
out: list[PriceChange] = []
|
||||
ts = _ts(msg)
|
||||
cond = str(msg.get("market", ""))
|
||||
for ch in msg.get("price_changes", []) or []:
|
||||
try:
|
||||
out.append(
|
||||
PriceChange(
|
||||
asset_id=str(ch["asset_id"]),
|
||||
condition_id=cond,
|
||||
side=Side(str(ch["side"]).upper()),
|
||||
price=float(ch["price"]),
|
||||
size=float(ch["size"]),
|
||||
ts=ts,
|
||||
)
|
||||
)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def parse_last_trade(msg: dict[str, Any]) -> TradePrint | None:
|
||||
try:
|
||||
return TradePrint(
|
||||
asset_id=str(msg["asset_id"]),
|
||||
condition_id=str(msg.get("market", "")),
|
||||
aggressor=Side(str(msg.get("side", "BUY")).upper()),
|
||||
price=float(msg["price"]),
|
||||
size=float(msg["size"]),
|
||||
ts=_ts(msg),
|
||||
)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_tick_size_change(msg: dict[str, Any]) -> TickSizeChange | None:
|
||||
try:
|
||||
tick = msg.get("new_tick_size", msg.get("tick_size"))
|
||||
if tick is None:
|
||||
return None
|
||||
return TickSizeChange(asset_id=str(msg["asset_id"]), tick_size=float(tick))
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,185 @@
|
||||
"""MarketDataService: owns the market WS, maintains a book per token.
|
||||
|
||||
Subscribes to every YES+NO token of the markets we quote and routes each frame
|
||||
to that token's OrderBook. We do NOT set `custom_feature_enabled` (verified to
|
||||
broaden the feed beyond our assets); resolution is detected via catalog flags.
|
||||
|
||||
On every book mutation it wakes the owning market's quoter via `on_dirty`, and
|
||||
feeds trade prints to `on_trade` for the flow estimator. Reconnects re-snapshot
|
||||
automatically because the server sends a fresh `book` on (re)subscribe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import websockets
|
||||
|
||||
from polymaker.journal import Journal
|
||||
from polymaker.logging import get_logger
|
||||
from polymaker.marketdata.orderbook import BookView, OrderBook
|
||||
from polymaker.marketdata.parse import (
|
||||
TradePrint,
|
||||
parse_book,
|
||||
parse_last_trade,
|
||||
parse_price_changes,
|
||||
parse_tick_size_change,
|
||||
)
|
||||
|
||||
log = get_logger("marketdata.service")
|
||||
|
||||
DirtyCb = Callable[[str, str], None] # (condition_id, token_id)
|
||||
TradeCb = Callable[[TradePrint], None]
|
||||
|
||||
|
||||
class MarketDataService:
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "wss://ws-subscriptions-clob.polymarket.com/ws/market",
|
||||
*,
|
||||
on_dirty: DirtyCb | None = None,
|
||||
on_trade: TradeCb | None = None,
|
||||
journal: Journal | None = None,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._on_dirty = on_dirty or (lambda _c, _t: None)
|
||||
self._on_trade = on_trade or (lambda _tp: None)
|
||||
self._journal = journal
|
||||
self._proxy = proxy
|
||||
self.books: dict[str, OrderBook] = {}
|
||||
self._token_condition: dict[str, str] = {}
|
||||
self._subs: list[str] = []
|
||||
self._ws: Any = None
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
# ── subscription management ─────────────────────────────────────────
|
||||
def set_markets(self, markets: list[tuple[str, list[str]]]) -> None:
|
||||
"""markets = [(condition_id, [token_ids...])]. Rebuilds the desired set."""
|
||||
subs: list[str] = []
|
||||
for cond, tokens in markets:
|
||||
for tok in tokens:
|
||||
self._token_condition[tok] = cond
|
||||
self.books.setdefault(tok, OrderBook())
|
||||
subs.append(tok)
|
||||
self._subs = subs
|
||||
|
||||
def view(self, token_id: str) -> BookView:
|
||||
book = self.books.get(token_id)
|
||||
return book.view() if book else _empty_view()
|
||||
|
||||
def book(self, token_id: str) -> OrderBook | None:
|
||||
return self.books.get(token_id)
|
||||
|
||||
def last_update_ts(self, token_id: str) -> float:
|
||||
b = self.books.get(token_id)
|
||||
return b.last_update_ts if b else 0.0
|
||||
|
||||
# ── run loop ────────────────────────────────────────────────────────
|
||||
async def run(self) -> None:
|
||||
backoff = 1.0
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await self._connect_and_listen()
|
||||
backoff = 1.0
|
||||
except (websockets.ConnectionClosed, OSError) as exc:
|
||||
log.warning("market_ws_dropped", err=str(exc), backoff=backoff)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("market_ws_error", err=str(exc))
|
||||
if self._stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
|
||||
async def _connect_and_listen(self) -> None:
|
||||
if not self._subs:
|
||||
await asyncio.sleep(1.0)
|
||||
return
|
||||
kwargs: dict[str, Any] = {"ping_interval": 5, "ping_timeout": None}
|
||||
if self._proxy:
|
||||
kwargs["proxy"] = self._proxy
|
||||
async with websockets.connect(self._url, **kwargs) as ws:
|
||||
self._ws = ws
|
||||
await ws.send(json.dumps({"assets_ids": self._subs, "type": "market"}))
|
||||
log.info("market_ws_subscribed", n=len(self._subs))
|
||||
async for raw in ws:
|
||||
self._handle(raw)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
# ── message handling ────────────────────────────────────────────────
|
||||
def _handle(self, raw: str | bytes) -> None:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
for msg in data if isinstance(data, list) else [data]:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
self._dispatch(msg)
|
||||
|
||||
def _dispatch(self, msg: dict[str, Any]) -> None:
|
||||
et = msg.get("event_type")
|
||||
if et == "book":
|
||||
self._on_book(msg)
|
||||
elif et == "price_change":
|
||||
self._on_price_change(msg)
|
||||
elif et == "last_trade_price":
|
||||
self._on_last_trade(msg)
|
||||
elif et == "tick_size_change":
|
||||
self._on_tick_change(msg)
|
||||
|
||||
def _on_book(self, msg: dict[str, Any]) -> None:
|
||||
upd = parse_book(msg)
|
||||
if upd is None or upd.asset_id not in self.books:
|
||||
return
|
||||
book = self.books[upd.asset_id]
|
||||
if upd.tick_size:
|
||||
book.set_tick_size(upd.tick_size)
|
||||
book.apply_snapshot(upd.bids, upd.asks, upd.ts, upd.book_hash)
|
||||
self._journal_write("book", msg, upd.ts)
|
||||
self._wake(upd.asset_id)
|
||||
|
||||
def _on_price_change(self, msg: dict[str, Any]) -> None:
|
||||
changes = parse_price_changes(msg)
|
||||
touched: set[str] = set()
|
||||
for ch in changes:
|
||||
book = self.books.get(ch.asset_id)
|
||||
if book is None:
|
||||
continue
|
||||
book.apply_delta(ch.side, ch.price, ch.size, ch.ts)
|
||||
touched.add(ch.asset_id)
|
||||
if changes:
|
||||
self._journal_write("price_change", msg, changes[0].ts)
|
||||
for tok in touched:
|
||||
self._wake(tok)
|
||||
|
||||
def _on_last_trade(self, msg: dict[str, Any]) -> None:
|
||||
tp = parse_last_trade(msg)
|
||||
if tp is None or tp.asset_id not in self.books:
|
||||
return
|
||||
self._journal_write("last_trade_price", msg, tp.ts)
|
||||
self._on_trade(tp)
|
||||
|
||||
def _on_tick_change(self, msg: dict[str, Any]) -> None:
|
||||
tc = parse_tick_size_change(msg)
|
||||
if tc and tc.asset_id in self.books:
|
||||
self.books[tc.asset_id].set_tick_size(tc.tick_size)
|
||||
log.info("tick_size_change", token=tc.asset_id[:12], tick=tc.tick_size)
|
||||
|
||||
def _wake(self, token_id: str) -> None:
|
||||
cond = self._token_condition.get(token_id)
|
||||
if cond:
|
||||
self._on_dirty(cond, token_id)
|
||||
|
||||
def _journal_write(self, kind: str, payload: dict[str, Any], ts: float) -> None:
|
||||
if self._journal is not None:
|
||||
self._journal.write(kind, payload, ts)
|
||||
|
||||
|
||||
def _empty_view() -> BookView:
|
||||
return BookView(None, 0.0, None, 0.0, None, None, 0.0, 0.0)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Native Python position merger (replaces the Node.js poly_merger subprocess).
|
||||
|
||||
When we hold both YES and NO in the same market, merging the pair returns
|
||||
collateral (1 USDC/pUSD per pair) — a maker-only exit with zero market impact.
|
||||
|
||||
Two execution paths:
|
||||
* EOA wallet (signature_type=0): direct contract call, fully implemented here.
|
||||
* Proxy/Safe wallet (signature_type 1/2): the merge tx must be routed through
|
||||
the Safe. That path is gated on the Phase-2 wallet spike (docs 03 §6) — until
|
||||
then merging is skipped (logged), and inventory is exited via limit sells
|
||||
instead. The bot is fully functional without it; merging just frees capital
|
||||
sooner.
|
||||
|
||||
The V2/pUSD collateral question (does the CTF collateral resolve to pUSD post-
|
||||
migration?) is also spike-gated; addresses are config-driven, not baked in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from polymaker.config import Config
|
||||
from polymaker.logging import get_logger
|
||||
|
||||
log = get_logger("merge")
|
||||
|
||||
# Polygon mainnet contracts (pre-V2 defaults; confirm collateral in the spike).
|
||||
CONDITIONAL_TOKENS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"
|
||||
USDC_COLLATERAL = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
_CTF_ABI = [
|
||||
{
|
||||
"name": "mergePositions",
|
||||
"type": "function",
|
||||
"stateMutability": "nonpayable",
|
||||
"inputs": [
|
||||
{"name": "collateralToken", "type": "address"},
|
||||
{"name": "parentCollectionId", "type": "bytes32"},
|
||||
{"name": "conditionId", "type": "bytes32"},
|
||||
{"name": "partition", "type": "uint256[]"},
|
||||
{"name": "amount", "type": "uint256"},
|
||||
],
|
||||
"outputs": [],
|
||||
}
|
||||
]
|
||||
_NEG_RISK_ABI = [
|
||||
{
|
||||
"name": "mergePositions",
|
||||
"type": "function",
|
||||
"stateMutability": "nonpayable",
|
||||
"inputs": [
|
||||
{"name": "conditionId", "type": "bytes32"},
|
||||
{"name": "amount", "type": "uint256"},
|
||||
],
|
||||
"outputs": [],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class Merger:
|
||||
def __init__(self, cfg: Config) -> None:
|
||||
self._cfg = cfg
|
||||
self._w3: Any = None
|
||||
self._account: Any = None
|
||||
|
||||
def _ensure_web3(self) -> None:
|
||||
if self._w3 is not None:
|
||||
return
|
||||
from eth_account import Account
|
||||
from web3 import Web3
|
||||
from web3.middleware import ExtraDataToPOAMiddleware
|
||||
|
||||
rpc = self._cfg.secrets.polygon_rpc or self._cfg.wallet.polygon_rpc
|
||||
w3 = Web3(Web3.HTTPProvider(rpc))
|
||||
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
||||
self._w3 = w3
|
||||
self._account = Account.from_key(self._cfg.secrets.pk)
|
||||
|
||||
@property
|
||||
def can_merge(self) -> bool:
|
||||
"""EOA can merge directly today; Safe/proxy is spike-gated."""
|
||||
return self._cfg.wallet.signature_type == 0
|
||||
|
||||
def merge(self, condition_id: str, amount_raw: int, neg_risk: bool) -> str | None:
|
||||
"""Merge `amount_raw` (6-dec) YES+NO pairs. Returns tx hash or None."""
|
||||
if amount_raw <= 0:
|
||||
return None
|
||||
if not self.can_merge:
|
||||
log.info(
|
||||
"merge_skipped_safe_wallet",
|
||||
condition=condition_id[:12],
|
||||
amount=amount_raw,
|
||||
note="Safe merge is spike-gated; inventory exits via limit sells",
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return self._merge_eoa(condition_id, amount_raw, neg_risk)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("merge_failed", condition=condition_id[:12], err=str(exc))
|
||||
return None
|
||||
|
||||
def _merge_eoa(self, condition_id: str, amount_raw: int, neg_risk: bool) -> str:
|
||||
self._ensure_web3()
|
||||
w3 = self._w3
|
||||
addr = self._account.address
|
||||
cond = _to_bytes32(condition_id)
|
||||
|
||||
if neg_risk:
|
||||
c = w3.eth.contract(address=w3.to_checksum_address(NEG_RISK_ADAPTER), abi=_NEG_RISK_ABI)
|
||||
fn = c.functions.mergePositions(cond, amount_raw)
|
||||
else:
|
||||
c = w3.eth.contract(address=w3.to_checksum_address(CONDITIONAL_TOKENS), abi=_CTF_ABI)
|
||||
fn = c.functions.mergePositions(
|
||||
w3.to_checksum_address(USDC_COLLATERAL),
|
||||
b"\x00" * 32, # parent collection id (top-level market)
|
||||
cond,
|
||||
[1, 2], # partition: the two outcome slots
|
||||
amount_raw,
|
||||
)
|
||||
|
||||
tx = fn.build_transaction(
|
||||
{
|
||||
"from": addr,
|
||||
"nonce": w3.eth.get_transaction_count(addr),
|
||||
"chainId": self._cfg.wallet.chain_id,
|
||||
"gas": 300_000,
|
||||
"maxFeePerGas": w3.eth.gas_price * 2,
|
||||
"maxPriorityFeePerGas": w3.to_wei(30, "gwei"),
|
||||
}
|
||||
)
|
||||
signed = self._account.sign_transaction(tx)
|
||||
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
||||
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
||||
h = str(receipt["transactionHash"].hex())
|
||||
log.info("merge_sent", condition=condition_id[:12], amount=amount_raw, tx=h[:14])
|
||||
return h
|
||||
|
||||
|
||||
def _to_bytes32(hex_str: str) -> bytes:
|
||||
s = hex_str[2:] if hex_str.startswith("0x") else hex_str
|
||||
return bytes.fromhex(s.rjust(64, "0"))
|
||||
@@ -0,0 +1,146 @@
|
||||
"""RiskManager: pre-trade gates and circuit breakers (docs 04 §6, 02).
|
||||
|
||||
Consulted by the engine before every quote set. Returns a per-market decision
|
||||
(size scale / reduce-only / halt) and owns the global kill switches. Position
|
||||
and order data come from the StateStore; fair-value marks are pushed in by the
|
||||
engine so PnL is always current.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from polymaker.config import RiskConfig
|
||||
from polymaker.domain import Fill, MarketMeta, Side
|
||||
from polymaker.logging import get_logger
|
||||
from polymaker.state.store import StateStore
|
||||
|
||||
log = get_logger("risk.manager")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RiskDecision:
|
||||
halt: bool # HALTED regime for this market
|
||||
reduce_only: bool # REDUCE_ONLY regime for this market
|
||||
size_scale: float # multiply quote sizes by this [0,1]
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class RiskManager:
|
||||
def __init__(self, cfg: RiskConfig, store: StateStore) -> None:
|
||||
self._cfg = cfg
|
||||
self._store = store
|
||||
self._marks: dict[str, float] = {} # token_id -> fair value
|
||||
self._net_cash = 0.0 # cumulative signed cash from fills (+sell, -buy)
|
||||
self._day_start_equity = 0.0
|
||||
self._killed = False
|
||||
self._order_attempts = 0
|
||||
self._order_errors = 0
|
||||
|
||||
# ── PnL bookkeeping ─────────────────────────────────────────────────
|
||||
def note_fill(self, fill: Fill) -> None:
|
||||
self._net_cash += (fill.price * fill.size) * (1 if fill.side is Side.SELL else -1)
|
||||
|
||||
def update_mark(self, token_id: str, fv: float) -> None:
|
||||
self._marks[token_id] = fv
|
||||
|
||||
def _inventory_value(self) -> float:
|
||||
total = 0.0
|
||||
for tok, pos in self._store.positions.items():
|
||||
if pos.size > 0:
|
||||
total += pos.size * self._marks.get(tok, pos.avg_price)
|
||||
return total
|
||||
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
return self._net_cash + self._inventory_value()
|
||||
|
||||
@property
|
||||
def daily_pnl(self) -> float:
|
||||
return self.equity - self._day_start_equity
|
||||
|
||||
def reset_day(self) -> None:
|
||||
self._day_start_equity = self.equity
|
||||
|
||||
# ── error-rate breaker ──────────────────────────────────────────────
|
||||
def note_order_result(self, ok: bool) -> None:
|
||||
self._order_attempts += 1
|
||||
if not ok:
|
||||
self._order_errors += 1
|
||||
|
||||
@property
|
||||
def error_rate(self) -> float:
|
||||
return self._order_errors / self._order_attempts if self._order_attempts >= 20 else 0.0
|
||||
|
||||
# ── global kill switch ──────────────────────────────────────────────
|
||||
def global_halt(self) -> tuple[bool, str]:
|
||||
if self._killed:
|
||||
return True, "manual_kill"
|
||||
if self.daily_pnl <= -self._cfg.daily_loss_kill_usdc:
|
||||
return True, f"daily_loss {self.daily_pnl:.0f}"
|
||||
if self.error_rate >= self._cfg.max_order_error_rate:
|
||||
return True, f"error_rate {self.error_rate:.2f}"
|
||||
return False, ""
|
||||
|
||||
def kill(self) -> None:
|
||||
self._killed = True
|
||||
log.critical("kill_switch_engaged")
|
||||
|
||||
# ── per-market evaluation ───────────────────────────────────────────
|
||||
def evaluate(
|
||||
self, meta: MarketMeta, *, ws_stale: bool, event_group_cost: float
|
||||
) -> RiskDecision:
|
||||
halted, why = self.global_halt()
|
||||
if halted:
|
||||
return RiskDecision(True, False, 0.0, why)
|
||||
if ws_stale:
|
||||
return RiskDecision(True, False, 0.0, "ws_stale")
|
||||
|
||||
market_notional = self._market_notional(meta)
|
||||
total_exposure = self._total_exposure()
|
||||
|
||||
# hard caps -> reduce only
|
||||
if market_notional >= self._cfg.max_market_notional_usdc:
|
||||
return RiskDecision(False, True, 1.0, "market_cap")
|
||||
if event_group_cost >= self._cfg.max_event_group_loss_usdc:
|
||||
return RiskDecision(False, True, 1.0, "event_group_cap")
|
||||
if total_exposure >= self._cfg.max_total_exposure_usdc:
|
||||
return RiskDecision(False, True, 1.0, "total_exposure_cap")
|
||||
|
||||
# soft scaling: taper size as any cap is approached (worst-binding wins)
|
||||
scale = min(
|
||||
_headroom(market_notional, self._cfg.max_market_notional_usdc),
|
||||
_headroom(total_exposure, self._cfg.max_total_exposure_usdc),
|
||||
_headroom(event_group_cost, self._cfg.max_event_group_loss_usdc),
|
||||
)
|
||||
return RiskDecision(False, False, scale, "")
|
||||
|
||||
def _market_notional(self, meta: MarketMeta) -> float:
|
||||
total = 0.0
|
||||
for tok in (meta.yes.token_id, meta.no.token_id):
|
||||
pos = self._store.position(tok)
|
||||
total += pos.size * self._marks.get(tok, pos.avg_price or 0.5)
|
||||
for o in self._store.orders_for(tok):
|
||||
if o.side is Side.BUY:
|
||||
total += o.notional
|
||||
return total
|
||||
|
||||
def _total_exposure(self) -> float:
|
||||
total = 0.0
|
||||
for tok, pos in self._store.positions.items():
|
||||
if pos.size > 0:
|
||||
total += pos.size * self._marks.get(tok, pos.avg_price or 0.5)
|
||||
for o in self._store.orders.values():
|
||||
if o.side is Side.BUY:
|
||||
total += o.notional
|
||||
return total
|
||||
|
||||
|
||||
def _headroom(current: float, cap: float) -> float:
|
||||
"""1.0 well below the cap, tapering to 0 as we approach it (from 70%)."""
|
||||
if cap <= 0:
|
||||
return 1.0
|
||||
frac = current / cap
|
||||
if frac <= 0.7:
|
||||
return 1.0
|
||||
return max(0.0, (1.0 - frac) / 0.3)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""StateStore: the single owner of positions and open orders.
|
||||
|
||||
Replaces v1's module-level global dicts + the `performing`/`last_trade_update`
|
||||
races. Three inputs, one arbitration rule (docs/scoping/02-architecture.md):
|
||||
|
||||
* WS fill events apply immediately (optimistic),
|
||||
* REST reconciliation corrects drift ONLY for tokens with no in-flight trades,
|
||||
* on-chain balances are consulted only by the merger.
|
||||
|
||||
In-memory + typed, mirrored to SQLite on change so a crash-restart resumes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from polymaker.domain import Fill, OpenOrder, OrderState, Position, Side
|
||||
from polymaker.logging import get_logger
|
||||
|
||||
log = get_logger("state.store")
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS positions (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
size REAL NOT NULL,
|
||||
avg_price REAL NOT NULL,
|
||||
updated_ts REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS fills (
|
||||
trade_id TEXT PRIMARY KEY,
|
||||
token_id TEXT, side TEXT, price REAL, size REAL, is_maker INT, ts REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS order_log (
|
||||
order_id TEXT PRIMARY KEY,
|
||||
token_id TEXT, side TEXT, price REAL, size REAL, state TEXT, ts REAL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Owns positions + open orders + a per-token in-flight guard."""
|
||||
|
||||
def __init__(self, db_path: str | Path = "state.db") -> None:
|
||||
self._conn = sqlite3.connect(str(db_path))
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
self.positions: dict[str, Position] = {}
|
||||
# order_id -> OpenOrder
|
||||
self.orders: dict[str, OpenOrder] = {}
|
||||
# token_id -> count of in-flight (MATCHED-not-CONFIRMED) trades; guards reconcile
|
||||
self._inflight: dict[str, int] = {}
|
||||
self._last_fill_ts: dict[str, float] = {}
|
||||
self._load()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
# ── positions ───────────────────────────────────────────────────────
|
||||
def position(self, token_id: str) -> Position:
|
||||
return self.positions.get(token_id, Position(token_id))
|
||||
|
||||
def apply_fill(self, fill: Fill) -> None:
|
||||
"""Apply a fill optimistically to inventory + avg price."""
|
||||
pos = self.positions.setdefault(fill.token_id, Position(fill.token_id))
|
||||
signed = fill.size if fill.side is Side.BUY else -fill.size
|
||||
new_size = pos.size + signed
|
||||
if fill.side is Side.BUY:
|
||||
if pos.size <= 0:
|
||||
pos.avg_price = fill.price
|
||||
else:
|
||||
pos.avg_price = (pos.avg_price * pos.size + fill.price * fill.size) / (
|
||||
pos.size + fill.size
|
||||
)
|
||||
# selling leaves avg_price unchanged
|
||||
pos.size = max(0.0, new_size)
|
||||
if pos.size <= 0:
|
||||
pos.avg_price = 0.0
|
||||
self._last_fill_ts[fill.token_id] = fill.ts
|
||||
self._persist_position(pos)
|
||||
self._record_fill(fill)
|
||||
log.info("fill", token=fill.token_id[:12], side=fill.side.value,
|
||||
price=fill.price, size=fill.size, pos=round(pos.size, 2))
|
||||
|
||||
def set_position(self, token_id: str, size: float, avg_price: float) -> None:
|
||||
pos = Position(token_id, max(0.0, size), avg_price if size > 0 else 0.0)
|
||||
self.positions[token_id] = pos
|
||||
self._persist_position(pos)
|
||||
|
||||
def reconcile_positions(self, api_positions: dict[str, tuple[float, float]]) -> None:
|
||||
"""Overwrite sizes from REST, skipping tokens with in-flight trades or
|
||||
a very recent fill (the optimistic value is more current there)."""
|
||||
now = time.time()
|
||||
for token_id, (size, avg) in api_positions.items():
|
||||
if self._inflight.get(token_id, 0) > 0:
|
||||
continue
|
||||
if now - self._last_fill_ts.get(token_id, 0.0) < 5.0:
|
||||
continue
|
||||
self.set_position(token_id, size, avg)
|
||||
|
||||
# ── in-flight guard ─────────────────────────────────────────────────
|
||||
def mark_inflight(self, token_id: str) -> None:
|
||||
self._inflight[token_id] = self._inflight.get(token_id, 0) + 1
|
||||
|
||||
def clear_inflight(self, token_id: str) -> None:
|
||||
if self._inflight.get(token_id, 0) > 0:
|
||||
self._inflight[token_id] -= 1
|
||||
|
||||
def inflight(self, token_id: str) -> int:
|
||||
return self._inflight.get(token_id, 0)
|
||||
|
||||
# ── orders ──────────────────────────────────────────────────────────
|
||||
def orders_for(self, token_id: str) -> list[OpenOrder]:
|
||||
return [o for o in self.orders.values() if o.token_id == token_id]
|
||||
|
||||
def upsert_order(self, order: OpenOrder) -> None:
|
||||
if order.state in (OrderState.CANCELED, OrderState.DONE, OrderState.REJECTED):
|
||||
self.orders.pop(order.order_id, None)
|
||||
else:
|
||||
self.orders[order.order_id] = order
|
||||
self._persist_order(order)
|
||||
|
||||
def remove_order(self, order_id: str) -> None:
|
||||
self.orders.pop(order_id, None)
|
||||
|
||||
def replace_open_orders(self, token_id: str, live: list[OpenOrder]) -> None:
|
||||
"""Replace our view of a token's open orders from a REST snapshot."""
|
||||
for oid in [o.order_id for o in self.orders.values() if o.token_id == token_id]:
|
||||
self.orders.pop(oid, None)
|
||||
for o in live:
|
||||
self.orders[o.order_id] = o
|
||||
|
||||
# ── persistence ─────────────────────────────────────────────────────
|
||||
def _persist_position(self, pos: Position) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO positions(token_id,size,avg_price,updated_ts) VALUES(?,?,?,?)",
|
||||
(pos.token_id, pos.size, pos.avg_price, time.time()),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def _record_fill(self, f: Fill) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO fills(trade_id,token_id,side,price,size,is_maker,ts) VALUES(?,?,?,?,?,?,?)",
|
||||
(f.trade_id, f.token_id, f.side.value, f.price, f.size, int(f.is_maker), f.ts),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def _persist_order(self, o: OpenOrder) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO order_log(order_id,token_id,side,price,size,state,ts) VALUES(?,?,?,?,?,?,?)",
|
||||
(o.order_id, o.token_id, o.side.value, o.price, o.size, o.state.value, time.time()),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def _load(self) -> None:
|
||||
for row in self._conn.execute("SELECT token_id,size,avg_price FROM positions"):
|
||||
if row["size"] > 0:
|
||||
self.positions[row["token_id"]] = Position(
|
||||
row["token_id"], row["size"], row["avg_price"]
|
||||
)
|
||||
|
||||
# ── reporting ───────────────────────────────────────────────────────
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"positions": {k: json.loads(_pos_json(v)) for k, v in self.positions.items() if v.size > 0},
|
||||
"open_orders": len(self.orders),
|
||||
}
|
||||
|
||||
|
||||
def _pos_json(p: Position) -> str:
|
||||
return json.dumps({"size": round(p.size, 4), "avg_price": round(p.avg_price, 4)})
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Order/trade lifecycle processing over the StateStore.
|
||||
|
||||
Consumes *normalized* user-stream events (the wire-format extraction lives in
|
||||
userstream/, so this is unit-testable with synthetic events) and drives the
|
||||
state machine from docs/scoping/02-architecture.md:
|
||||
|
||||
Trade: MATCHED -> MINED -> CONFIRMED
|
||||
└──────────-> FAILED (roll back the optimistic fill, reconcile)
|
||||
|
||||
Because we quote post-only, we are always the maker; `our_side` is our side of
|
||||
each match. We apply the fill optimistically at MATCHED and reverse it on FAILED.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from polymaker.domain import Fill, OpenOrder, OrderState, Side, TradeState
|
||||
from polymaker.logging import get_logger
|
||||
from polymaker.state.store import StateStore
|
||||
|
||||
log = get_logger("state.tracker")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradeEvent:
|
||||
token_id: str
|
||||
our_side: Side
|
||||
price: float
|
||||
size: float
|
||||
trade_id: str
|
||||
status: TradeState
|
||||
ts: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrderEvent:
|
||||
order_id: str
|
||||
token_id: str
|
||||
side: Side
|
||||
price: float
|
||||
remaining_size: float # original - matched
|
||||
is_cancel: bool = False
|
||||
|
||||
|
||||
class UserEventProcessor:
|
||||
"""Applies normalized trade/order events to the store."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: StateStore,
|
||||
on_change: Callable[[str], None] | None = None,
|
||||
on_fill: Callable[[Fill], None] | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._on_change = on_change or (lambda _cid: None)
|
||||
self._on_fill = on_fill or (lambda _fill: None)
|
||||
# trade_id -> applied Fill, so FAILED can reverse exactly what we applied
|
||||
self._applied: dict[str, Fill] = {}
|
||||
|
||||
def on_trade(self, ev: TradeEvent, condition_id: str) -> None:
|
||||
if ev.status is TradeState.MATCHED:
|
||||
if ev.trade_id in self._applied:
|
||||
return # idempotent: already counted this match
|
||||
fill = Fill(ev.token_id, ev.our_side, ev.price, ev.size, ev.trade_id, ev.ts, is_maker=True)
|
||||
self._store.apply_fill(fill)
|
||||
self._store.mark_inflight(ev.token_id)
|
||||
self._applied[ev.trade_id] = fill
|
||||
self._on_fill(fill)
|
||||
self._on_change(condition_id)
|
||||
|
||||
elif ev.status in (TradeState.CONFIRMED, TradeState.MINED):
|
||||
if ev.trade_id in self._applied and ev.status is TradeState.CONFIRMED:
|
||||
self._store.clear_inflight(ev.token_id)
|
||||
# keep the fill; it's now settled
|
||||
self._applied.pop(ev.trade_id, None)
|
||||
self._on_change(condition_id)
|
||||
|
||||
elif ev.status in (TradeState.FAILED, TradeState.RETRYING):
|
||||
prior = self._applied.pop(ev.trade_id, None)
|
||||
if prior is not None:
|
||||
# reverse the optimistic fill
|
||||
self._store.apply_fill(
|
||||
Fill(prior.token_id, prior.side.opposite, prior.price, prior.size,
|
||||
f"{prior.trade_id}:reverse", prior.ts, is_maker=True)
|
||||
)
|
||||
self._store.clear_inflight(ev.token_id)
|
||||
log.warning("trade_failed_reversed", trade_id=ev.trade_id, token=ev.token_id[:12])
|
||||
self._on_change(condition_id)
|
||||
|
||||
def on_order(self, ev: OrderEvent, condition_id: str) -> None:
|
||||
if ev.is_cancel or ev.remaining_size <= 0:
|
||||
self._store.remove_order(ev.order_id)
|
||||
else:
|
||||
state = OrderState.LIVE
|
||||
self._store.upsert_order(
|
||||
OpenOrder(ev.order_id, ev.token_id, ev.side, ev.price, ev.remaining_size, state)
|
||||
)
|
||||
self._on_change(condition_id)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Online estimators driven by the live stream: EWMAs of vol, flow, toxicity.
|
||||
|
||||
All are time-decayed (half-life in seconds) so they behave correctly under
|
||||
irregular event arrival — a burst of ticks and a quiet minute are weighted by
|
||||
elapsed wall-clock, not by sample count. Pure state machines: feed them
|
||||
observations with timestamps, read scalar summaries. No I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from polymaker.domain import Side
|
||||
|
||||
|
||||
class Ewma:
|
||||
"""Time-decayed exponentially weighted mean.
|
||||
|
||||
On each update the prior weight decays by 0.5 ** (dt / halflife); a fresh
|
||||
observation gets the remaining weight. The first observation seeds the mean.
|
||||
"""
|
||||
|
||||
__slots__ = ("halflife", "_value", "_last_ts", "_initialized")
|
||||
|
||||
def __init__(self, halflife_s: float) -> None:
|
||||
if halflife_s <= 0:
|
||||
raise ValueError("halflife must be positive")
|
||||
self.halflife = halflife_s
|
||||
self._value = 0.0
|
||||
self._last_ts = 0.0
|
||||
self._initialized = False
|
||||
|
||||
def update(self, value: float, ts: float) -> float:
|
||||
if not self._initialized:
|
||||
self._value = value
|
||||
self._last_ts = ts
|
||||
self._initialized = True
|
||||
return self._value
|
||||
dt = max(0.0, ts - self._last_ts)
|
||||
decay = 0.5 ** (dt / self.halflife)
|
||||
self._value = decay * self._value + (1.0 - decay) * value
|
||||
self._last_ts = ts
|
||||
return self._value
|
||||
|
||||
def decay_to(self, ts: float) -> float:
|
||||
"""Decay the stored value toward 0 as if observing 0 at `ts`.
|
||||
|
||||
Used to age out flow/vol during silence without a new observation.
|
||||
"""
|
||||
if self._initialized:
|
||||
dt = max(0.0, ts - self._last_ts)
|
||||
self._value *= 0.5 ** (dt / self.halflife)
|
||||
self._last_ts = ts
|
||||
return self._value
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self._initialized
|
||||
|
||||
|
||||
class VolEstimator:
|
||||
"""Realized volatility at two horizons from fair-value changes."""
|
||||
|
||||
__slots__ = ("_short", "_long", "_last_fv", "_last_ts")
|
||||
|
||||
def __init__(self, short_halflife_s: float, long_halflife_s: float) -> None:
|
||||
self._short = Ewma(short_halflife_s)
|
||||
self._long = Ewma(long_halflife_s)
|
||||
self._last_fv: float | None = None
|
||||
self._last_ts = 0.0
|
||||
|
||||
def update(self, fv: float, ts: float) -> None:
|
||||
if self._last_fv is not None:
|
||||
r = fv - self._last_fv
|
||||
sq = r * r
|
||||
self._short.update(sq, ts)
|
||||
self._long.update(sq, ts)
|
||||
self._last_fv = fv
|
||||
self._last_ts = ts
|
||||
|
||||
@property
|
||||
def short(self) -> float:
|
||||
return math.sqrt(max(0.0, self._short.value))
|
||||
|
||||
@property
|
||||
def long(self) -> float:
|
||||
return math.sqrt(max(0.0, self._long.value))
|
||||
|
||||
@property
|
||||
def ratio(self) -> float:
|
||||
"""short/long vol ratio; >1 means recent activity above baseline."""
|
||||
lo = self.long
|
||||
return self.short / lo if lo > 1e-9 else 1.0
|
||||
|
||||
|
||||
class FlowEstimator:
|
||||
"""Signed aggressor flow and its normalized strength (a crude z-score)."""
|
||||
|
||||
__slots__ = ("_signed", "_abs")
|
||||
|
||||
def __init__(self, halflife_s: float) -> None:
|
||||
self._signed = Ewma(halflife_s)
|
||||
self._abs = Ewma(halflife_s)
|
||||
|
||||
def update(self, aggressor: Side, size: float, ts: float) -> None:
|
||||
signed = size if aggressor is Side.BUY else -size
|
||||
self._signed.update(signed, ts)
|
||||
self._abs.update(abs(size), ts)
|
||||
|
||||
def decay_to(self, ts: float) -> None:
|
||||
self._signed.decay_to(ts)
|
||||
self._abs.decay_to(ts)
|
||||
|
||||
@property
|
||||
def signed(self) -> float:
|
||||
return self._signed.value
|
||||
|
||||
@property
|
||||
def z(self) -> float:
|
||||
"""Signed flow normalized by average trade magnitude, in ~[-1, 1]+."""
|
||||
denom = self._abs.value
|
||||
return self._signed.value / denom if denom > 1e-9 else 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PendingMarkout:
|
||||
fv_at_fill: float
|
||||
side: Side # our side of the fill (BUY => we bought => adverse if price falls)
|
||||
due_ts: float
|
||||
|
||||
|
||||
class MarkoutTracker:
|
||||
"""Measures adverse selection: how fair value moves against us after fills.
|
||||
|
||||
For each fill we remember FV-at-fill and, after a horizon, compare to the
|
||||
then-current FV. Signed so that a *positive* markout means the trade was
|
||||
good (price moved in our favor) and negative means we got picked off. The
|
||||
toxicity summary is the magnitude of recent adverse (negative) markout,
|
||||
which the quoter turns into extra spread / less size.
|
||||
"""
|
||||
|
||||
__slots__ = ("_horizon_s", "_pending", "_markout")
|
||||
|
||||
def __init__(self, horizon_s: float = 300.0, ewma_halflife_s: float = 1800.0) -> None:
|
||||
self._horizon_s = horizon_s
|
||||
self._pending: list[_PendingMarkout] = []
|
||||
self._markout = Ewma(ewma_halflife_s)
|
||||
|
||||
def record_fill(self, side: Side, fv_at_fill: float, ts: float) -> None:
|
||||
self._pending.append(_PendingMarkout(fv_at_fill, side, ts + self._horizon_s))
|
||||
|
||||
def evaluate(self, fv_now: float, ts: float) -> None:
|
||||
"""Resolve any markouts whose horizon has elapsed."""
|
||||
still: list[_PendingMarkout] = []
|
||||
for p in self._pending:
|
||||
if ts >= p.due_ts:
|
||||
move = fv_now - p.fv_at_fill
|
||||
# if we BOUGHT, a rise is good (+); if we SOLD, a fall is good (+)
|
||||
signed = move if p.side is Side.BUY else -move
|
||||
self._markout.update(signed, ts)
|
||||
else:
|
||||
still.append(p)
|
||||
self._pending = still
|
||||
|
||||
@property
|
||||
def markout(self) -> float:
|
||||
return self._markout.value
|
||||
|
||||
@property
|
||||
def toxicity(self) -> float:
|
||||
"""Non-negative adverse-selection score (0 when fills are benign)."""
|
||||
return max(0.0, -self._markout.value)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MarketEstimators:
|
||||
"""Bundle of the per-market online estimators the engine keeps."""
|
||||
|
||||
vol: VolEstimator
|
||||
flow: FlowEstimator
|
||||
markout: MarkoutTracker
|
||||
last_fv: float | None = None
|
||||
last_fv_ts: float = 0.0
|
||||
fv_history: list[tuple[float, float]] = field(default_factory=list)
|
||||
|
||||
def on_fair_value(self, fv: float, ts: float) -> None:
|
||||
self.vol.update(fv, ts)
|
||||
self.markout.evaluate(fv, ts)
|
||||
self.last_fv = fv
|
||||
self.last_fv_ts = ts
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Pure quote construction: (market state, inventory, params) -> TargetQuotes.
|
||||
|
||||
This is the deterministic core of the strategy. No I/O, no wall-clock reads
|
||||
except values passed in. Everything here is exercised directly by unit tests.
|
||||
|
||||
Model (see docs/scoping/04-strategy.md):
|
||||
reservation r = FV - skew(inventory)
|
||||
half-spread δ = base + c_vol·σ + c_tox·toxicity (clamped to reward band in QUIET)
|
||||
YES entry bid = r - δ (BUY YES, USDC-collateralized)
|
||||
NO entry bid = (1 - r) - δ (BUY NO; implied YES ask at r + δ)
|
||||
exits = SELL limits on held inventory, walked toward the touch by urgency
|
||||
|
||||
The BUY-YES + BUY-NO pair is the canonical two-sided quote: both are bids, both
|
||||
score rewards, and a filled pair merges back to USDC at locked edge 1 - p - q.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from polymaker.config import StrategyProfile
|
||||
from polymaker.domain import MarketMeta, Position, Quote, Regime, Side, TargetQuotes
|
||||
from polymaker.marketdata.orderbook import BookView
|
||||
|
||||
_EPS = 1e-9
|
||||
|
||||
|
||||
def round_to_tick(price: float, tick: float, decimals: int, *, up: bool) -> float:
|
||||
"""Snap a price to the tick grid, rounding up or down, clamped to (0,1)."""
|
||||
n = price / tick
|
||||
n = math.ceil(n - _EPS) if up else math.floor(n + _EPS)
|
||||
p = round(n * tick, decimals)
|
||||
return min(max(p, tick), 1.0 - tick)
|
||||
|
||||
|
||||
def compute_fair_value(microprice: float, flow_z: float, tick: float, weight: float = 0.5) -> float:
|
||||
"""Nudge the microprice by bounded signed flow. Clamped to (tick, 1-tick)."""
|
||||
fv = microprice + weight * flow_z * tick
|
||||
return min(max(fv, tick), 1.0 - tick)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuoteInputs:
|
||||
meta: MarketMeta
|
||||
regime: Regime
|
||||
fv: float # YES fair value in (0,1)
|
||||
vol_short: float
|
||||
toxicity: float
|
||||
yes_view: BookView
|
||||
no_view: BookView
|
||||
pos_yes: Position
|
||||
pos_no: Position
|
||||
profile: StrategyProfile
|
||||
now: float
|
||||
risk_size_scale: float = 1.0 # RiskManager may throttle size in [0,1]
|
||||
yes_exit_urgency: float = 0.0 # [0,1]; engine raises with hold time / adverse drift
|
||||
no_exit_urgency: float = 0.0
|
||||
|
||||
|
||||
def construct_quotes(inp: QuoteInputs) -> TargetQuotes:
|
||||
m = inp.meta
|
||||
p = inp.profile
|
||||
tick = m.tick_size
|
||||
dec = m.price_decimals
|
||||
cid = m.condition_id
|
||||
|
||||
if inp.regime in (Regime.EVENT, Regime.HALTED):
|
||||
return TargetQuotes(cid, inp.regime, ())
|
||||
|
||||
quotes: list[Quote] = []
|
||||
|
||||
# ── inventory in YES-equivalent shares; holding NO is short YES ──────
|
||||
net_shares = inp.pos_yes.size - inp.pos_no.size
|
||||
q_max_shares = p.q_max_usdc / max(inp.fv, tick)
|
||||
u = _clamp(net_shares / q_max_shares, -1.0, 1.0) if q_max_shares > 0 else 0.0
|
||||
|
||||
skew = p.gamma * inp.vol_short * u
|
||||
|
||||
# ── half-spread ─────────────────────────────────────────────────────
|
||||
base = p.delta_min_ticks * tick
|
||||
delta = base + p.c_vol * inp.vol_short + p.c_tox * inp.toxicity
|
||||
reward_band = m.rewards_max_spread / 100.0
|
||||
if inp.regime == Regime.QUIET and reward_band > 0:
|
||||
delta = _clamp(delta, base, max(base, reward_band))
|
||||
delta = max(delta, tick)
|
||||
|
||||
r = inp.fv - skew
|
||||
yes_bid_target = r - delta
|
||||
no_bid_target = (1.0 - r) - delta
|
||||
|
||||
# ── size scaling ────────────────────────────────────────────────────
|
||||
regime_scale = 0.5 if inp.regime == Regime.TRENDING else 1.0
|
||||
tox_scale = 1.0 / (1.0 + inp.toxicity * 10.0)
|
||||
common_scale = regime_scale * tox_scale * _clamp(inp.risk_size_scale, 0.0, 1.0)
|
||||
|
||||
soft_cap = p.q_soft_frac # fraction of q_max at which the adding side pulls
|
||||
add_yes = inp.regime not in (Regime.REDUCE_ONLY,) and u < soft_cap
|
||||
add_no = inp.regime not in (Regime.REDUCE_ONLY,) and u > -soft_cap
|
||||
|
||||
# entry: BUY YES
|
||||
if add_yes:
|
||||
price = _place_bid(yes_bid_target, inp.yes_view, tick, dec, inp.fv, p.min_edge_ticks)
|
||||
if price is not None:
|
||||
_add_layers(quotes, m.yes.token_id, Side.BUY, price, tick, dec,
|
||||
_size_shares(p.base_size_usdc, price, common_scale * (1 - max(u, 0.0)), m),
|
||||
p.layers, p.layer_step_ticks, down=True)
|
||||
|
||||
# entry: BUY NO
|
||||
if add_no:
|
||||
no_fv = 1.0 - inp.fv
|
||||
price = _place_bid(no_bid_target, inp.no_view, tick, dec, no_fv, p.min_edge_ticks)
|
||||
if price is not None:
|
||||
_add_layers(quotes, m.no.token_id, Side.BUY, price, tick, dec,
|
||||
_size_shares(p.base_size_usdc, price, common_scale * (1 - max(-u, 0.0)), m),
|
||||
p.layers, p.layer_step_ticks, down=True)
|
||||
|
||||
# ── exits: SELL held inventory (maker, never cross) ─────────────────
|
||||
_maybe_exit(quotes, m.yes.token_id, inp.pos_yes, inp.fv, delta, inp.yes_view, tick, dec,
|
||||
inp.yes_exit_urgency, m, inp.regime)
|
||||
_maybe_exit(quotes, m.no.token_id, inp.pos_no, 1.0 - inp.fv, delta, inp.no_view, tick, dec,
|
||||
inp.no_exit_urgency, m, inp.regime)
|
||||
|
||||
return TargetQuotes(cid, inp.regime, tuple(quotes))
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float, hi: float) -> float:
|
||||
return min(max(x, lo), hi)
|
||||
|
||||
|
||||
def _place_bid(
|
||||
target: float, view: BookView, tick: float, dec: int, fv: float, min_edge_ticks: int
|
||||
) -> float | None:
|
||||
"""Position a BUY: join the touch or sit behind, never cross, keep min edge vs FV."""
|
||||
price = target
|
||||
# never bid above (FV - min_edge*tick): we don't pay through fair value
|
||||
price = min(price, fv - min_edge_ticks * tick)
|
||||
# join the queue rather than jump it (conservative maker default)
|
||||
if view.best_bid is not None and price >= view.best_bid:
|
||||
price = view.best_bid
|
||||
# never cross the ask
|
||||
if view.best_ask is not None and price >= view.best_ask:
|
||||
price = view.best_ask - tick
|
||||
p = round_to_tick(price, tick, dec, up=False)
|
||||
if p <= 0 or p >= 1:
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def _size_shares(base_usdc: float, price: float, scale: float, m: MarketMeta) -> float:
|
||||
"""USDC-notional sizing -> shares, honoring exchange & reward minimums."""
|
||||
shares = (base_usdc / max(price, m.tick_size)) * max(scale, 0.0)
|
||||
if shares <= 0:
|
||||
return 0.0
|
||||
floor = max(m.min_order_size, m.rewards_min_size)
|
||||
# round up small-but-real sizes to the reward min so they actually score
|
||||
if 0.5 * floor <= shares < floor:
|
||||
shares = floor
|
||||
return round(shares, 2) if shares >= m.min_order_size else 0.0
|
||||
|
||||
|
||||
def _add_layers(
|
||||
quotes: list[Quote], token_id: str, side: Side, top_price: float, tick: float, dec: int,
|
||||
total_size: float, layers: int, step_ticks: int, *, down: bool,
|
||||
) -> None:
|
||||
"""Split size across `layers` price levels stepping away from the touch."""
|
||||
if total_size <= 0:
|
||||
return
|
||||
layers = max(1, layers)
|
||||
per = round(total_size / layers, 2)
|
||||
if per <= 0:
|
||||
per = total_size
|
||||
layers = 1
|
||||
for i in range(layers):
|
||||
offset = i * step_ticks * tick
|
||||
price = top_price - offset if down else top_price + offset
|
||||
price = round(price, dec)
|
||||
if 0 < price < 1 and per > 0:
|
||||
quotes.append(Quote(token_id, side, price, per))
|
||||
|
||||
|
||||
def _maybe_exit(
|
||||
quotes: list[Quote], token_id: str, pos: Position, token_fv: float, delta: float,
|
||||
view: BookView, tick: float, dec: int, urgency: float, m: MarketMeta, regime: Regime,
|
||||
) -> None:
|
||||
if pos.size < m.min_order_size:
|
||||
return
|
||||
# target starts at fv + delta and walks toward best_bid + tick as urgency -> 1
|
||||
passive = token_fv + delta
|
||||
floor = (view.best_bid + tick) if view.best_bid is not None else passive
|
||||
if regime == Regime.REDUCE_ONLY:
|
||||
urgency = max(urgency, 0.5)
|
||||
target = passive * (1.0 - urgency) + floor * urgency
|
||||
# never cross down through the bid; never sell below best_bid
|
||||
if view.best_bid is not None:
|
||||
target = max(target, view.best_bid + tick)
|
||||
price = round_to_tick(target, tick, dec, up=True)
|
||||
size = round(pos.size, 2)
|
||||
if 0 < price < 1 and size >= m.min_order_size:
|
||||
quotes.append(Quote(token_id, Side.SELL, price, size))
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Per-market regime decision (see docs/scoping/04-strategy.md §5).
|
||||
|
||||
Priority order, highest first:
|
||||
HALTED kill switch / stale data / resolved / past halt-before window
|
||||
EVENT active cooloff, or a fresh sweep / fair-value jump
|
||||
REDUCE_ONLY inventory at hard cap, or inside the reduce-only end-date window
|
||||
TRENDING persistent one-sided flow or elevated short/long vol
|
||||
QUIET default farming posture
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from polymaker.config import StrategyProfile
|
||||
from polymaker.domain import Regime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegimeInputs:
|
||||
now: float
|
||||
tick: float
|
||||
fv: float
|
||||
prev_fv: float | None
|
||||
vol_ratio: float
|
||||
flow_z: float
|
||||
inventory_util: float # |net notional| / q_max, >=0
|
||||
hours_to_end: float | None
|
||||
sweep_flagged: bool = False
|
||||
market_resolved: bool = False
|
||||
ws_stale: bool = False
|
||||
risk_halt: bool = False
|
||||
risk_reduce_only: bool = False
|
||||
|
||||
|
||||
class RegimeMachine:
|
||||
"""Stateful regime decider for one market (tracks the EVENT cooloff)."""
|
||||
|
||||
__slots__ = ("_event_until",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._event_until: float = 0.0
|
||||
|
||||
def decide(self, inp: RegimeInputs, p: StrategyProfile) -> Regime:
|
||||
# 1. hard halts
|
||||
if inp.risk_halt or inp.ws_stale or inp.market_resolved:
|
||||
return Regime.HALTED
|
||||
if inp.hours_to_end is not None and inp.hours_to_end <= p.halt_before_hours:
|
||||
return Regime.HALTED
|
||||
|
||||
# 2. events (sweep / jump / active cooloff)
|
||||
jump_ticks = abs(inp.fv - inp.prev_fv) / inp.tick if inp.prev_fv is not None else 0.0
|
||||
if inp.sweep_flagged or jump_ticks >= p.event_jump_ticks:
|
||||
self._event_until = inp.now + p.event_cooloff_s
|
||||
return Regime.EVENT
|
||||
if inp.now < self._event_until:
|
||||
return Regime.EVENT
|
||||
|
||||
# 3. reduce-only
|
||||
if inp.risk_reduce_only or inp.inventory_util >= 1.0:
|
||||
return Regime.REDUCE_ONLY
|
||||
if inp.hours_to_end is not None and inp.hours_to_end <= p.reduce_only_hours:
|
||||
return Regime.REDUCE_ONLY
|
||||
|
||||
# 4. trending
|
||||
if abs(inp.flow_z) >= p.trend_flow_z or inp.vol_ratio >= 2.0:
|
||||
return Regime.TRENDING
|
||||
|
||||
# 5. default
|
||||
return Regime.QUIET
|
||||
|
||||
@property
|
||||
def in_cooloff(self) -> bool:
|
||||
return self._event_until > 0.0
|
||||
@@ -0,0 +1,126 @@
|
||||
"""UserStream: authenticated user WS for our order/trade lifecycle events.
|
||||
|
||||
Subscribes with L2 creds and the condition_ids we trade; routes fills and order
|
||||
updates into the StateStore via the UserEventProcessor. Reconnects with backoff.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import websockets
|
||||
|
||||
from polymaker.journal import Journal
|
||||
from polymaker.logging import get_logger
|
||||
from polymaker.state.tracker import UserEventProcessor
|
||||
from polymaker.userstream.parse import normalize_order, normalize_trade
|
||||
|
||||
log = get_logger("userstream.client")
|
||||
|
||||
|
||||
class UserStream:
|
||||
def __init__(
|
||||
self,
|
||||
creds: Any,
|
||||
our_address: str,
|
||||
processor: UserEventProcessor,
|
||||
*,
|
||||
other_token: Callable[[str], str | None],
|
||||
condition_of_token: Callable[[str], str | None],
|
||||
url: str = "wss://ws-subscriptions-clob.polymarket.com/ws/user",
|
||||
journal: Journal | None = None,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
self._creds = creds
|
||||
self._address = our_address
|
||||
self._proc = processor
|
||||
self._other_token = other_token
|
||||
self._condition_of_token = condition_of_token
|
||||
self._url = url
|
||||
self._journal = journal
|
||||
self._proxy = proxy
|
||||
self._markets: list[str] = []
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
def set_markets(self, condition_ids: list[str]) -> None:
|
||||
self._markets = condition_ids
|
||||
|
||||
async def run(self) -> None:
|
||||
backoff = 1.0
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await self._connect_and_listen()
|
||||
backoff = 1.0
|
||||
except (websockets.ConnectionClosed, OSError) as exc:
|
||||
log.warning("user_ws_dropped", err=str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("user_ws_error", err=str(exc))
|
||||
if self._stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
|
||||
async def _connect_and_listen(self) -> None:
|
||||
sub = {
|
||||
"type": "user",
|
||||
"auth": {
|
||||
"apiKey": self._creds.api_key,
|
||||
"secret": self._creds.api_secret,
|
||||
"passphrase": self._creds.api_passphrase,
|
||||
},
|
||||
"markets": self._markets,
|
||||
}
|
||||
kwargs: dict[str, Any] = {"ping_interval": 5, "ping_timeout": None}
|
||||
if self._proxy:
|
||||
kwargs["proxy"] = self._proxy
|
||||
async with websockets.connect(self._url, **kwargs) as ws:
|
||||
await ws.send(json.dumps(sub))
|
||||
log.info("user_ws_subscribed", markets=len(self._markets))
|
||||
async for raw in ws:
|
||||
self._handle(raw)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
def _handle(self, raw: str | bytes) -> None:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
for msg in data if isinstance(data, list) else [data]:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
et = msg.get("event_type")
|
||||
if et == "trade":
|
||||
self._on_trade(msg)
|
||||
elif et == "order":
|
||||
self._on_order(msg)
|
||||
|
||||
def _on_trade(self, msg: dict[str, Any]) -> None:
|
||||
self._journal_write("user_trade", msg)
|
||||
for ev in normalize_trade(msg, self._address, self._other_token):
|
||||
cond = self._condition_of_token(ev.token_id) or str(msg.get("market", ""))
|
||||
self._proc.on_trade(ev, cond)
|
||||
|
||||
def _on_order(self, msg: dict[str, Any]) -> None:
|
||||
self._journal_write("user_order", msg)
|
||||
ev = normalize_order(msg)
|
||||
if ev is not None:
|
||||
cond = self._condition_of_token(ev.token_id) or str(msg.get("market", ""))
|
||||
self._proc.on_order(ev, cond)
|
||||
|
||||
def _journal_write(self, kind: str, payload: dict[str, Any]) -> None:
|
||||
if self._journal is not None:
|
||||
self._journal.write(kind, payload, _ts(payload))
|
||||
|
||||
|
||||
def _ts(msg: dict[str, Any]) -> float:
|
||||
raw = msg.get("timestamp")
|
||||
try:
|
||||
v = float(raw) # type: ignore[arg-type]
|
||||
return v / 1000.0 if v > 1e12 else v
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Pure parsers for user-WS frames -> normalized trade/order events.
|
||||
|
||||
Fills come from `trade` events; open-order tracking from `order` events. The
|
||||
maker/taker/mint side logic mirrors v1's proven handler (post-only means we are
|
||||
always the maker):
|
||||
|
||||
* maker & taker on the SAME outcome -> we SELL the taker's asset (reverse side)
|
||||
* maker & taker on DIFFERENT outcomes -> a mint: we BUY the opposite token
|
||||
|
||||
NOTE: exact field names must be reconfirmed in the Phase-2 wallet spike
|
||||
(docs/scoping/03-api-layer.md §9); this is coded to the v1-observed shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from polymaker.domain import Side, TradeState
|
||||
from polymaker.state.tracker import OrderEvent, TradeEvent
|
||||
|
||||
_STATUS = {
|
||||
"MATCHED": TradeState.MATCHED,
|
||||
"MINED": TradeState.MINED,
|
||||
"CONFIRMED": TradeState.CONFIRMED,
|
||||
"RETRYING": TradeState.RETRYING,
|
||||
"FAILED": TradeState.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def _ts(msg: dict[str, Any]) -> float:
|
||||
raw = msg.get("timestamp")
|
||||
try:
|
||||
v = float(raw) # type: ignore[arg-type]
|
||||
return v / 1000.0 if v > 1e12 else v
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def normalize_trade(
|
||||
msg: dict[str, Any],
|
||||
our_address: str,
|
||||
other_token: Callable[[str], str | None],
|
||||
) -> list[TradeEvent]:
|
||||
"""Extract our maker fills from a `trade` event. Returns one TradeEvent per
|
||||
matching maker order (usually one)."""
|
||||
status = _STATUS.get(str(msg.get("status", "")).upper())
|
||||
if status is None:
|
||||
return []
|
||||
taker_asset = str(msg.get("asset_id", ""))
|
||||
taker_side = _side(msg.get("side"))
|
||||
taker_outcome = msg.get("outcome")
|
||||
ts = _ts(msg)
|
||||
trade_id = str(msg.get("id", ""))
|
||||
addr = our_address.lower()
|
||||
|
||||
out: list[TradeEvent] = []
|
||||
for i, mo in enumerate(msg.get("maker_orders", []) or []):
|
||||
if str(mo.get("maker_address", "")).lower() != addr:
|
||||
continue
|
||||
try:
|
||||
size = float(mo.get("matched_amount", 0))
|
||||
price = float(mo.get("price", 0))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if size <= 0:
|
||||
continue
|
||||
if mo.get("outcome") == taker_outcome:
|
||||
token = taker_asset
|
||||
our_side = taker_side.opposite
|
||||
else:
|
||||
token = other_token(taker_asset) or taker_asset
|
||||
our_side = taker_side
|
||||
out.append(
|
||||
TradeEvent(
|
||||
token_id=token,
|
||||
our_side=our_side,
|
||||
price=price,
|
||||
size=size,
|
||||
trade_id=f"{trade_id}:{i}" if len(msg.get('maker_orders', [])) > 1 else trade_id,
|
||||
status=status,
|
||||
ts=ts,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_order(msg: dict[str, Any]) -> OrderEvent | None:
|
||||
"""Map an `order` event to remaining-size tracking for the reconciler."""
|
||||
try:
|
||||
asset = str(msg["asset_id"])
|
||||
side = _side(msg.get("side"))
|
||||
original = float(msg.get("original_size", msg.get("size", 0)))
|
||||
matched = float(msg.get("size_matched", 0))
|
||||
remaining = original - matched
|
||||
status = str(msg.get("status", "")).upper()
|
||||
is_cancel = status in ("CANCELED", "CANCELLED") or msg.get("type") == "CANCELLATION"
|
||||
return OrderEvent(
|
||||
order_id=str(msg.get("id", "")),
|
||||
token_id=asset,
|
||||
side=side,
|
||||
price=float(msg.get("price", 0)),
|
||||
remaining_size=remaining,
|
||||
is_cancel=is_cancel,
|
||||
)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _side(value: object) -> Side:
|
||||
return Side.SELL if str(value).upper() == "SELL" else Side.BUY
|
||||
Reference in New Issue
Block a user