From e137116e7ff199132172207f98e2fe1068694379 Mon Sep 17 00:00:00 2001 From: chemavx Date: Thu, 11 Jun 2026 13:48:41 +0000 Subject: [PATCH] feat(resolution): add automatic market resolution detector with conservative payout validation - 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 --- bot/data/polymarket.py | 94 +++++++++++++ bot/executor/paper.py | 2 +- bot/main.py | 57 ++++++++ tests/test_paper_close.py | 2 +- tests/test_resolution_detector.py | 219 ++++++++++++++++++++++++++++++ 5 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 tests/test_resolution_detector.py diff --git a/bot/data/polymarket.py b/bot/data/polymarket.py index 631d7e5..69f9aad 100644 --- a/bot/data/polymarket.py +++ b/bot/data/polymarket.py @@ -211,6 +211,32 @@ class Market: category: str = "" +@dataclass +class MarketResolution: + """Resolution state of a market, from Gamma API. + + resolution is the final YES outcome price: 1.0 = YES won, 0.0 = NO won. + resolved is True only when the outcome is definitive — a market that is + closed but still in UMA dispute/proposal reports resolved=False. + """ + resolved: bool + resolution: Optional[float] = None + resolved_at: Optional[datetime] = None + + +def _parse_resolution_timestamp(raw: Optional[str]) -> Optional[datetime]: + """Parse Gamma timestamps: '2026-06-11 13:15:01+00' or '2026-06-11T13:15:01Z'.""" + if not raw: + return None + try: + dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, TypeError): + return None + + @dataclass class OrderBook: market_id: str @@ -447,6 +473,74 @@ class PolymarketClient: ) return markets + async def get_market_resolution(self, market_id: str) -> Optional[MarketResolution]: + """Fetch resolution state for a market by Gamma market id. + + Observed Gamma API behaviour (GET /markets/{id}): + open market → closed=false, umaResolutionStatus absent + resolved market → closed=true, umaResolutionStatus="resolved", + outcomePrices='["0", "1"]' (final YES price = outcome) + unknown id → HTTP 404 + + Returns None on API errors (caller retries next check). A closed market + whose outcome prices are not degenerate (0/1) or whose UMA status is not + "resolved" yet (proposed/disputed) reports resolved=False — we never + settle a position on an ambiguous outcome. + """ + try: + resp = await self._client.get(f"{GAMMA_API}/markets/{market_id}") + if resp.status_code == 404: + log.warning("get_market_resolution: market %s not found (404)", market_id) + return None + resp.raise_for_status() + m = resp.json() + except httpx.HTTPError as e: + log.warning("get_market_resolution: API error for %s: %s", market_id, e) + return None + + if not m.get("closed"): + return MarketResolution(resolved=False) + + uma_status = (m.get("umaResolutionStatus") or "").lower() + if uma_status and uma_status != "resolved": + # Closed but UMA outcome still proposed/disputed — wait for finality + return MarketResolution(resolved=False) + + raw_prices = m.get("outcomePrices", []) + if isinstance(raw_prices, str): + import json as _json + try: + raw_prices = _json.loads(raw_prices) + except ValueError: + raw_prices = [] + try: + yes_final = float(raw_prices[0]) + except (IndexError, TypeError, ValueError): + log.warning( + "get_market_resolution: market %s closed but outcomePrices " + "unparseable: %r", market_id, m.get("outcomePrices"), + ) + return MarketResolution(resolved=False) + + if yes_final >= 0.99: + resolution = 1.0 + elif yes_final <= 0.01: + resolution = 0.0 + else: + # Closed but prices not settled at 0/1 (partial / ambiguous outcome) + log.warning( + "get_market_resolution: market %s closed with non-binary final " + "price %.3f — not settling", market_id, yes_final, + ) + return MarketResolution(resolved=False) + + resolved_at = ( + _parse_resolution_timestamp(m.get("closedTime")) + or _parse_resolution_timestamp(m.get("umaEndDate")) + or _parse_resolution_timestamp(m.get("endDate")) + ) + return MarketResolution(resolved=True, resolution=resolution, resolved_at=resolved_at) + async def get_order_book(self, token_id: str) -> Optional[OrderBook]: """Get order book for a specific token.""" try: diff --git a/bot/executor/paper.py b/bot/executor/paper.py index 9feb8aa..bf2192f 100644 --- a/bot/executor/paper.py +++ b/bot/executor/paper.py @@ -270,7 +270,7 @@ class PaperExecutor: await self._db.close_paper_position( market_id, - reason=f"market_resolved resolution={resolution:.1f}", + reason="resolved", resolution=resolution, ) log.info( diff --git a/bot/main.py b/bot/main.py index c1b0f7f..e2bcf10 100644 --- a/bot/main.py +++ b/bot/main.py @@ -27,6 +27,50 @@ log = logging.getLogger("bot.main") PAPER_MODE = os.getenv("PAPER_MODE", "true").lower() == "true" PAPER_BANKROLL = float(os.getenv("PAPER_BANKROLL", "10000")) +# Check open positions for market resolution every N trading cycles (~N minutes +# at the 60s cycle cadence). Keeps Gamma API load at ~1 request per open +# position per 10 minutes. +RESOLUTION_CHECK_INTERVAL = 10 + + +async def check_resolutions( + poly: PolymarketClient, + executor: PaperExecutor, + db: Database, +) -> None: + """Detect resolved markets and settle their open paper positions. + + For each open position, asks the Gamma API whether the market resolved. + On a definitive resolution, PaperExecutor.close_position() settles the + payout, persists close_reason='resolved' + resolution + close_pnl, and + sends the Telegram notification. + """ + positions = await db.get_open_position_details() + checked = 0 + resolved = 0 + for pos in positions: + market_id = str(pos["market_id"]) + try: + res = await poly.get_market_resolution(market_id) + except Exception as exc: + log.warning("Resolution check failed for market %s: %s", market_id, exc) + continue + checked += 1 + if res is None or not res.resolved or res.resolution is None: + continue + pnl = await executor.close_position( + market_id, res.resolution, question=pos.get("question") or "", + ) + resolved += 1 + log.info( + "MARKET_RESOLVED market_id=%s resolution=%.1f pnl=%s | %s", + market_id, + res.resolution, + f"{pnl:+.2f}" if pnl is not None else "n/a", + (pos.get("question") or "")[:60], + ) + log.info("Resolution check: %d positions checked, %d resolved", checked, resolved) + async def run_trading_loop( poly: PolymarketClient, @@ -40,9 +84,22 @@ async def run_trading_loop( """Main trading loop — runs every 60 seconds.""" log.info("Trading loop started. PAPER_MODE=%s", PAPER_MODE) checkpoint_monitor = CheckpointMonitor() + cycle_count = 0 while True: try: + cycle_count += 1 + + # 0. Resolution detector — every RESOLUTION_CHECK_INTERVAL cycles, + # settle paper positions whose market resolved on Polymarket. + # Runs before evaluation so freed cash/families are usable this cycle. + if ( + PAPER_MODE + and isinstance(executor, PaperExecutor) + and cycle_count % RESOLUTION_CHECK_INTERVAL == 0 + ): + await check_resolutions(poly, executor, db) + # 1. Fetch active markets (90-day window) markets = await poly.get_active_markets() log.info("Found %d active markets", len(markets)) diff --git a/tests/test_paper_close.py b/tests/test_paper_close.py index 0c30c4a..b497ad5 100644 --- a/tests/test_paper_close.py +++ b/tests/test_paper_close.py @@ -95,7 +95,7 @@ def test_buy_no_loses(): def test_position_is_removed_and_persisted(): pnl, ex, db, notif = _close("BUY_YES", resolution=1.0) assert "mkt1" not in ex._portfolio.positions - assert db.closed == [("mkt1", "market_resolved resolution=1.0", 1.0)] + assert db.closed == [("mkt1", "resolved", 1.0)] def test_unknown_market_returns_none(): diff --git a/tests/test_resolution_detector.py b/tests/test_resolution_detector.py new file mode 100644 index 0000000..3e914c8 --- /dev/null +++ b/tests/test_resolution_detector.py @@ -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