feat(resolution): add automatic market resolution detector with conservative payout validation
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:
chemavx
2026-06-11 13:48:41 +00:00
co-authored by Claude Fable 5
parent 340c8523cf
commit e137116e7f
5 changed files with 372 additions and 2 deletions
+94
View File
@@ -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: