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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
+57
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user