""" 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