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 = ""
|
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
|
@dataclass
|
||||||
class OrderBook:
|
class OrderBook:
|
||||||
market_id: str
|
market_id: str
|
||||||
@@ -447,6 +473,74 @@ class PolymarketClient:
|
|||||||
)
|
)
|
||||||
return markets
|
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]:
|
async def get_order_book(self, token_id: str) -> Optional[OrderBook]:
|
||||||
"""Get order book for a specific token."""
|
"""Get order book for a specific token."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ class PaperExecutor:
|
|||||||
|
|
||||||
await self._db.close_paper_position(
|
await self._db.close_paper_position(
|
||||||
market_id,
|
market_id,
|
||||||
reason=f"market_resolved resolution={resolution:.1f}",
|
reason="resolved",
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
|
|||||||
+57
@@ -27,6 +27,50 @@ log = logging.getLogger("bot.main")
|
|||||||
PAPER_MODE = os.getenv("PAPER_MODE", "true").lower() == "true"
|
PAPER_MODE = os.getenv("PAPER_MODE", "true").lower() == "true"
|
||||||
PAPER_BANKROLL = float(os.getenv("PAPER_BANKROLL", "10000"))
|
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(
|
async def run_trading_loop(
|
||||||
poly: PolymarketClient,
|
poly: PolymarketClient,
|
||||||
@@ -40,9 +84,22 @@ async def run_trading_loop(
|
|||||||
"""Main trading loop — runs every 60 seconds."""
|
"""Main trading loop — runs every 60 seconds."""
|
||||||
log.info("Trading loop started. PAPER_MODE=%s", PAPER_MODE)
|
log.info("Trading loop started. PAPER_MODE=%s", PAPER_MODE)
|
||||||
checkpoint_monitor = CheckpointMonitor()
|
checkpoint_monitor = CheckpointMonitor()
|
||||||
|
cycle_count = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
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)
|
# 1. Fetch active markets (90-day window)
|
||||||
markets = await poly.get_active_markets()
|
markets = await poly.get_active_markets()
|
||||||
log.info("Found %d active markets", len(markets))
|
log.info("Found %d active markets", len(markets))
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def test_buy_no_loses():
|
|||||||
def test_position_is_removed_and_persisted():
|
def test_position_is_removed_and_persisted():
|
||||||
pnl, ex, db, notif = _close("BUY_YES", resolution=1.0)
|
pnl, ex, db, notif = _close("BUY_YES", resolution=1.0)
|
||||||
assert "mkt1" not in ex._portfolio.positions
|
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():
|
def test_unknown_market_returns_none():
|
||||||
|
|||||||
@@ -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