← back to Demo Longshot Bot

venue_adapter.py

152 lines

"""
venue_adapter.py — normalizes a trading venue into a single interface the bot uses.

    adapter = get_adapter("polymarket")
    markets = adapter.fetch_universe(query="", limit=200)   # list[Market]
    price   = adapter.mark_price(market_id)                  # float 0..1

A Market is a plain dict with the variables safe_eval.MARKET_VARS expects, plus
id / title for display. Adapters are READ-ONLY here — they never place orders.
Order routing lives in executor.py so the read path and the money path stay
separate (and the money path stays gated).

Polymarket uses the PUBLIC Gamma API — no key, $0 per call. This is the same
data source the `polymtrx` skill scans, so a bot built here and a /polymtrx scan
see the same market truth.
"""

import json
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone

USER_AGENT = "zero-code-trading-bot/1.0 (paper; +local)"


def _get_json(url, timeout=20):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def _days_until(iso_ts):
    if not iso_ts:
        return 9999.0
    try:
        end = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
        return max(0.0, (end - datetime.now(timezone.utc)).total_seconds() / 86400.0)
    except (ValueError, TypeError):
        return 9999.0


class PolymarketAdapter:
    """Read-only Polymarket via the public Gamma API (gamma-api.polymarket.com)."""

    name = "polymarket"
    GAMMA = "https://gamma-api.polymarket.com/markets"

    def fetch_universe(self, query="", limit=200):
        # order=volumeNum (numeric) — plain order=volume sorts lexicographically,
        # ranking "$100" above "$154M" and starving the bot of real markets.
        params = {"closed": "false", "active": "true", "limit": str(min(limit, 500)),
                  "order": "volumeNum", "ascending": "false"}
        url = self.GAMMA + "?" + urllib.parse.urlencode(params)
        raw = _get_json(url)
        markets = []
        for m in raw:
            title = (m.get("question") or m.get("title") or "").strip()
            if query and query.lower() not in title.lower():
                continue
            price = self._yes_price(m)
            if price is None:
                continue
            spread = self._safe_float(m.get("spread"), default=1.0)
            markets.append({
                "id": str(m.get("id") or m.get("conditionId") or title),
                "title": title,
                "price": price,
                "volume_usd": self._safe_float(m.get("volume") or m.get("volumeNum")),
                "liquidity_usd": self._safe_float(m.get("liquidity") or m.get("liquidityNum")),
                "spread": spread,
                "days_to_resolve": _days_until(m.get("endDate") or m.get("end_date_iso")),
                "url": "https://polymarket.com/event/" + str(m.get("slug", "")),
            })
        return markets

    def mark_price(self, market_id):
        """Re-fetch a single market's current YES price (for marking open positions)."""
        url = self.GAMMA + "?" + urllib.parse.urlencode({"id": market_id})
        try:
            raw = _get_json(url)
        except Exception:
            return None
        if not raw:
            return None
        return self._yes_price(raw[0])

    @staticmethod
    def _yes_price(m):
        prices = m.get("outcomePrices")
        if isinstance(prices, str):
            try:
                prices = json.loads(prices)
            except json.JSONDecodeError:
                prices = None
        if isinstance(prices, list) and prices:
            try:
                return round(float(prices[0]), 4)
            except (ValueError, TypeError):
                return None
        best = m.get("bestBid")
        return round(float(best), 4) if best not in (None, "") else None

    @staticmethod
    def _safe_float(v, default=0.0):
        try:
            return float(v)
        except (ValueError, TypeError):
            return default


class KalshiDemoAdapter:
    """
    Kalshi placeholder. Live Kalshi needs real API credentials (KALSHI_API_KEY /
    KALSHI_PRIVATE_KEY) — see references/venues.md. Until those are wired and
    Steve-approved, this adapter runs in DEMO: it returns a clear stub so a bot
    can be authored and paper-tested against Polymarket first, then re-pointed at
    Kalshi once credentials exist. (Mirrors the Ken/Kalshi dashboard being DEMO
    pending prod creds.)
    """

    name = "kalshi"

    def fetch_universe(self, query="", limit=200):
        raise NotImplementedError(
            "Kalshi live data needs API credentials. Author + paper-test the "
            "strategy on Polymarket (venue='polymarket'), or wire Kalshi creds "
            "per references/venues.md and get Steve's go-live approval first."
        )

    def mark_price(self, market_id):
        return None


_ADAPTERS = {"polymarket": PolymarketAdapter, "kalshi": KalshiDemoAdapter}


def get_adapter(venue):
    cls = _ADAPTERS.get((venue or "").lower())
    if cls is None:
        raise ValueError(f"unknown venue {venue!r}; supported: {sorted(_ADAPTERS)}")
    return cls()


if __name__ == "__main__":  # quick manual check: python venue_adapter.py
    a = get_adapter("polymarket")
    t0 = time.time()
    ms = a.fetch_universe(limit=50)
    print(f"fetched {len(ms)} markets in {time.time()-t0:.1f}s  (cost: $0 — public API)")
    for m in ms[:5]:
        print(f"  {m['price']:.2f}  vol=${m['volume_usd']:,.0f}  {m['title'][:70]}")