Getting there
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user