Getting there

This commit is contained in:
Daniel Sapkota
2026-07-05 17:50:04 -04:00
parent bfacef317b
commit c7b686116e
92 changed files with 8062 additions and 7964 deletions
View File
+49
View File
@@ -0,0 +1,49 @@
"""Shared test fixtures."""
from __future__ import annotations
import pytest
from polymaker.config import StrategyProfile
from polymaker.domain import MarketMeta, TokenMeta
from polymaker.marketdata.orderbook import BookView
@pytest.fixture
def meta() -> MarketMeta:
return MarketMeta(
condition_id="0xcond",
question="Will X happen?",
slug="will-x-happen",
tokens=(TokenMeta("yes-token", "Yes"), TokenMeta("no-token", "No")),
tick_size=0.01,
neg_risk=False,
min_order_size=5.0,
rewards_min_size=10.0,
rewards_max_spread=3.0, # 3 cents
rewards_daily_rate=50.0,
maker_fee_bps=0,
taker_fee_bps=100,
fees_enabled=True,
end_date_iso="2028-11-07T00:00:00Z",
event_id="evt-1",
)
@pytest.fixture
def profile() -> StrategyProfile:
return StrategyProfile() # defaults
def view(bb: float | None, ba: float | None, bb_sz: float = 500, ba_sz: float = 500) -> BookView:
"""Construct a BookView for a token with a symmetric deep book."""
return BookView(
best_bid=bb,
best_bid_size=bb_sz,
best_ask=ba,
best_ask_size=ba_sz,
second_bid=(bb - 0.01) if bb else None,
second_ask=(ba + 0.01) if ba else None,
bid_depth=bb_sz,
ask_depth=ba_sz,
)
+91
View File
@@ -0,0 +1,91 @@
"""Tests for market parsing, scoring, and the SQLite catalog store."""
from __future__ import annotations
import json
from polymaker.catalog.gamma import parse_market
from polymaker.catalog.scoring import score_market
from polymaker.catalog.store import CatalogStore
RAW = {
"conditionId": "0xabc",
"question": "Will candidate X win?",
"slug": "will-x-win",
"clobTokenIds": json.dumps(["tok-yes", "tok-no"]),
"outcomes": json.dumps(["Yes", "No"]),
"orderPriceMinTickSize": 0.01,
"orderMinSize": 5,
"negRisk": True,
"acceptingOrders": True,
"rewardsMinSize": 10,
"rewardsMaxSpread": 3.0,
"feesEnabled": True,
"feeSchedule": {"rate": 0.01, "takerOnly": True, "rebateRate": 0.25},
"bestBid": 0.48,
"bestAsk": 0.50,
"liquidityNum": 20000.0,
"volumeNum": 500000.0,
"endDate": "2028-11-07T00:00:00Z",
"events": [{"id": 999, "slug": "2028-election"}],
}
def test_parse_market_maps_fields():
m = parse_market(RAW, reward_rates={"0xabc": 42.0})
assert m is not None
assert m.condition_id == "0xabc"
assert m.yes.token_id == "tok-yes"
assert m.no.token_id == "tok-no"
assert m.tick_size == 0.01
assert m.neg_risk is True
assert m.rewards_daily_rate == 42.0
assert m.taker_fee_bps == 100 # 0.01 -> 100 bps
assert m.maker_fee_bps == 0 # V2 makers pay zero
assert m.rebate_rate == 0.25
assert m.event_id == "999"
def test_parse_market_rejects_non_binary_and_closed():
triple = {**RAW, "clobTokenIds": json.dumps(["a", "b", "c"]),
"outcomes": json.dumps(["A", "B", "C"])}
assert parse_market(triple) is None
not_accepting = {**RAW, "acceptingOrders": False}
assert parse_market(not_accepting) is None
def test_score_prefers_rewards_and_rebates():
good = parse_market(RAW, {"0xabc": 100.0})
poor = parse_market({**RAW, "conditionId": "0xdef", "rewardsMinSize": 0,
"rewardsMaxSpread": 0, "feesEnabled": False},
{"0xdef": 0.0})
assert score_market(good).score > score_market(poor).score
def test_score_penalizes_extremity():
balanced = parse_market(RAW, {"0xabc": 50.0})
extreme = parse_market({**RAW, "conditionId": "0xext", "bestBid": 0.96, "bestAsk": 0.98},
{"0xext": 50.0})
assert score_market(extreme).extremity > score_market(balanced).extremity
def test_store_roundtrip_and_top(tmp_path):
store = CatalogStore(tmp_path / "s.db")
m = parse_market(RAW, {"0xabc": 42.0})
store.upsert_market(m)
assert store.get("0xabc").condition_id == "0xabc"
assert store.get_by_slug("will-x-win").slug == "will-x-win"
top = store.top(10)
assert len(top) == 1 and top[0][0].condition_id == "0xabc"
# tokens survive the JSON round-trip as a 2-tuple
assert len(store.get("0xabc").tokens) == 2
store.close()
def test_store_upsert_is_idempotent(tmp_path):
store = CatalogStore(tmp_path / "s.db")
m = parse_market(RAW, {"0xabc": 42.0})
store.upsert_market(m)
store.upsert_market(m) # second time updates, not duplicates
assert len(store.top(10)) == 1
store.close()
+76
View File
@@ -0,0 +1,76 @@
"""Integration test: one full engine recompute cycle in paper mode (no network)."""
from __future__ import annotations
import asyncio
import time
from polymaker.config import Config, PathsConfig, StrategyProfile
from polymaker.domain import Side
from polymaker.engine import Engine
from polymaker.strategy.regime import RegimeMachine
def _engine_with_market(tmp_path, meta) -> Engine:
cfg = Config(paths=PathsConfig(db=str(tmp_path / "state.db"),
journal_dir=str(tmp_path / "j"),
log_dir=str(tmp_path / "l")))
cfg.engine.journal = False
eng = Engine(cfg, paper=True)
cid = meta.condition_id
# inject one market directly, bypassing network resolution
eng.metas[cid] = meta
eng.profiles[cid] = StrategyProfile()
eng.est[cid] = Engine._make_estimators(eng.profiles[cid])
eng.regime_m[cid] = RegimeMachine()
eng._dirty[cid] = asyncio.Event()
for tok in (meta.yes.token_id, meta.no.token_id):
eng._token_cid[tok] = cid
eng.md.set_markets([(cid, [meta.yes.token_id, meta.no.token_id])])
eng._running = True
return eng
def _feed_book(eng, meta):
now = time.time() # fresh ts so the ws_stale guard doesn't HALT the market
yb = eng.md.book(meta.yes.token_id)
yb.apply_snapshot(bids=[(0.48, 500), (0.49, 500)], asks=[(0.51, 500), (0.52, 500)], ts=now)
nb = eng.md.book(meta.no.token_id)
nb.apply_snapshot(bids=[(0.48, 500), (0.49, 500)], asks=[(0.51, 500), (0.52, 500)], ts=now)
async def test_recompute_places_two_sided_paper_quotes(tmp_path, meta):
eng = _engine_with_market(tmp_path, meta)
_feed_book(eng, meta)
await eng._recompute(meta.condition_id)
yes_orders = eng.state.orders_for(meta.yes.token_id)
no_orders = eng.state.orders_for(meta.no.token_id)
assert yes_orders, "no YES quotes placed"
assert no_orders, "no NO quotes placed"
# entry quotes are BUYs on both tokens (the canonical two-sided quote)
assert all(o.side is Side.BUY for o in yes_orders)
assert all(o.side is Side.BUY for o in no_orders)
eng.state.close()
eng.catalog.close()
async def test_recompute_is_idempotent_within_tolerance(tmp_path, meta):
eng = _engine_with_market(tmp_path, meta)
_feed_book(eng, meta)
await eng._recompute(meta.condition_id)
n_after_first = len(eng.state.orders)
# same book -> reconcile should be a no-op, order count unchanged
await eng._recompute(meta.condition_id)
assert len(eng.state.orders) == n_after_first
eng.state.close()
eng.catalog.close()
async def test_recompute_skips_when_book_empty(tmp_path, meta):
eng = _engine_with_market(tmp_path, meta)
# no book fed
await eng._recompute(meta.condition_id)
assert len(eng.state.orders) == 0
eng.state.close()
eng.catalog.close()
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for the online estimators (vol, flow, markout/toxicity)."""
from __future__ import annotations
import pytest
from polymaker.domain import Side
from polymaker.strategy.estimators import (
Ewma,
FlowEstimator,
MarkoutTracker,
VolEstimator,
)
def test_ewma_seeds_then_decays():
e = Ewma(halflife_s=10.0)
assert not e.ready
e.update(1.0, ts=0.0)
assert e.value == 1.0
# after exactly one half-life, a 0 observation pulls the mean halfway
e.update(0.0, ts=10.0)
assert e.value == pytest.approx(0.5, abs=1e-9)
def test_ewma_decay_to_ages_value():
e = Ewma(halflife_s=10.0)
e.update(1.0, ts=0.0)
e.decay_to(ts=10.0) # one half-life of silence
assert e.value == pytest.approx(0.5, abs=1e-9)
def test_vol_estimator_rises_with_movement():
v = VolEstimator(short_halflife_s=5.0, long_halflife_s=100.0)
# quiet: tiny moves
fv, t = 0.5, 0.0
for _ in range(20):
t += 1.0
v.update(fv, t) # no change -> zero vol
assert v.short == pytest.approx(0.0, abs=1e-6)
# sudden jumps -> short vol jumps, ratio > 1
for step in (0.05, -0.04, 0.06):
t += 1.0
fv += step
v.update(fv, t)
assert v.short > 0.01
assert v.ratio > 1.0
def test_flow_estimator_sign_and_z():
f = FlowEstimator(halflife_s=10.0)
t = 0.0
for _ in range(5):
t += 1.0
f.update(Side.BUY, 100, t) # persistent buying
assert f.signed > 0
assert f.z > 0.5 # strongly one-sided
# now heavy selling flips the sign over time
for _ in range(10):
t += 1.0
f.update(Side.SELL, 200, t)
assert f.signed < 0
assert f.z < 0
def test_markout_toxicity_from_adverse_fills():
mt = MarkoutTracker(horizon_s=30.0, ewma_halflife_s=100.0)
# we BUY at fv=0.50; price then falls to 0.45 after the horizon -> adverse
mt.record_fill(Side.BUY, fv_at_fill=0.50, ts=0.0)
mt.evaluate(fv_now=0.50, ts=10.0) # before horizon: nothing resolves
assert mt.markout == 0.0
mt.evaluate(fv_now=0.45, ts=31.0) # after horizon: -0.05 markout
assert mt.markout < 0
assert mt.toxicity > 0
def test_markout_benign_fills_are_not_toxic():
mt = MarkoutTracker(horizon_s=30.0, ewma_halflife_s=100.0)
# we BUY at 0.50; price rises to 0.55 -> favorable, not toxic
mt.record_fill(Side.BUY, fv_at_fill=0.50, ts=0.0)
mt.evaluate(fv_now=0.55, ts=31.0)
assert mt.markout > 0
assert mt.toxicity == 0.0
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the rate budgeter and the paper-mode gateway."""
from __future__ import annotations
import asyncio
import time
import pytest
from polymaker.config import Config
from polymaker.domain import Quote, Side
from polymaker.execution.gateway import ExecutionGateway, _tick_str
from polymaker.execution.ratelimit import TokenBucket
def test_tick_str_formats():
assert _tick_str(0.01) == "0.01"
assert _tick_str(0.001) == "0.001"
assert _tick_str(0.0025) == "0.0025"
assert _tick_str(0.1) == "0.1"
async def test_token_bucket_limits_rate():
bucket = TokenBucket(rate_per_s=100.0, burst=5.0)
start = time.monotonic()
# burst of 5 is instant; the next 5 must wait ~ (5/100)s = 50ms
for _ in range(10):
await bucket.acquire(1)
elapsed = time.monotonic() - start
assert elapsed >= 0.04 # had to wait for refill
async def test_token_bucket_pressure_rises_when_drained():
bucket = TokenBucket(rate_per_s=10.0, burst=10.0)
assert bucket.pressure == pytest.approx(0.0, abs=0.01)
for _ in range(10):
await bucket.acquire(1)
assert bucket.pressure > 0.8
async def test_paper_gateway_places_and_cancels_without_wallet(meta):
cfg = Config() # defaults, no secrets
gw = ExecutionGateway(cfg, paper=True)
quotes = [
Quote(meta.yes.token_id, Side.BUY, 0.49, 100),
Quote(meta.no.token_id, Side.BUY, 0.48, 100),
]
placed = await gw.place(quotes, meta)
assert len(placed) == 2
assert all(o.order_id.startswith("paper-") for o in placed)
# cancel is a no-op in paper mode but must not raise
await gw.cancel([o.order_id for o in placed])
assert await gw.open_orders() == []
async def test_paper_gateway_heartbeat_and_cancel_all_noop():
gw = ExecutionGateway(Config(), paper=True)
await gw.heartbeat("hb1")
await gw.cancel_all() # no client, must not raise
def test_gateway_requires_wallet_for_live_connect():
from polymaker.config import Secrets
# explicitly-empty secrets (don't read a real .env that may exist on disk)
cfg = Config(secrets=Secrets(_env_file=None))
gw = ExecutionGateway(cfg, paper=False)
with pytest.raises(RuntimeError, match="no wallet"):
asyncio.run(gw.connect())
+74
View File
@@ -0,0 +1,74 @@
"""Live integration test for the market WS (network-gated).
Run explicitly with: POLYMAKER_LIVE=1 uv run pytest tests/test_live_marketdata.py
Skipped by default so the unit suite stays offline and fast.
"""
from __future__ import annotations
import asyncio
import json
import os
import httpx
import pytest
import websockets
pytestmark = pytest.mark.skipif(
os.environ.get("POLYMAKER_LIVE") != "1", reason="live test; set POLYMAKER_LIVE=1"
)
async def _top_political_tokens() -> tuple[str, list[str]]:
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(
"https://gamma-api.polymarket.com/markets",
params={"limit": 1, "closed": "false", "tag_id": 2,
"order": "volume24hr", "ascending": "false"},
)
m = r.json()[0]
return m["conditionId"], json.loads(m["clobTokenIds"])
async def test_market_service_builds_a_live_book():
from polymaker.marketdata.service import MarketDataService
cond, tokens = await _top_political_tokens()
woken: list[tuple[str, str]] = []
svc = MarketDataService(on_dirty=lambda c, t: woken.append((c, t)))
svc.set_markets([(cond, tokens)])
task = asyncio.create_task(svc.run())
try:
# wait until at least one token has a two-sided book
for _ in range(60):
await asyncio.sleep(0.5)
if any(not svc.book(t).is_empty for t in tokens):
break
finally:
svc.stop()
task.cancel()
assert woken, "quoter was never woken by a book event"
live = [t for t in tokens if not svc.book(t).is_empty]
assert live, "no book was populated from the live feed"
book = svc.book(live[0])
assert book.best_bid() is not None and book.best_ask() is not None
assert book.best_bid().price < book.best_ask().price
async def test_live_market_ws_raw_frames():
"""Sanity check the wire format our parser targets hasn't drifted."""
_, tokens = await _top_political_tokens()
uri = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
async with websockets.connect(uri, ping_interval=5, ping_timeout=None) as ws:
await ws.send(json.dumps({"assets_ids": tokens, "type": "market"}))
got_book = False
for _ in range(10):
raw = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(raw)
for msg in data if isinstance(data, list) else [data]:
if msg.get("event_type") == "book":
assert {"asset_id", "bids", "asks", "hash"} <= set(msg)
got_book = True
assert got_book
+110
View File
@@ -0,0 +1,110 @@
"""Tests for market-WS parsing and the book service routing."""
from __future__ import annotations
from polymaker.domain import Side
from polymaker.marketdata.parse import (
parse_book,
parse_last_trade,
parse_price_changes,
parse_tick_size_change,
)
from polymaker.marketdata.service import MarketDataService
# frames modeled on live captures (2026-07-05)
BOOK = {
"event_type": "book",
"market": "0xcond",
"asset_id": "yes-tok",
"timestamp": "1783270000000",
"hash": "abc123",
"tick_size": "0.01",
"bids": [{"price": "0.48", "size": "100"}, {"price": "0.49", "size": "200"}],
"asks": [{"price": "0.52", "size": "150"}, {"price": "0.51", "size": "80"}],
}
PRICE_CHANGE = {
"event_type": "price_change",
"market": "0xcond",
"timestamp": "1783270001000",
"price_changes": [
{"asset_id": "yes-tok", "price": "0.49", "size": "0", "side": "BUY", "hash": "h"},
{"asset_id": "yes-tok", "price": "0.50", "size": "300", "side": "BUY", "hash": "h"},
],
}
LAST_TRADE = {
"event_type": "last_trade_price",
"market": "0xcond",
"asset_id": "yes-tok",
"price": "0.50",
"size": "42",
"side": "BUY",
"timestamp": "1783270002000",
}
def test_parse_book_converts_ms_and_levels():
upd = parse_book(BOOK)
assert upd is not None
assert upd.asset_id == "yes-tok"
assert upd.condition_id == "0xcond"
assert (0.49, 200) in upd.bids
assert upd.ts == 1783270000.0 # ms -> s
assert upd.tick_size == 0.01
def test_parse_price_changes():
changes = parse_price_changes(PRICE_CHANGE)
assert len(changes) == 2
assert changes[0].side is Side.BUY
assert changes[1].price == 0.50 and changes[1].size == 300
def test_parse_last_trade_aggressor():
tp = parse_last_trade(LAST_TRADE)
assert tp is not None
assert tp.aggressor is Side.BUY
assert tp.size == 42
def test_parse_tick_size_change():
tc = parse_tick_size_change(
{"event_type": "tick_size_change", "asset_id": "yes-tok", "new_tick_size": "0.001"}
)
assert tc is not None and tc.tick_size == 0.001
def test_service_routes_book_and_wakes_quoter():
woken: list[tuple[str, str]] = []
svc = MarketDataService(on_dirty=lambda c, t: woken.append((c, t)))
svc.set_markets([("0xcond", ["yes-tok", "no-tok"])])
svc._dispatch(BOOK)
book = svc.book("yes-tok")
assert book is not None
assert book.best_bid().price == 0.49
assert book.best_ask().price == 0.51
assert woken == [("0xcond", "yes-tok")]
def test_service_applies_price_change_delta():
svc = MarketDataService()
svc.set_markets([("0xcond", ["yes-tok"])])
svc._dispatch(BOOK)
svc._dispatch(PRICE_CHANGE) # removes 0.49 bid, adds 0.50 bid
book = svc.book("yes-tok")
assert book.best_bid().price == 0.50
assert 0.49 not in book.bids
def test_service_ignores_unsubscribed_asset():
svc = MarketDataService()
svc.set_markets([("0xcond", ["yes-tok"])])
svc._dispatch({**BOOK, "asset_id": "stranger"})
assert svc.book("stranger") is None
def test_service_forwards_trades_for_flow():
trades = []
svc = MarketDataService(on_trade=trades.append)
svc.set_markets([("0xcond", ["yes-tok"])])
svc._dispatch(LAST_TRADE)
assert len(trades) == 1 and trades[0].aggressor is Side.BUY
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests for the order book and its analytics."""
from __future__ import annotations
import pytest
from polymaker.domain import Side
from polymaker.marketdata.orderbook import OrderBook, to_no_price
def make_book() -> OrderBook:
ob = OrderBook(tick_size=0.01)
ob.apply_snapshot(
bids=[(0.40, 100), (0.41, 200), (0.42, 50)], # best bid 0.42
asks=[(0.45, 80), (0.46, 150), (0.44, 30)], # best ask 0.44
ts=1.0,
)
return ob
def test_best_bid_ask():
ob = make_book()
assert ob.best_bid().price == 0.42
assert ob.best_bid().size == 50
assert ob.best_ask().price == 0.44
assert ob.best_ask().size == 30
def test_apply_delta_add_and_remove():
ob = make_book()
ob.apply_delta(Side.BUY, 0.43, 25, ts=2.0)
assert ob.best_bid().price == 0.43
ob.apply_delta(Side.BUY, 0.43, 0, ts=3.0) # size 0 removes the level
assert ob.best_bid().price == 0.42
assert ob.last_update_ts == 3.0
def test_empty_book_views_are_none():
ob = OrderBook()
assert ob.best_bid() is None
assert ob.best_ask() is None
assert ob.microprice() is None
assert ob.is_empty
v = ob.view()
assert v.mid is None
assert v.spread is None
assert v.imbalance == 0.0
def test_microprice_pulls_toward_thin_side():
ob = OrderBook(tick_size=0.01)
# bid side much heavier than ask side -> microprice near the ask
ob.apply_snapshot(bids=[(0.40, 1000)], asks=[(0.42, 10)], ts=1.0)
mp = ob.microprice(levels=1)
assert mp is not None
assert 0.41 < mp <= 0.42 # dragged up toward the thin ask
# symmetric sizes -> mid
ob.apply_snapshot(bids=[(0.40, 100)], asks=[(0.42, 100)], ts=2.0)
assert ob.microprice(levels=1) == pytest.approx(0.41)
def test_best_with_min_size_skips_dust():
ob = OrderBook(tick_size=0.01)
# a dust order (size 1) sits at the touch; real size is one level back
ob.apply_snapshot(bids=[(0.42, 1), (0.41, 500)], asks=[(0.44, 1), (0.45, 500)], ts=1.0)
price, size, top = ob.best_with_min_size(Side.BUY, min_size=5)
assert price == 0.41 and size == 500
assert top == 0.42 # the dust touch is still reported as top
price, size, top = ob.best_with_min_size(Side.SELL, min_size=5)
assert price == 0.45 and size == 500
assert top == 0.44
def test_depth_within_band():
ob = make_book()
# bids at 0.40,0.41,0.42 all within [0.40, 0.42]
assert ob.depth_within(Side.BUY, 0.40, 0.42) == 350
assert ob.depth_within(Side.BUY, 0.415, 0.42) == 50
def test_view_second_levels_and_imbalance():
ob = make_book()
v = ob.view(min_size=0.0)
assert v.best_bid == 0.42
assert v.second_bid == 0.41
assert v.best_ask == 0.44
assert v.second_ask == 0.45
assert -1.0 <= v.imbalance <= 1.0
def test_no_price_mirror():
assert to_no_price(0.42) == pytest.approx(0.58)
assert to_no_price(0.0) == 1.0
assert to_no_price(1.0) == 0.0
+187
View File
@@ -0,0 +1,187 @@
"""Unit tests for pure quote construction — the strategy's decision core."""
from __future__ import annotations
import pytest
from polymaker.domain import Position, Regime, Side
from polymaker.strategy.quoting import (
QuoteInputs,
compute_fair_value,
construct_quotes,
round_to_tick,
)
from tests.conftest import view
def _inputs(meta, profile, **over):
base = dict(
meta=meta,
regime=Regime.QUIET,
fv=0.50,
vol_short=0.0,
toxicity=0.0,
yes_view=view(0.49, 0.51),
no_view=view(0.49, 0.51),
pos_yes=Position("yes-token"),
pos_no=Position("no-token"),
profile=profile,
now=1000.0,
)
base.update(over)
return QuoteInputs(**base)
# ── round_to_tick ──────────────────────────────────────────────────────────
def test_round_to_tick_down_and_up():
assert round_to_tick(0.5049, 0.01, 2, up=False) == 0.50
assert round_to_tick(0.5051, 0.01, 2, up=True) == 0.51
# clamps inside (0,1)
assert round_to_tick(0.0, 0.01, 2, up=False) == 0.01
assert round_to_tick(1.0, 0.01, 2, up=True) == 0.99
def test_compute_fair_value_flow_nudge():
# positive flow nudges FV up, negative down, no flow = microprice
assert compute_fair_value(0.50, 0.0, 0.01) == pytest.approx(0.50)
assert compute_fair_value(0.50, 1.0, 0.01, weight=0.5) == pytest.approx(0.505)
assert compute_fair_value(0.50, -1.0, 0.01, weight=0.5) == pytest.approx(0.495)
# ── two-sided quoting ────────────────────────────────────────────────────────
def test_quiet_market_quotes_both_sides_as_bids(meta, profile):
tq = construct_quotes(_inputs(meta, profile))
assert tq.regime == Regime.QUIET
yes = [q for q in tq.quotes if q.token_id == "yes-token"]
no = [q for q in tq.quotes if q.token_id == "no-token"]
assert yes and no
# both entry quotes are BUYs (USDC-collateralized two-sided quote)
assert all(q.side == Side.BUY for q in yes)
assert all(q.side == Side.BUY for q in no)
def test_pair_prices_sum_below_one(meta, profile):
"""BUY YES @ p and BUY NO @ q must satisfy p + q < 1 (merge edge)."""
tq = construct_quotes(_inputs(meta, profile))
top_yes = max(q.price for q in tq.quotes if q.token_id == "yes-token")
top_no = max(q.price for q in tq.quotes if q.token_id == "no-token")
assert top_yes + top_no < 1.0
def test_never_bids_through_fair_value(meta, profile):
"""No BUY should ever sit at or above FV - min_edge (YES) / (1-FV)-min_edge (NO)."""
tq = construct_quotes(_inputs(meta, profile, fv=0.50))
edge = profile.min_edge_ticks * meta.tick_size
for q in tq.quotes:
if q.side == Side.BUY and q.token_id == "yes-token":
assert q.price <= 0.50 - edge + 1e-9
if q.side == Side.BUY and q.token_id == "no-token":
assert q.price <= 0.50 - edge + 1e-9 # NO fv is also 0.50 here
def test_layers_split_size(meta, profile):
tq = construct_quotes(_inputs(meta, profile))
yes = sorted((q for q in tq.quotes if q.token_id == "yes-token" and q.side == Side.BUY),
key=lambda q: -q.price)
assert len(yes) == profile.layers
# deeper layer is at a lower price
assert yes[0].price > yes[1].price
# ── inventory skew ──────────────────────────────────────────────────────────
def test_long_yes_inventory_skews_quotes_down(meta, profile):
"""Holding YES should lower the YES bid and raise the NO bid vs flat."""
flat = construct_quotes(_inputs(meta, profile, vol_short=0.02))
longy = construct_quotes(
_inputs(meta, profile, vol_short=0.02, pos_yes=Position("yes-token", 300, 0.5))
)
def top(tq, tok):
ps = [q.price for q in tq.quotes if q.token_id == tok and q.side == Side.BUY]
return max(ps) if ps else None
# YES bid should not be higher when long YES; NO bid should not be lower
assert top(longy, "yes-token") <= top(flat, "yes-token")
assert top(longy, "no-token") >= top(flat, "no-token")
def test_reduce_only_emits_only_exits(meta, profile):
tq = construct_quotes(
_inputs(
meta, profile, regime=Regime.REDUCE_ONLY,
pos_yes=Position("yes-token", 100, 0.5),
)
)
assert all(q.side == Side.SELL for q in tq.quotes)
assert any(q.token_id == "yes-token" for q in tq.quotes)
def test_event_and_halted_pull_all_quotes(meta, profile):
for regime in (Regime.EVENT, Regime.HALTED):
tq = construct_quotes(
_inputs(meta, profile, regime=regime, pos_yes=Position("yes-token", 100, 0.5))
)
assert tq.is_empty
# ── exits ────────────────────────────────────────────────────────────────────
def test_exit_sell_priced_above_fv_when_not_urgent(meta, profile):
tq = construct_quotes(
_inputs(meta, profile, pos_yes=Position("yes-token", 100, 0.4), yes_exit_urgency=0.0)
)
sells = [q for q in tq.quotes if q.side == Side.SELL and q.token_id == "yes-token"]
assert sells
assert sells[0].price >= 0.50 # at/above FV, a passive maker exit
def test_exit_never_below_best_bid(meta, profile):
tq = construct_quotes(
_inputs(
meta, profile,
pos_yes=Position("yes-token", 100, 0.4),
yes_view=view(0.49, 0.51),
yes_exit_urgency=1.0, # maximally urgent
)
)
sells = [q for q in tq.quotes if q.side == Side.SELL and q.token_id == "yes-token"]
assert sells
assert sells[0].price >= 0.49 # still a maker order, never crosses down
def test_no_exit_when_position_is_dust(meta, profile):
tq = construct_quotes(
_inputs(meta, profile, pos_yes=Position("yes-token", 1.0, 0.4)) # below min_order_size
)
assert not [q for q in tq.quotes if q.side == Side.SELL]
# ── spread widening ──────────────────────────────────────────────────────────
def test_toxicity_widens_spread(meta, profile):
"""Higher toxicity should push the YES bid lower (wider spread)."""
calm = construct_quotes(_inputs(meta, profile, regime=Regime.TRENDING, toxicity=0.0))
toxic = construct_quotes(_inputs(meta, profile, regime=Regime.TRENDING, toxicity=0.02))
def top_yes(tq):
ps = [q.price for q in tq.quotes if q.token_id == "yes-token" and q.side == Side.BUY]
return max(ps) if ps else None
assert top_yes(toxic) < top_yes(calm)
def test_quiet_regime_clamps_spread_to_reward_band(meta, profile):
"""In QUIET, even with high vol the bid stays within the reward band of FV."""
tq = construct_quotes(_inputs(meta, profile, regime=Regime.QUIET, vol_short=0.5))
band = meta.rewards_max_spread / 100.0 # 0.03
top_yes = max(q.price for q in tq.quotes if q.token_id == "yes-token" and q.side == Side.BUY)
# bid should be within (band + a tick of rounding) of FV
assert top_yes >= 0.50 - band - meta.tick_size
+74
View File
@@ -0,0 +1,74 @@
"""Unit tests for the regime state machine."""
from __future__ import annotations
from polymaker.config import StrategyProfile
from polymaker.domain import Regime
from polymaker.strategy.regime import RegimeInputs, RegimeMachine
def _inp(**over):
base = dict(
now=1000.0,
tick=0.01,
fv=0.50,
prev_fv=0.50,
vol_ratio=1.0,
flow_z=0.0,
inventory_util=0.0,
hours_to_end=1000.0,
)
base.update(over)
return RegimeInputs(**base)
def test_default_is_quiet():
p = StrategyProfile()
assert RegimeMachine().decide(_inp(), p) == Regime.QUIET
def test_halts_take_priority():
p = StrategyProfile()
m = RegimeMachine()
assert m.decide(_inp(risk_halt=True), p) == Regime.HALTED
assert m.decide(_inp(ws_stale=True), p) == Regime.HALTED
assert m.decide(_inp(market_resolved=True), p) == Regime.HALTED
assert m.decide(_inp(hours_to_end=1.0), p) == Regime.HALTED # inside halt window
def test_fv_jump_triggers_event_and_cooloff():
p = StrategyProfile() # event_jump_ticks=8, cooloff=60
m = RegimeMachine()
# jump of 0.10 = 10 ticks > 8 -> EVENT
assert m.decide(_inp(fv=0.60, prev_fv=0.50), p) == Regime.EVENT
# still in cooloff a moment later even without a jump
assert m.decide(_inp(now=1030.0, fv=0.60, prev_fv=0.60), p) == Regime.EVENT
# after cooloff expires, back to quiet
assert m.decide(_inp(now=1100.0, fv=0.60, prev_fv=0.60), p) == Regime.QUIET
def test_sweep_flag_triggers_event():
p = StrategyProfile()
assert RegimeMachine().decide(_inp(sweep_flagged=True), p) == Regime.EVENT
def test_reduce_only_from_inventory_and_enddate():
p = StrategyProfile() # reduce_only_hours=24
m = RegimeMachine()
assert m.decide(_inp(inventory_util=1.0), p) == Regime.REDUCE_ONLY
assert m.decide(_inp(risk_reduce_only=True), p) == Regime.REDUCE_ONLY
assert m.decide(_inp(hours_to_end=12.0), p) == Regime.REDUCE_ONLY
def test_trending_from_flow_and_vol():
p = StrategyProfile() # trend_flow_z=1.5
m = RegimeMachine()
assert m.decide(_inp(flow_z=2.0), p) == Regime.TRENDING
assert m.decide(_inp(vol_ratio=3.0), p) == Regime.TRENDING
def test_event_beats_reduce_only_and_trending():
p = StrategyProfile()
m = RegimeMachine()
r = m.decide(_inp(sweep_flagged=True, inventory_util=1.0, flow_z=5.0), p)
assert r == Regime.EVENT
+86
View File
@@ -0,0 +1,86 @@
"""Tests for the RiskManager gates and circuit breakers."""
from __future__ import annotations
from polymaker.config import RiskConfig
from polymaker.domain import Fill, Side
from polymaker.risk.manager import RiskManager
from polymaker.state.store import StateStore
def _rm(tmp_path, **over):
cfg = RiskConfig(**{
"max_total_exposure_usdc": 5000, "max_market_notional_usdc": 800,
"max_event_group_loss_usdc": 1000, "daily_loss_kill_usdc": 250,
**over,
})
store = StateStore(tmp_path / "s.db")
return RiskManager(cfg, store), store
def test_daily_loss_kill_switch(tmp_path, meta):
rm, store = _rm(tmp_path)
# buy 1000 shares @ 0.50 -> -500 cash, +1000 inventory
store.apply_fill(Fill(meta.yes.token_id, Side.BUY, 0.50, 1000, "t1"))
rm.note_fill(Fill(meta.yes.token_id, Side.BUY, 0.50, 1000, "t1"))
rm.update_mark(meta.yes.token_id, 0.50)
rm.reset_day()
assert rm.global_halt()[0] is False
# fair value collapses to 0.20 -> unrealized loss 300 > 250 kill
rm.update_mark(meta.yes.token_id, 0.20)
halted, why = rm.global_halt()
assert halted and "daily_loss" in why
store.close()
def test_market_cap_triggers_reduce_only(tmp_path, meta):
rm, store = _rm(tmp_path, max_market_notional_usdc=100)
store.apply_fill(Fill(meta.yes.token_id, Side.BUY, 0.50, 300, "t1")) # 150 notional > 100
rm.update_mark(meta.yes.token_id, 0.50)
rm.update_mark(meta.no.token_id, 0.50)
d = rm.evaluate(meta, ws_stale=False, event_group_cost=0.0)
assert d.reduce_only and d.reason == "market_cap"
store.close()
def test_ws_stale_halts_market(tmp_path, meta):
rm, store = _rm(tmp_path)
d = rm.evaluate(meta, ws_stale=True, event_group_cost=0.0)
assert d.halt and d.reason == "ws_stale"
store.close()
def test_size_scale_tapers_near_cap(tmp_path, meta):
rm, store = _rm(tmp_path, max_market_notional_usdc=100)
# 85 notional -> 85% of cap -> should scale below 1.0 but not reduce-only
store.apply_fill(Fill(meta.yes.token_id, Side.BUY, 0.50, 170, "t1")) # 85 notional
rm.update_mark(meta.yes.token_id, 0.50)
rm.update_mark(meta.no.token_id, 0.50)
d = rm.evaluate(meta, ws_stale=False, event_group_cost=0.0)
assert not d.reduce_only
assert 0.0 < d.size_scale < 1.0
store.close()
def test_event_group_cap(tmp_path, meta):
rm, store = _rm(tmp_path, max_event_group_loss_usdc=50)
d = rm.evaluate(meta, ws_stale=False, event_group_cost=60.0)
assert d.reduce_only and d.reason == "event_group_cap"
store.close()
def test_error_rate_breaker(tmp_path, meta):
rm, store = _rm(tmp_path, max_order_error_rate=0.25)
for _ in range(15):
rm.note_order_result(False)
for _ in range(10):
rm.note_order_result(True) # 15/25 = 0.6 > 0.25
assert rm.global_halt()[0] is True
store.close()
def test_manual_kill(tmp_path, meta):
rm, store = _rm(tmp_path)
rm.kill()
assert rm.global_halt() == (True, "manual_kill")
store.close()
+171
View File
@@ -0,0 +1,171 @@
"""Tests for StateStore, the user-event tracker, and the reconciler."""
from __future__ import annotations
from polymaker.domain import (
Fill,
OpenOrder,
OrderState,
Quote,
Regime,
Side,
TargetQuotes,
TradeState,
)
from polymaker.execution.reconciler import reconcile
from polymaker.state.store import StateStore
from polymaker.state.tracker import OrderEvent, TradeEvent, UserEventProcessor
# ── StateStore ──────────────────────────────────────────────────────────────
def test_apply_fill_updates_size_and_avg(tmp_path):
s = StateStore(tmp_path / "s.db")
s.apply_fill(Fill("tok", Side.BUY, 0.50, 100, "t1"))
assert s.position("tok").size == 100
assert s.position("tok").avg_price == 0.50
# buy more at a higher price -> weighted avg
s.apply_fill(Fill("tok", Side.BUY, 0.60, 100, "t2"))
assert s.position("tok").size == 200
assert abs(s.position("tok").avg_price - 0.55) < 1e-9
# sell reduces size, avg unchanged
s.apply_fill(Fill("tok", Side.SELL, 0.70, 50, "t3"))
assert s.position("tok").size == 150
assert abs(s.position("tok").avg_price - 0.55) < 1e-9
s.close()
def test_sell_to_flat_resets_avg(tmp_path):
s = StateStore(tmp_path / "s.db")
s.apply_fill(Fill("tok", Side.BUY, 0.5, 100, "t1"))
s.apply_fill(Fill("tok", Side.SELL, 0.6, 100, "t2"))
assert s.position("tok").size == 0
assert s.position("tok").avg_price == 0.0
s.close()
def test_reconcile_positions_skips_inflight_and_recent(tmp_path):
s = StateStore(tmp_path / "s.db")
s.mark_inflight("tok")
s.reconcile_positions({"tok": (999.0, 0.9)}) # ignored: in-flight
assert s.position("tok").size == 0
s.clear_inflight("tok")
# still recent fill guard: simulate no recent fill by using a fresh token
s.reconcile_positions({"other": (42.0, 0.3)})
assert s.position("other").size == 42.0
s.close()
def test_state_persists_across_restart(tmp_path):
db = tmp_path / "s.db"
s = StateStore(db)
s.apply_fill(Fill("tok", Side.BUY, 0.5, 100, "t1"))
s.close()
s2 = StateStore(db)
assert s2.position("tok").size == 100
s2.close()
# ── UserEventProcessor ───────────────────────────────────────────────────────
def test_matched_then_confirmed(tmp_path):
s = StateStore(tmp_path / "s.db")
changed: list[str] = []
p = UserEventProcessor(s, on_change=changed.append)
p.on_trade(TradeEvent("tok", Side.BUY, 0.5, 100, "trade1", TradeState.MATCHED, 1.0), "cid")
assert s.position("tok").size == 100
assert s.inflight("tok") == 1
p.on_trade(TradeEvent("tok", Side.BUY, 0.5, 100, "trade1", TradeState.CONFIRMED, 2.0), "cid")
assert s.inflight("tok") == 0
assert s.position("tok").size == 100 # settled
assert changed == ["cid", "cid"]
s.close()
def test_matched_is_idempotent(tmp_path):
s = StateStore(tmp_path / "s.db")
p = UserEventProcessor(s)
ev = TradeEvent("tok", Side.BUY, 0.5, 100, "trade1", TradeState.MATCHED, 1.0)
p.on_trade(ev, "cid")
p.on_trade(ev, "cid") # duplicate MATCHED for same trade id
assert s.position("tok").size == 100 # not doubled
s.close()
def test_failed_trade_reverses_fill(tmp_path):
s = StateStore(tmp_path / "s.db")
p = UserEventProcessor(s)
p.on_trade(TradeEvent("tok", Side.BUY, 0.5, 100, "trade1", TradeState.MATCHED, 1.0), "cid")
assert s.position("tok").size == 100
p.on_trade(TradeEvent("tok", Side.BUY, 0.5, 100, "trade1", TradeState.FAILED, 2.0), "cid")
assert s.position("tok").size == 0 # rolled back
assert s.inflight("tok") == 0
s.close()
def test_order_event_upsert_and_cancel(tmp_path):
s = StateStore(tmp_path / "s.db")
p = UserEventProcessor(s)
p.on_order(OrderEvent("o1", "tok", Side.BUY, 0.49, 100), "cid")
assert len(s.orders_for("tok")) == 1
p.on_order(OrderEvent("o1", "tok", Side.BUY, 0.49, 0, is_cancel=True), "cid")
assert len(s.orders_for("tok")) == 0
s.close()
# ── reconciler ───────────────────────────────────────────────────────────────
def _live(order_id, token, side, price, size):
return OpenOrder(order_id, token, side, price, size, OrderState.LIVE)
def test_reconcile_places_when_no_live():
tq = TargetQuotes("cid", Regime.QUIET, (Quote("tok", Side.BUY, 0.49, 100),))
plan = reconcile(tq, [], tick=0.01, reprice_ticks=2, resize_frac=0.15)
assert len(plan.to_place) == 1
assert plan.to_cancel == []
def test_reconcile_keeps_close_order():
tq = TargetQuotes("cid", Regime.QUIET, (Quote("tok", Side.BUY, 0.49, 100),))
live = [_live("o1", "tok", Side.BUY, 0.49, 102)] # within tolerances
plan = reconcile(tq, live, tick=0.01, reprice_ticks=2, resize_frac=0.15)
assert plan.is_noop
def test_reconcile_reprices_when_far():
tq = TargetQuotes("cid", Regime.QUIET, (Quote("tok", Side.BUY, 0.45, 100),))
live = [_live("o1", "tok", Side.BUY, 0.49, 100)] # 4 ticks away > 2
plan = reconcile(tq, live, tick=0.01, reprice_ticks=2, resize_frac=0.15)
assert plan.to_cancel == ["o1"]
assert len(plan.to_place) == 1
def test_reconcile_resizes_when_size_drifts():
tq = TargetQuotes("cid", Regime.QUIET, (Quote("tok", Side.BUY, 0.49, 100),))
live = [_live("o1", "tok", Side.BUY, 0.49, 50)] # 50% smaller > 15%
plan = reconcile(tq, live, tick=0.01, reprice_ticks=2, resize_frac=0.15)
assert plan.to_cancel == ["o1"]
assert len(plan.to_place) == 1
def test_reconcile_cancels_all_when_target_empty():
tq = TargetQuotes("cid", Regime.EVENT, ())
live = [_live("o1", "tok", Side.BUY, 0.49, 100), _live("o2", "tok", Side.SELL, 0.55, 50)]
plan = reconcile(tq, live, tick=0.01, reprice_ticks=2, resize_frac=0.15)
assert set(plan.to_cancel) == {"o1", "o2"}
assert plan.to_place == []
def test_reconcile_matches_layers_one_to_one():
tq = TargetQuotes("cid", Regime.QUIET, (
Quote("tok", Side.BUY, 0.49, 100),
Quote("tok", Side.BUY, 0.47, 100),
))
live = [_live("o1", "tok", Side.BUY, 0.49, 100)] # only the top layer exists
plan = reconcile(tq, live, tick=0.01, reprice_ticks=2, resize_frac=0.15)
assert plan.to_cancel == []
assert len(plan.to_place) == 1 # only the missing deeper layer
assert plan.to_place[0].price == 0.47
+88
View File
@@ -0,0 +1,88 @@
"""Tests for user-WS normalization (maker fill extraction + order tracking).
Frames modeled on v1's observed shape; reconfirm field names in the wallet spike.
"""
from __future__ import annotations
from polymaker.domain import Side, TradeState
from polymaker.userstream.parse import normalize_order, normalize_trade
OUR = "0xMyWallet"
def _other(token: str) -> str | None:
return {"yes-tok": "no-tok", "no-tok": "yes-tok"}.get(token)
def test_maker_same_outcome_is_a_sell():
# taker BUYs YES; we are the maker on YES -> we SELL YES
msg = {
"event_type": "trade",
"market": "0xcond",
"asset_id": "yes-tok",
"side": "BUY",
"outcome": "Yes",
"status": "MATCHED",
"id": "trade1",
"timestamp": "1700000000000",
"maker_orders": [
{"maker_address": OUR, "matched_amount": "50", "price": "0.49", "outcome": "Yes"}
],
}
evs = normalize_trade(msg, OUR, _other)
assert len(evs) == 1
ev = evs[0]
assert ev.token_id == "yes-tok"
assert ev.our_side is Side.SELL
assert ev.size == 50 and ev.price == 0.49
assert ev.status is TradeState.MATCHED
def test_maker_different_outcome_is_a_mint_buy():
# taker BUYs YES; we are the maker on NO -> a mint: we BUY NO
msg = {
"event_type": "trade",
"market": "0xcond",
"asset_id": "yes-tok",
"side": "BUY",
"outcome": "Yes",
"status": "MATCHED",
"id": "trade2",
"timestamp": "1700000000000",
"maker_orders": [
{"maker_address": OUR, "matched_amount": "30", "price": "0.51", "outcome": "No"}
],
}
evs = normalize_trade(msg, OUR, _other)
assert len(evs) == 1
assert evs[0].token_id == "no-tok"
assert evs[0].our_side is Side.BUY
assert evs[0].size == 30
def test_ignores_maker_orders_that_are_not_ours():
msg = {
"event_type": "trade", "market": "0xcond", "asset_id": "yes-tok", "side": "BUY",
"outcome": "Yes", "status": "MATCHED", "id": "t", "timestamp": "1700000000000",
"maker_orders": [
{"maker_address": "0xSomeoneElse", "matched_amount": "50", "price": "0.49", "outcome": "Yes"}
],
}
assert normalize_trade(msg, OUR, _other) == []
def test_normalize_order_remaining_and_cancel():
ev = normalize_order({
"event_type": "order", "asset_id": "yes-tok", "side": "BUY", "price": "0.49",
"original_size": "100", "size_matched": "40", "status": "LIVE", "id": "o1",
})
assert ev is not None
assert ev.remaining_size == 60
assert ev.is_cancel is False
cancel = normalize_order({
"event_type": "order", "asset_id": "yes-tok", "side": "BUY", "price": "0.49",
"original_size": "100", "size_matched": "0", "status": "CANCELED", "id": "o1",
})
assert cancel is not None and cancel.is_cancel is True