feat(resolution): add automatic market resolution detector with conservative payout validation
CI/CD / build-and-push (push) Successful in 8s
CI/CD / build-and-push (push) Successful in 8s
- PolymarketClient.get_market_resolution(): query Gamma API by market id; resolved only when closed AND uma status final AND outcome prices binary (never settle on disputed/ambiguous outcomes) - bot/main.py: check_resolutions() runs every 10 cycles (~10 min) in paper mode, settles open positions via PaperExecutor.close_position() - close_reason now persisted as 'resolved' (resolution has its own column) - tests/test_resolution_detector.py: 10 tests covering API parsing shapes and the BUY_NO settlement flow; 27/27 suite green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
340c8523cf
commit
e137116e7f
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Tests for the automatic market-resolution detector (Phase 2).
|
||||
|
||||
Covers:
|
||||
- PolymarketClient.get_market_resolution() parsing of real Gamma API shapes
|
||||
(resolved YES/NO, still open, UMA-disputed, ambiguous prices, 404, errors).
|
||||
- check_resolutions() in bot/main.py: a resolved market settles the open
|
||||
paper position via PaperExecutor.close_position() and persists
|
||||
close_reason='resolved' with the resolution value.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from bot.data.polymarket import PolymarketClient, MarketResolution
|
||||
from bot.executor import paper
|
||||
from bot.executor.paper import PaperExecutor
|
||||
from bot.main import check_resolutions
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# get_market_resolution() — Gamma API response parsing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, payload: dict | None = None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise httpx.HTTPStatusError(
|
||||
f"HTTP {self.status_code}", request=None, response=None
|
||||
)
|
||||
|
||||
|
||||
class FakeHTTPClient:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
self.requested_urls: list[str] = []
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
self.requested_urls.append(url)
|
||||
if isinstance(self._response, Exception):
|
||||
raise self._response
|
||||
return self._response
|
||||
|
||||
|
||||
def _resolution_for(response) -> MarketResolution | None:
|
||||
client = PolymarketClient()
|
||||
client._client = FakeHTTPClient(response)
|
||||
return asyncio.run(client.get_market_resolution("12345"))
|
||||
|
||||
|
||||
def _gamma_market(closed: bool, yes_price: str, no_price: str,
|
||||
uma_status: str | None = "resolved") -> dict:
|
||||
"""Mirror the real Gamma /markets/{id} payload shape (observed 2026-06-11)."""
|
||||
m = {
|
||||
"id": "12345",
|
||||
"question": "Test market?",
|
||||
"closed": closed,
|
||||
"active": True,
|
||||
"outcomePrices": json.dumps([yes_price, no_price]),
|
||||
"closedTime": "2026-06-11 13:15:01+00" if closed else None,
|
||||
"umaEndDate": "2026-06-11T13:15:01Z" if closed else None,
|
||||
"endDate": "2026-06-11T13:00:00Z",
|
||||
}
|
||||
if uma_status is not None:
|
||||
m["umaResolutionStatus"] = uma_status
|
||||
return m
|
||||
|
||||
|
||||
def test_resolution_no_won():
|
||||
res = _resolution_for(FakeResponse(200, _gamma_market(True, "0", "1")))
|
||||
assert res.resolved is True
|
||||
assert res.resolution == 0.0
|
||||
assert res.resolved_at is not None
|
||||
|
||||
|
||||
def test_resolution_yes_won():
|
||||
res = _resolution_for(FakeResponse(200, _gamma_market(True, "1", "0")))
|
||||
assert res.resolved is True
|
||||
assert res.resolution == 1.0
|
||||
|
||||
|
||||
def test_open_market_not_resolved():
|
||||
res = _resolution_for(FakeResponse(
|
||||
200, _gamma_market(False, "0.51", "0.49", uma_status=None)
|
||||
))
|
||||
assert res.resolved is False
|
||||
assert res.resolution is None
|
||||
|
||||
|
||||
def test_closed_but_uma_disputed_not_settled():
|
||||
res = _resolution_for(FakeResponse(
|
||||
200, _gamma_market(True, "0", "1", uma_status="disputed")
|
||||
))
|
||||
assert res.resolved is False
|
||||
|
||||
|
||||
def test_closed_with_ambiguous_prices_not_settled():
|
||||
res = _resolution_for(FakeResponse(200, _gamma_market(True, "0.6", "0.4")))
|
||||
assert res.resolved is False
|
||||
|
||||
|
||||
def test_market_not_found_returns_none():
|
||||
assert _resolution_for(FakeResponse(404)) is None
|
||||
|
||||
|
||||
def test_api_error_returns_none():
|
||||
assert _resolution_for(httpx.ConnectError("boom")) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# check_resolutions() — detector loop settles paper positions
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class FakeDB:
|
||||
"""Database stub: one open BUY_NO paper position."""
|
||||
|
||||
def __init__(self, trades_by_market: dict[str, list[dict]]):
|
||||
self._trades = trades_by_market
|
||||
self.closed: list[tuple] = []
|
||||
|
||||
async def get_open_position_details(self) -> list[dict]:
|
||||
return [
|
||||
{"market_id": mid, "question": t[0].get("question", ""),
|
||||
"direction": t[0]["direction"]}
|
||||
for mid, t in self._trades.items()
|
||||
]
|
||||
|
||||
async def get_open_trades_for_market(self, market_id: str) -> list[dict]:
|
||||
return self._trades.get(market_id, [])
|
||||
|
||||
async def close_paper_position(self, market_id, reason="", resolution=None):
|
||||
self.closed.append((market_id, reason, resolution))
|
||||
|
||||
|
||||
class FakePoly:
|
||||
def __init__(self, resolutions: dict[str, MarketResolution | None]):
|
||||
self._resolutions = resolutions
|
||||
self.checked: list[str] = []
|
||||
|
||||
async def get_market_resolution(self, market_id: str):
|
||||
self.checked.append(market_id)
|
||||
return self._resolutions.get(market_id)
|
||||
|
||||
|
||||
def _run_check(resolutions: dict, trades: dict):
|
||||
notifications: list[tuple] = []
|
||||
|
||||
async def fake_trade_closed(question, pnl):
|
||||
notifications.append((question, pnl))
|
||||
|
||||
async def run():
|
||||
db = FakeDB(trades)
|
||||
ex = PaperExecutor(db=db, bankroll=1000.0)
|
||||
for mid, t in trades.items():
|
||||
ex._portfolio.positions[mid] = sum(x["net_cost"] for x in t) - 2.0
|
||||
ex._portfolio.cash = 898.0
|
||||
poly = FakePoly(resolutions)
|
||||
|
||||
original = paper.telegram.trade_closed
|
||||
paper.telegram.trade_closed = fake_trade_closed
|
||||
try:
|
||||
await check_resolutions(poly, ex, db)
|
||||
await asyncio.sleep(0) # let notification task run
|
||||
finally:
|
||||
paper.telegram.trade_closed = original
|
||||
return db, ex, poly
|
||||
|
||||
db, ex, poly = asyncio.run(run())
|
||||
return db, ex, poly, notifications
|
||||
|
||||
|
||||
BUY_NO_TRADE = {
|
||||
"mkt1": [{
|
||||
"direction": "BUY_NO", "shares": 200.0, "net_cost": 102.0,
|
||||
"question": "Will X happen?",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_resolved_buy_no_position_is_closed():
|
||||
"""BUY_NO position + market resolved NO (resolution=0.0) → winning close."""
|
||||
db, ex, poly, notif = _run_check(
|
||||
{"mkt1": MarketResolution(resolved=True, resolution=0.0)},
|
||||
BUY_NO_TRADE,
|
||||
)
|
||||
assert poly.checked == ["mkt1"]
|
||||
# close_paper_position called with close_reason='resolved' and the resolution
|
||||
assert db.closed == [("mkt1", "resolved", 0.0)]
|
||||
# Position removed and payout credited: 200 shares * (1 - 0.0) = $200
|
||||
assert "mkt1" not in ex._portfolio.positions
|
||||
assert ex._portfolio.cash == pytest.approx(898.0 + 200.0)
|
||||
# Telegram notified with positive pnl (200 - 102)
|
||||
assert notif == [("Will X happen?", pytest.approx(98.0))]
|
||||
|
||||
|
||||
def test_unresolved_position_stays_open():
|
||||
db, ex, poly, notif = _run_check(
|
||||
{"mkt1": MarketResolution(resolved=False)},
|
||||
BUY_NO_TRADE,
|
||||
)
|
||||
assert poly.checked == ["mkt1"]
|
||||
assert db.closed == []
|
||||
assert "mkt1" in ex._portfolio.positions
|
||||
assert notif == []
|
||||
|
||||
|
||||
def test_api_failure_leaves_position_open():
|
||||
db, ex, poly, notif = _run_check({"mkt1": None}, BUY_NO_TRADE)
|
||||
assert db.closed == []
|
||||
assert "mkt1" in ex._portfolio.positions
|
||||
Reference in New Issue
Block a user