Getting there

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