188 lines
7.3 KiB
Python
188 lines
7.3 KiB
Python
"""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
|