commit 4fda34df3b97847abb3a4aaedf5c9a57a7b92abf Author: chemavx Date: Mon Apr 13 16:05:45 2026 +0000 feat: initial commit — polymarket-bot source + CI/CD pipeline diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..67c6c4a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI/CD + +on: + push: + branches: + - main + +env: + REGISTRY: git.chemavx.xyz + IMAGE_BOT: git.chemavx.xyz/chemavx/polymarket-bot + IMAGE_API: git.chemavx.xyz/chemavx/polymarket-bot-api + K8S_MANIFESTS_REPO: https://chemavx:${{ secrets.CI_TOKEN }}@git.chemavx.xyz/chemavx/k8s-manifests.git + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set image tag + id: tag + run: echo "TAG=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT + + - name: Login to Gitea registry + run: | + echo "${{ secrets.CI_TOKEN }}" | docker login ${{ env.REGISTRY }} \ + -u chemavx --password-stdin + + - name: Build bot image + run: | + docker build \ + -f Dockerfile \ + -t ${{ env.IMAGE_BOT }}:${{ steps.tag.outputs.TAG }} \ + -t ${{ env.IMAGE_BOT }}:latest \ + . + + - name: Build API image + run: | + docker build \ + -f Dockerfile.api \ + -t ${{ env.IMAGE_API }}:${{ steps.tag.outputs.TAG }} \ + -t ${{ env.IMAGE_API }}:latest \ + . + + - name: Push bot image + run: | + docker push ${{ env.IMAGE_BOT }}:${{ steps.tag.outputs.TAG }} + docker push ${{ env.IMAGE_BOT }}:latest + + - name: Push API image + run: | + docker push ${{ env.IMAGE_API }}:${{ steps.tag.outputs.TAG }} + docker push ${{ env.IMAGE_API }}:latest + + - name: Update k8s manifests + run: | + TAG=${{ steps.tag.outputs.TAG }} + + git config --global user.email "ci@git.chemavx.xyz" + git config --global user.name "Gitea CI" + + git clone ${{ env.K8S_MANIFESTS_REPO }} /tmp/k8s-manifests + cd /tmp/k8s-manifests + + # Update bot image + sed -i "s|image: .*polymarket-bot.*|image: ${{ env.IMAGE_BOT }}:${TAG}|g" \ + polymarket-bot/deployment-bot.yaml + + # Update API image + sed -i "s|image: .*polymarket-bot-api.*|image: ${{ env.IMAGE_API }}:${TAG}|g" \ + polymarket-bot/deployment-api.yaml + + # Fix imagePullPolicy to Always (registry instead of local) + sed -i "s|imagePullPolicy: Never|imagePullPolicy: Always|g" \ + polymarket-bot/deployment-bot.yaml \ + polymarket-bot/deployment-api.yaml + + git add polymarket-bot/deployment-bot.yaml polymarket-bot/deployment-api.yaml + git diff --cached --quiet || git commit -m "ci: update polymarket-bot images to ${TAG} [skip ci]" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..672775e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +.env.* +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72343f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot/ ./bot/ +COPY api/ ./api/ + +CMD ["python3", "-m", "bot.main"] diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..ddc2619 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,11 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot/ ./bot/ +COPY api/ ./api/ + +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..52d7ebc --- /dev/null +++ b/api/main.py @@ -0,0 +1,75 @@ +""" +FastAPI Backend — serves metrics and trade data to the React dashboard. +""" +from contextlib import asynccontextmanager +import os +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from bot.data.db import Database + +db = Database() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await db.connect() + yield + await db.disconnect() + + +app = FastAPI(title="Polymarket Bot API", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health(): + return {"status": "ok", "paper_mode": os.getenv("PAPER_MODE", "true")} + + +@app.get("/api/metrics") +async def get_metrics(): + history = await db.get_metrics_history(days=42) + if not history: + return {"history": [], "latest": None} + return {"history": history, "latest": history[0]} + + +@app.get("/api/trades") +async def get_trades(limit: int = 50): + trades = await db.get_recent_trades(limit=limit) + return {"trades": trades, "count": len(trades)} + + +@app.get("/api/summary") +async def get_summary(): + """Dashboard summary card data.""" + history = await db.get_metrics_history(days=1) + trades = await db.get_recent_trades(limit=500) + + latest = history[0] if history else {} + paper_bankroll = float(os.getenv("PAPER_BANKROLL", "10000")) + total_deployed = sum(t.get("net_cost", 0) for t in trades) + + return { + "paper_mode": os.getenv("PAPER_MODE", "true") == "true", + "paper_bankroll": paper_bankroll, + "total_trades": len(trades), + "total_deployed": total_deployed, + "total_pnl": latest.get("total_pnl", 0), + "win_rate": latest.get("win_rate", 0), + "sharpe_ratio": latest.get("sharpe_ratio", 0), + "calibration_score": latest.get("calibration_score", 0), + "promotion_ready": ( + latest.get("sharpe_ratio", 0) >= 0.5 + and latest.get("win_rate", 0) >= 0.52 + and latest.get("calibration_score", 0) >= 0.7 + and len(trades) >= 50 + ), + } diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/data/__init__.py b/bot/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/data/db.py b/bot/data/db.py new file mode 100644 index 0000000..54dc61e --- /dev/null +++ b/bot/data/db.py @@ -0,0 +1,84 @@ +"""Database layer using asyncpg for PostgreSQL.""" +import logging +import os +from typing import Optional +import asyncpg + +log = logging.getLogger(__name__) + + +class Database: + def __init__(self) -> None: + self._url = os.getenv("DATABASE_URL", "postgresql://bot:bot@localhost:5432/polymarket") + self._pool: Optional[asyncpg.Pool] = None + + async def connect(self) -> None: + self._pool = await asyncpg.create_pool(self._url) + log.info("Database connected") + + async def disconnect(self) -> None: + if self._pool: + await self._pool.close() + + async def run_migrations(self) -> None: + schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") + with open(schema_path) as f: + schema = f.read() + async with self._pool.acquire() as conn: + await conn.execute(schema) + log.info("Migrations applied") + + async def save_trade(self, trade) -> None: + async with self._pool.acquire() as conn: + await conn.execute(""" + INSERT INTO trades ( + id, market_id, question, direction, size_usdc, + entry_price, shares, fee_usdc, net_cost, timestamp, reasoning, paper + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + ON CONFLICT (id) DO NOTHING + """, + trade.id, trade.market_id, trade.question, trade.direction, + trade.size_usdc, trade.entry_price, trade.shares, trade.fee_usdc, + trade.net_cost, trade.timestamp, trade.reasoning, trade.paper, + ) + + async def save_daily_metrics(self, metrics: dict) -> None: + async with self._pool.acquire() as conn: + await conn.execute(""" + INSERT INTO metrics_daily ( + timestamp, total_trades, total_deployed, total_fees, + total_pnl, win_rate, avg_edge, sharpe_ratio, calibration_score, paper_mode + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + """, + metrics["timestamp"], metrics["total_trades"], metrics["total_deployed"], + metrics["total_fees"], metrics["total_pnl"], metrics["win_rate"], + metrics["avg_edge"], metrics["sharpe_ratio"], metrics["calibration_score"], + metrics["paper_mode"], + ) + + async def get_open_positions(self) -> dict[str, float]: + """Return {market_id: total_net_cost} for all trades in DB. + + Since there is no closed flag, every trade in the DB is treated as an + open position. After a TRUNCATE the query returns nothing, so the + portfolio correctly resets to a full bankroll. + """ + async with self._pool.acquire() as conn: + rows = await conn.fetch( + "SELECT market_id, SUM(net_cost) AS total FROM trades GROUP BY market_id" + ) + return {r["market_id"]: float(r["total"]) for r in rows} + + async def get_recent_trades(self, limit: int = 100) -> list[dict]: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM trades ORDER BY timestamp DESC LIMIT $1", limit + ) + return [dict(r) for r in rows] + + async def get_metrics_history(self, days: int = 42) -> list[dict]: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM metrics_daily ORDER BY timestamp DESC LIMIT $1", days + ) + return [dict(r) for r in rows] diff --git a/bot/data/external.py b/bot/data/external.py new file mode 100644 index 0000000..3c24ecf --- /dev/null +++ b/bot/data/external.py @@ -0,0 +1,99 @@ +""" +External data signals for Bayesian probability estimation. +Sources: CoinGecko (crypto prices), Alternative.me (Fear&Greed), Polymarket trends +""" +import logging +from dataclasses import dataclass +import httpx + +log = logging.getLogger(__name__) + + +@dataclass +class ExternalSignals: + btc_price: float = 0.0 + btc_change_24h: float = 0.0 # % change + eth_price: float = 0.0 + eth_change_24h: float = 0.0 + btc_dominance: float = 50.0 # BTC market dominance % + fear_greed_index: int = 50 # 0=extreme fear, 100=extreme greed + fear_greed_label: str = "neutral" + total_market_cap_change: float = 0.0 + valid: bool = False + + +class ExternalDataClient: + """Fetches external market signals used to calibrate probability estimates.""" + + def __init__(self) -> None: + self._client = httpx.AsyncClient(timeout=15) + + async def get_all_signals(self) -> ExternalSignals: + """Aggregate all external signals. Returns best-effort (partial ok).""" + signals = ExternalSignals() + + try: + prices = await self._get_crypto_prices() + signals.btc_price = prices.get("bitcoin", {}).get("usd", 0) + signals.btc_change_24h = prices.get("bitcoin", {}).get("usd_24h_change", 0) + signals.eth_price = prices.get("ethereum", {}).get("usd", 0) + signals.eth_change_24h = prices.get("ethereum", {}).get("usd_24h_change", 0) + except Exception as e: + log.warning("CoinGecko fetch failed: %s", e) + + try: + fg = await self._get_fear_greed() + signals.fear_greed_index = fg["value"] + signals.fear_greed_label = fg["label"] + except Exception as e: + log.warning("Fear&Greed fetch failed: %s", e) + + try: + global_data = await self._get_global_market() + signals.btc_dominance = global_data.get("btc_dominance", 50) + signals.total_market_cap_change = global_data.get("market_cap_change_24h", 0) + except Exception as e: + log.warning("Global market data fetch failed: %s", e) + + signals.valid = signals.btc_price > 0 + log.info( + "External signals: BTC=$%.0f (%.1f%%) F&G=%d/%s", + signals.btc_price, + signals.btc_change_24h, + signals.fear_greed_index, + signals.fear_greed_label, + ) + return signals + + async def _get_crypto_prices(self) -> dict: + resp = await self._client.get( + "https://api.coingecko.com/api/v3/simple/price", + params={ + "ids": "bitcoin,ethereum", + "vs_currencies": "usd", + "include_24hr_change": True, + }, + ) + resp.raise_for_status() + return resp.json() + + async def _get_fear_greed(self) -> dict: + resp = await self._client.get("https://api.alternative.me/fng/?limit=1") + resp.raise_for_status() + data = resp.json()["data"][0] + return { + "value": int(data["value"]), + "label": data["value_classification"], + } + + async def _get_global_market(self) -> dict: + resp = await self._client.get("https://api.coingecko.com/api/v3/global") + resp.raise_for_status() + data = resp.json()["data"] + return { + "btc_dominance": data.get("market_cap_percentage", {}).get("btc", 50), + "market_cap_change_24h": data.get("market_cap_change_percentage_24h_usd", 0), + } + + async def close(self) -> None: + await self._client.aclose() diff --git a/bot/data/polymarket.py b/bot/data/polymarket.py new file mode 100644 index 0000000..6fc5bc2 --- /dev/null +++ b/bot/data/polymarket.py @@ -0,0 +1,213 @@ +""" +Polymarket CLOB API client. +Docs: https://docs.polymarket.com +""" +import asyncio +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone, timedelta +from typing import Optional +import httpx + +log = logging.getLogger(__name__) + +POLYMARKET_API = "https://clob.polymarket.com" +GAMMA_API = "https://gamma-api.polymarket.com" + + +@dataclass +class Market: + id: str + condition_id: str + question: str + yes_token_id: str + no_token_id: str + yes_price: float # 0-1, current best ask for YES + no_price: float + volume_24h: float + end_date: str + active: bool + category: str = "" + + +@dataclass +class OrderBook: + market_id: str + yes_bids: list[tuple[float, float]] = field(default_factory=list) # (price, size) + yes_asks: list[tuple[float, float]] = field(default_factory=list) + mid_price: float = 0.5 + + +class PolymarketClient: + """ + Async Polymarket client. + In paper mode, API key is not needed — only public data. + API key required for placing real orders. + """ + + def __init__(self) -> None: + self.api_key = os.getenv("POLYMARKET_API_KEY", "") + self.secret = os.getenv("POLYMARKET_SECRET", "") + self.passphrase = os.getenv("POLYMARKET_PASSPHRASE", "") + self._client = httpx.AsyncClient(timeout=30) + + # Keywords that identify crypto / finance markets. + # Short tickers are padded with spaces to avoid false substring matches + # (e.g. " eth " won't match "Hegseth"; " sol " won't match "solar"). + _CRYPTO_FINANCE_KEYWORDS: list[str] = [ + "bitcoin", "btc", " eth ", "ethereum", " sol ", "solana", + "xrp", "ripple", "dogecoin", "doge", "litecoin", "ltc", + "coinbase", "binance", "kraken", "bybit", "okx", + "usdc", "usdt", "stablecoin", + "defi", "nft", "blockchain", "crypto", + " fdv", "airdrop", "token launch", "token listing", + "microstrategy", "mstr", "saylor", + "nasdaq", "sp500", "s&p 500", "s&p500", + "federal reserve", "fed rate", "interest rate", + "inflation", "tariff", "treasury yield", + " ipo ", "sec ", "cftc", + ] + + @classmethod + def _is_crypto_finance(cls, question: str) -> bool: + q = f" {question.lower()} " # pad so edge keywords match cleanly + return any(kw in q for kw in cls._CRYPTO_FINANCE_KEYWORDS) + + async def get_active_markets( + self, + min_volume: float = 1000, + pages: int = 3, + page_size: int = 200, + max_days_to_resolution: int = 30, + ) -> list[Market]: + """Fetch active crypto/finance markets from Gamma API (no auth needed). + + Fetches events without tag filtering (tag= param is unreliable), + then keeps only markets whose question matches crypto/finance keywords + and that resolve within max_days_to_resolution days. + """ + seen: set[str] = set() + markets: list[Market] = [] + cutoff = datetime.now(timezone.utc) + timedelta(days=max_days_to_resolution) + + for page in range(pages): + try: + resp = await self._client.get( + f"{GAMMA_API}/events", + params={ + "active": True, + "closed": False, + "limit": page_size, + "offset": page * page_size, + }, + ) + resp.raise_for_status() + events = resp.json() + + if not events: + break # no more pages + + for event in events: + event_title = event.get("title", "") + for m in event.get("markets", []): + try: + if not m.get("active") or m.get("closed"): + continue + + question = m.get("question", "") + if not self._is_crypto_finance(question) and \ + not self._is_crypto_finance(event_title): + continue + + # Filter: only markets resolving within the cutoff window + # Gamma API may return endDate or end_date (snake_case) + raw_end = m.get("endDate") or m.get("end_date") or m.get("endDateIso", "") + if raw_end: + try: + end_dt = datetime.fromisoformat( + raw_end.replace("Z", "+00:00") + ) + # Make naive datetimes UTC-aware before comparing + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=timezone.utc) + if end_dt > cutoff: + continue + except (ValueError, TypeError): + pass # keep market if date unparseable + + market_id = str(m["id"]) + if market_id in seen: + continue + + vol = float(m.get("volume24hr", 0)) + if vol < min_volume: + continue + + raw_prices = m.get("outcomePrices", ["0.5", "0.5"]) + if isinstance(raw_prices, str): + import json as _json + raw_prices = _json.loads(raw_prices) + yes_price = float(raw_prices[0]) + + raw_tokens = m.get("clobTokenIds", ["", ""]) + if isinstance(raw_tokens, str): + import json as _json + raw_tokens = _json.loads(raw_tokens) + + seen.add(market_id) + markets.append(Market( + id=market_id, + condition_id=m.get("conditionId", ""), + question=question, + yes_token_id=raw_tokens[0] if raw_tokens else "", + no_token_id=raw_tokens[1] if len(raw_tokens) > 1 else "", + yes_price=yes_price, + no_price=1 - yes_price, + volume_24h=vol, + end_date=m.get("endDate", ""), + active=True, + category="crypto/finance", + )) + except (KeyError, ValueError, IndexError) as e: + log.debug("Skipping malformed market: %s", e) + + except httpx.HTTPError as e: + log.error("Polymarket API error (page=%d): %s", page, e) + break + + log.info( + "Loaded %d crypto/finance markets (min_vol=%.0f, resolving within %dd)", + len(markets), min_volume, max_days_to_resolution, + ) + return markets + + async def get_order_book(self, token_id: str) -> Optional[OrderBook]: + """Get order book for a specific token.""" + try: + resp = await self._client.get( + f"{POLYMARKET_API}/book", + params={"token_id": token_id}, + ) + resp.raise_for_status() + data = resp.json() + + bids = [(float(b["price"]), float(b["size"])) for b in data.get("bids", [])] + asks = [(float(a["price"]), float(a["size"])) for a in data.get("asks", [])] + + mid = 0.5 + if bids and asks: + mid = (bids[0][0] + asks[0][0]) / 2 + + return OrderBook( + market_id=token_id, + yes_bids=bids, + yes_asks=asks, + mid_price=mid, + ) + except Exception as e: + log.warning("Order book fetch failed for %s: %s", token_id, e) + return None + + async def close(self) -> None: + await self._client.aclose() diff --git a/bot/data/schema.sql b/bot/data/schema.sql new file mode 100644 index 0000000..8e1f105 --- /dev/null +++ b/bot/data/schema.sql @@ -0,0 +1,57 @@ +-- Polymarket Bot Database Schema + +CREATE TABLE IF NOT EXISTS trades ( + id TEXT PRIMARY KEY, + market_id TEXT NOT NULL, + question TEXT NOT NULL, + direction TEXT NOT NULL, -- BUY_YES | BUY_NO + size_usdc DOUBLE PRECISION, + entry_price DOUBLE PRECISION, + shares DOUBLE PRECISION, + fee_usdc DOUBLE PRECISION, + net_cost DOUBLE PRECISION, + timestamp TIMESTAMPTZ NOT NULL, + reasoning TEXT, + paper BOOLEAN DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS metrics_daily ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL, + total_trades INTEGER, + total_deployed DOUBLE PRECISION, + total_fees DOUBLE PRECISION, + total_pnl DOUBLE PRECISION, + win_rate DOUBLE PRECISION, + avg_edge DOUBLE PRECISION, + sharpe_ratio DOUBLE PRECISION, + calibration_score DOUBLE PRECISION, + paper_mode BOOLEAN DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS markets ( + id TEXT PRIMARY KEY, + condition_id TEXT, + question TEXT NOT NULL, + category TEXT, + end_date TEXT, + active BOOLEAN DEFAULT TRUE, + last_seen TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS signals ( + id SERIAL PRIMARY KEY, + market_id TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + polymarket_price DOUBLE PRECISION, + estimated_prob DOUBLE PRECISION, + edge DOUBLE PRECISION, + confidence DOUBLE PRECISION, + direction TEXT, + acted_on BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_trades_timestamp ON trades(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_trades_market ON trades(market_id); +CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics_daily(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_signals_timestamp ON signals(timestamp DESC); diff --git a/bot/executor/__init__.py b/bot/executor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/executor/paper.py b/bot/executor/paper.py new file mode 100644 index 0000000..3bd4629 --- /dev/null +++ b/bot/executor/paper.py @@ -0,0 +1,142 @@ +""" +Paper Trading Executor — simulates order execution without real money. + +Simulates realistic slippage and fees to get accurate paper P&L. +All trades are logged to PostgreSQL for metrics analysis. +""" +import logging +import uuid +from dataclasses import dataclass +from datetime import datetime, UTC +from typing import Optional + +from bot.risk.manager import Order, Portfolio +from bot.data.db import Database + +log = logging.getLogger(__name__) + +POLYMARKET_FEE = 0.02 # 2% fee on each trade + + +@dataclass +class Trade: + id: str + market_id: str + question: str + direction: str + size_usdc: float + entry_price: float + shares: float # How many YES/NO shares bought + fee_usdc: float + net_cost: float + timestamp: datetime + reasoning: str + paper: bool = True + + def __str__(self) -> str: + return ( + f"[PAPER] {self.direction} {self.shares:.1f} shares @ {self.entry_price:.3f} " + f"= ${self.net_cost:.2f} (fee ${self.fee_usdc:.2f}) | {self.question[:40]}" + ) + + +class PaperExecutor: + """Executes trades on paper — no real money, realistic simulation.""" + + def __init__(self, db: Database, bankroll: float) -> None: + self._db = db + self._portfolio = Portfolio( + cash=bankroll, + positions={}, + ) + log.info("Paper executor initialized with $%.2f bankroll", bankroll) + + async def initialize(self) -> None: + """Reconcile in-memory portfolio with DB state. + + Called once after __init__ so the executor reflects any trades that + survived a pod restart. After a TRUNCATE the DB is empty and the + portfolio resets to a full bankroll automatically. + """ + positions = await self._db.get_open_positions() + if not positions: + log.info("No open positions in DB — starting with full bankroll") + return + + total_deployed = sum(positions.values()) + self._portfolio.positions = positions + self._portfolio.cash = max(0.0, self._portfolio.cash - total_deployed) + log.info( + "Restored %d open position(s) from DB — deployed $%.2f, cash $%.2f", + len(positions), + total_deployed, + self._portfolio.cash, + ) + + def get_portfolio(self) -> Portfolio: + return self._portfolio + + async def execute(self, order: Order) -> Optional[Trade]: + """Simulate order execution with fees and slippage.""" + if order.size_usdc > self._portfolio.cash: + log.warning( + "Insufficient paper cash: need $%.2f have $%.2f", + order.size_usdc, + self._portfolio.cash, + ) + return None + + # Simulate small slippage (0.1-0.3% depending on order size) + slippage = min(0.003, order.size_usdc / 100000) + + # Determine entry price based on direction. + # We fill at the current Polymarket mid price + slippage (buying at ask). + # BUY_YES → paying the YES price (order.market_price) + # BUY_NO → paying the NO price (1 - order.market_price) + if order.direction == "BUY_YES": + entry_price = min(0.99, order.market_price + slippage) + else: + entry_price = min(0.99, (1 - order.market_price) + slippage) + + fee = order.size_usdc * POLYMARKET_FEE + net_cost = order.size_usdc + fee + shares = order.size_usdc / entry_price + + trade = Trade( + id=str(uuid.uuid4()), + market_id=order.market_id, + question=order.question, + direction=order.direction, + size_usdc=order.size_usdc, + entry_price=entry_price, + shares=shares, + fee_usdc=fee, + net_cost=net_cost, + timestamp=datetime.now(UTC), + reasoning=order.reasoning, + paper=True, + ) + + # Update paper portfolio + self._portfolio.cash -= net_cost + self._portfolio.positions[order.market_id] = order.size_usdc + + # Persist to DB + await self._db.save_trade(trade) + + return trade + + async def close_position(self, market_id: str, resolution: float) -> Optional[float]: + """ + Close a paper position after market resolution. + resolution: 1.0 if YES won, 0.0 if NO won. + Returns P&L in USDC. + """ + if market_id not in self._portfolio.positions: + return None + + # This would be called by a settlement watcher (future feature) + # For now, positions auto-expire at market end date + position_cost = self._portfolio.positions.pop(market_id) + log.info("Closed position in %s, resolution=%.0f", market_id, resolution) + return position_cost * resolution - position_cost diff --git a/bot/executor/real.py b/bot/executor/real.py new file mode 100644 index 0000000..0bc5a3f --- /dev/null +++ b/bot/executor/real.py @@ -0,0 +1,63 @@ +""" +REAL TRADING EXECUTOR — Only loaded when PAPER_MODE=false. + +DO NOT MODIFY unless explicitly asked and paper trading thresholds are met: + - Sharpe Ratio > 0.5 (over 4+ weeks) + - Win Rate > 52% + - Calibration Score > 0.7 + - Explicit `make promote` confirmation + +This file is intentionally minimal until paper trading validates the strategy. +""" +import logging +import os +from typing import Optional + +from bot.risk.manager import Order +from bot.executor.paper import Trade +from bot.data.db import Database + +log = logging.getLogger(__name__) + + +class RealExecutor: + """ + Real money executor using py-clob-client. + Requires: POLYMARKET_API_KEY, POLYMARKET_SECRET, POLYMARKET_PASSPHRASE, WALLET_PRIVATE_KEY + """ + + def __init__(self, db: Database) -> None: + # Safety check + if os.getenv("PAPER_MODE", "true").lower() == "true": + raise RuntimeError("RealExecutor instantiated while PAPER_MODE=true — this is a bug!") + + log.critical("REAL EXECUTOR ACTIVE — Real USDC will be spent") + self._db = db + self._client = self._init_client() + + def _init_client(self): + try: + from py_clob_client.client import ClobClient + from py_clob_client.clob_types import ApiCreds + + host = "https://clob.polymarket.com" + key = os.getenv("WALLET_PRIVATE_KEY", "") + chain_id = 137 # Polygon + + creds = ApiCreds( + api_key=os.getenv("POLYMARKET_API_KEY", ""), + api_secret=os.getenv("POLYMARKET_SECRET", ""), + api_passphrase=os.getenv("POLYMARKET_PASSPHRASE", ""), + ) + return ClobClient(host, key=key, chain_id=chain_id, creds=creds) + except ImportError: + raise ImportError("py-clob-client not installed. Run: pip install py-clob-client") + + async def execute(self, order: Order) -> Optional[Trade]: + """Execute real order on Polymarket CLOB.""" + # TODO: Implement after paper trading phase validates strategy + # See: https://docs.polymarket.com/developers/clob/examples + raise NotImplementedError("Real executor not yet implemented — complete paper trading phase first") + + def get_portfolio(self): + raise NotImplementedError("Real portfolio tracking not yet implemented") diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..d7b9f06 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,120 @@ +""" +Polymarket Trading Bot — Main Entry Point +""" +import asyncio +import logging +import os +from contextlib import asynccontextmanager + +from bot.data.polymarket import PolymarketClient +from bot.data.external import ExternalDataClient +from bot.strategy.bayesian import BayesianStrategy +from bot.risk.manager import RiskManager +from bot.executor.paper import PaperExecutor +from bot.metrics.tracker import MetricsTracker +from bot.data.db import Database + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +log = logging.getLogger("bot.main") + +PAPER_MODE = os.getenv("PAPER_MODE", "true").lower() == "true" +PAPER_BANKROLL = float(os.getenv("PAPER_BANKROLL", "10000")) + + +async def run_trading_loop( + poly: PolymarketClient, + external: ExternalDataClient, + strategy: BayesianStrategy, + risk: RiskManager, + executor: PaperExecutor, + metrics: MetricsTracker, +) -> None: + """Main trading loop — runs every 60 seconds.""" + log.info("Trading loop started. PAPER_MODE=%s", PAPER_MODE) + + while True: + try: + # 1. Fetch active crypto/finance markets + markets = await poly.get_active_markets() + log.info("Found %d active markets", len(markets)) + for _m in markets: + log.info(" [market] %s | ends: %s | yes_price: %.3f", + _m.question, _m.end_date, _m.yes_price) + + # 2. Get external signals + ext_data = await external.get_all_signals() + + for market in markets: + # 3. Estimate true probability + signal = await strategy.evaluate(market, ext_data) + if signal is None: + continue + + log.info( + "Signal: market=%s poly_price=%.3f our_estimate=%.3f confidence=%.2f", + market.question[:50], + signal.polymarket_price, + signal.estimated_prob, + signal.confidence, + ) + + # 4. Risk check + position sizing + order = risk.size_order(signal, executor.get_portfolio()) + if order is None: + log.debug("Risk manager rejected order for %s", market.id) + continue + + # 5. Execute (paper or real) + trade = await executor.execute(order) + if trade: + await metrics.record_trade(trade) + log.info("Trade executed: %s", trade) + + # 6. Update daily metrics + await metrics.update_daily_summary() + + except Exception as e: + log.error("Error in trading loop: %s", e, exc_info=True) + + await asyncio.sleep(60) + + +async def main() -> None: + if PAPER_MODE: + log.info("=" * 60) + log.info(" PAPER TRADING MODE — No real money at risk") + log.info(" Bankroll: $%.2f simulated", PAPER_BANKROLL) + log.info("=" * 60) + else: + log.warning("REAL TRADING MODE ACTIVE — Real money at risk!") + + db = Database() + await db.connect() + await db.run_migrations() + + poly = PolymarketClient() + external = ExternalDataClient() + strategy = BayesianStrategy() + risk = RiskManager(max_position_pct=0.05, max_exposure_pct=0.30) + executor = PaperExecutor(db=db, bankroll=PAPER_BANKROLL) if PAPER_MODE else None + metrics = MetricsTracker(db=db) + + if executor is None: + # Import real executor only when explicitly needed + from bot.executor.real import RealExecutor # noqa + executor = RealExecutor(db=db) + + if PAPER_MODE: + await executor.initialize() + + try: + await run_trading_loop(poly, external, strategy, risk, executor, metrics) + finally: + await db.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bot/metrics/__init__.py b/bot/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/metrics/tracker.py b/bot/metrics/tracker.py new file mode 100644 index 0000000..2eaf496 --- /dev/null +++ b/bot/metrics/tracker.py @@ -0,0 +1,130 @@ +""" +Metrics Tracker — Computes trading performance metrics. + +Key metrics tracked: +- P&L (cumulative and daily) +- Sharpe Ratio (annualized) +- Win Rate +- Calibration Score (how accurate our probability estimates are) +- Max Drawdown +- Average Edge realized +""" +import logging +import math +from datetime import datetime, UTC +from typing import Optional + +from bot.executor.paper import Trade +from bot.data.db import Database + +log = logging.getLogger(__name__) + + +class MetricsTracker: + def __init__(self, db: Database) -> None: + self._db = db + self._trades: list[Trade] = [] + self._daily_returns: list[float] = [] + + async def record_trade(self, trade: Trade) -> None: + self._trades.append(trade) + await self._db.save_trade(trade) + log.info("Trade recorded. Total trades: %d", len(self._trades)) + + async def update_daily_summary(self) -> None: + """Compute and store daily metrics snapshot.""" + if not self._trades: + return + + metrics = self.compute_metrics() + await self._db.save_daily_metrics(metrics) + + log.info( + "Daily metrics | Trades: %d | P&L: $%.2f | Win: %.1f%% | Sharpe: %.2f", + metrics["total_trades"], + metrics["total_pnl"], + metrics["win_rate"] * 100, + metrics["sharpe_ratio"], + ) + + def compute_metrics(self) -> dict: + if not self._trades: + return self._empty_metrics() + + trades = self._trades + n = len(trades) + + # Total cost deployed + total_deployed = sum(t.net_cost for t in trades) + total_fees = sum(t.fee_usdc for t in trades) + + # Win rate (trades where we had positive edge — in paper mode we estimate) + # A trade "wins" if entry_price < 0.5 (buying undervalued token) + wins = sum(1 for t in trades if t.entry_price < 0.5) + win_rate = wins / n if n > 0 else 0 + + # Estimated P&L (paper — based on edge captured) + # Edge = (estimated_prob - entry_price) * shares + total_pnl = sum( + (0.5 - t.entry_price) * t.shares - t.fee_usdc + for t in trades + ) + + # Average edge per trade + avg_edge = total_pnl / total_deployed if total_deployed > 0 else 0 + + # Sharpe ratio (simplified — daily returns not yet available in paper mode) + # Will improve once markets resolve and we have actual returns + sharpe = self._compute_sharpe() + + # Calibration score (Brier score based) + # Perfect calibration = 1.0, random = 0.0 + calibration = 1 - (2 * abs(avg_edge)) # Simplified until markets resolve + + return { + "timestamp": datetime.now(UTC), + "total_trades": n, + "total_deployed": total_deployed, + "total_fees": total_fees, + "total_pnl": total_pnl, + "win_rate": win_rate, + "avg_edge": avg_edge, + "sharpe_ratio": sharpe, + "calibration_score": max(0, min(1, calibration)), + "paper_mode": True, + } + + def _compute_sharpe(self) -> float: + """Annualized Sharpe ratio from daily returns.""" + if len(self._daily_returns) < 2: + return 0.0 + mean_r = sum(self._daily_returns) / len(self._daily_returns) + variance = sum((r - mean_r) ** 2 for r in self._daily_returns) / len(self._daily_returns) + std_r = math.sqrt(variance) if variance > 0 else 1e-9 + return (mean_r / std_r) * math.sqrt(365) # Annualize + + def check_promotion_thresholds(self) -> tuple[bool, dict]: + """Check if metrics qualify for real money trading.""" + metrics = self.compute_metrics() + checks = { + "sharpe_ratio": (metrics["sharpe_ratio"], 0.5, metrics["sharpe_ratio"] >= 0.5), + "win_rate": (metrics["win_rate"], 0.52, metrics["win_rate"] >= 0.52), + "calibration_score": (metrics["calibration_score"], 0.7, metrics["calibration_score"] >= 0.7), + "min_trades": (metrics["total_trades"], 50, metrics["total_trades"] >= 50), + } + all_pass = all(v[2] for v in checks.values()) + return all_pass, checks + + def _empty_metrics(self) -> dict: + return { + "timestamp": datetime.now(UTC), + "total_trades": 0, + "total_deployed": 0, + "total_fees": 0, + "total_pnl": 0, + "win_rate": 0, + "avg_edge": 0, + "sharpe_ratio": 0, + "calibration_score": 0, + "paper_mode": True, + } diff --git a/bot/risk/__init__.py b/bot/risk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/risk/manager.py b/bot/risk/manager.py new file mode 100644 index 0000000..785a41f --- /dev/null +++ b/bot/risk/manager.py @@ -0,0 +1,128 @@ +""" +Risk Manager — Kelly Criterion position sizing with safety constraints. + +Uses 1/4 Kelly fraction to be conservative during paper trading phase. +Hard limits: max 5% per position, max 30% total exposure. +""" +import logging +from dataclasses import dataclass +from typing import Optional + +from bot.strategy.bayesian import TradingSignal + +log = logging.getLogger(__name__) + +KELLY_FRACTION = 0.25 # Quarter Kelly — conservative + + +@dataclass +class Portfolio: + cash: float + positions: dict[str, float] # market_id -> USDC amount allocated + + @property + def total_value(self) -> float: + return self.cash + sum(self.positions.values()) + + @property + def total_exposure(self) -> float: + return sum(self.positions.values()) + + @property + def exposure_pct(self) -> float: + if self.total_value == 0: + return 0 + return self.total_exposure / self.total_value + + +@dataclass +class Order: + market_id: str + question: str + direction: str # "BUY_YES" | "BUY_NO" + size_usdc: float # Amount to risk in USDC + market_price: float # Polymarket YES price (0-1) — used for entry_price calculation + signal_edge: float + signal_confidence: float + reasoning: str + + +class RiskManager: + def __init__( + self, + max_position_pct: float = 0.05, + max_exposure_pct: float = 0.30, + ) -> None: + self.max_position_pct = max_position_pct + self.max_exposure_pct = max_exposure_pct + + def size_order( + self, + signal: TradingSignal, + portfolio: Portfolio, + ) -> Optional[Order]: + """ + Apply Kelly criterion to size the order. + Returns None if constraints are not met. + """ + # Check total exposure limit + if portfolio.exposure_pct >= self.max_exposure_pct: + log.info( + "Exposure limit reached: %.1f%% >= %.1f%%", + portfolio.exposure_pct * 100, + self.max_exposure_pct * 100, + ) + return None + + # Check if already in this market + if signal.market_id in portfolio.positions: + log.debug("Already have position in market %s", signal.market_id) + return None + + # Kelly formula: f = (bp - q) / b + # b = odds (1/price - 1), p = estimated_prob, q = 1 - p + price = signal.polymarket_price if signal.direction == "BUY_YES" else (1 - signal.polymarket_price) + if price <= 0 or price >= 1: + return None + + b = (1 / price) - 1 # decimal odds + p = signal.estimated_prob if signal.direction == "BUY_YES" else (1 - signal.estimated_prob) + q = 1 - p + + kelly_full = (b * p - q) / b + if kelly_full <= 0: + log.debug("Kelly fraction negative — no edge after fees") + return None + + kelly_fraction = kelly_full * KELLY_FRACTION + + # Apply position size limits + max_by_kelly = portfolio.total_value * kelly_fraction + max_by_rule = portfolio.total_value * self.max_position_pct + remaining_exposure = portfolio.total_value * self.max_exposure_pct - portfolio.total_exposure + + size = min(max_by_kelly, max_by_rule, remaining_exposure, portfolio.cash) + + if size < 5: # Minimum trade size $5 + log.debug("Order too small: $%.2f", size) + return None + + log.info( + "Order sized: %s %s $%.2f (kelly=%.1f%% capped at %.1f%%)", + signal.direction, + signal.question[:40], + size, + kelly_fraction * 100, + self.max_position_pct * 100, + ) + + return Order( + market_id=signal.market_id, + question=signal.question, + direction=signal.direction, + size_usdc=size, + market_price=signal.polymarket_price, + signal_edge=signal.edge, + signal_confidence=signal.confidence, + reasoning=signal.reasoning, + ) diff --git a/bot/strategy/__init__.py b/bot/strategy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py new file mode 100644 index 0000000..358090a --- /dev/null +++ b/bot/strategy/bayesian.py @@ -0,0 +1,196 @@ +""" +Bayesian Market Making Strategy. + +Core idea: +1. Compute a prior probability for a market outcome using external data +2. Compare with Polymarket's current price +3. If divergence > threshold + confidence is high enough → generate signal + +For crypto markets: if BTC is up 5% and fear/greed is 75 (greed), +a market asking "Will BTC be above $X?" should be priced higher than +Polymarket might reflect in a slow-moving order book. +""" +import logging +import math +from dataclasses import dataclass +from typing import Optional + +from bot.data.polymarket import Market +from bot.data.external import ExternalSignals + +log = logging.getLogger(__name__) + +# Minimum edge required to place a trade. +# With an informed prior (poly price), 10% means our signals strongly disagree +# with the market — much higher bar than before, but necessary to avoid noise. +MIN_EDGE = 0.10 # 10% edge minimum +MIN_CONFIDENCE = 0.55 # Minimum confidence in our estimate + + +@dataclass +class TradingSignal: + market_id: str + question: str + polymarket_price: float # Current market price for YES (0-1) + estimated_prob: float # Our Bayesian estimate (0-1) + edge: float # estimated_prob - polymarket_price + confidence: float # How confident we are (0-1) + direction: str # "BUY_YES" | "BUY_NO" + reasoning: str # Human-readable explanation for logging + sources: list[str] # Data sources used + + +class BayesianStrategy: + """ + Estimates true probability using external signals and Bayesian updating. + + Prior: Polymarket's current YES price (market consensus — not 0.5) + Likelihood updates from: + - BTC/ETH price momentum + - Fear & Greed index + - Market cap trend / BTC dominance + We only bet when our signals move the estimate far enough from the prior + to justify the fee + slippage cost (MIN_EDGE). + """ + + def __init__(self) -> None: + self._signal_count = 0 + + async def evaluate( + self, + market: Market, + ext: ExternalSignals, + ) -> Optional[TradingSignal]: + """ + Evaluate a market and return a signal if edge exists. + Returns None if no actionable opportunity. + """ + question_lower = market.question.lower() + + # Classify what kind of market this is + is_price_above = any(w in question_lower for w in ["above", "over", "exceed", "higher", "atleast", "reach"]) + is_price_below = any(w in question_lower for w in ["below", "under", "less than", "lower", "drop"]) + + is_btc = "btc" in question_lower or "bitcoin" in question_lower + is_eth = "eth" in question_lower or "ethereum" in question_lower + is_sol = "sol" in question_lower or "solana" in question_lower + is_xrp = "xrp" in question_lower or "ripple" in question_lower + is_doge = "doge" in question_lower or "dogecoin" in question_lower + is_altcoin = is_sol or is_xrp or is_doge or any( + w in question_lower for w in ["ltc", "litecoin", "bnb", "ada", "cardano", "avax", "avalanche"] + ) + is_general_crypto = any( + w in question_lower for w in ["crypto", "market cap", "total market", "altcoin", "defi"] + ) + is_macro = any( + w in question_lower for w in ["nasdaq", "s&p", "sp500", "inflation", "fed rate", "interest rate", "tariff"] + ) + + is_any_supported = is_btc or is_eth or is_altcoin or is_general_crypto or is_macro + if not is_any_supported: + log.debug("Skipping unsupported market: %s", market.question[:60]) + return None + + if not ext.valid: + return None # Can't reason without external data + + # --- Bayesian probability estimation --- + # Prior = Polymarket consensus price, clamped away from extremes. + # The market already aggregates information from many traders; + # our signals update from that informed baseline, not from 0.5. + prior = max(0.05, min(0.95, market.yes_price)) + sources: list[str] = [f"Prior=poly({prior:.3f})"] + adjustments: list[float] = [] + + # Signal 1: Price momentum (asset-specific or total market cap as proxy) + if is_btc: + momentum = ext.btc_change_24h + asset_label = "BTC" + elif is_eth: + momentum = ext.eth_change_24h + asset_label = "ETH" + else: + # Altcoins and general crypto: use total market cap change as proxy + momentum = ext.total_market_cap_change + asset_label = "total mktcap" + + if abs(momentum) > 2: + momentum_adj = math.tanh(momentum / 20) * 0.15 # Max ±15% + adjustments.append(momentum_adj if is_price_above else -momentum_adj) + sources.append(f"{asset_label} 24h: {momentum:+.1f}%") + + # Signal 2: Fear & Greed + fg = ext.fear_greed_index + if fg > 70: + fg_adj = 0.06 + sources.append(f"Fear&Greed: {fg} (greed)") + elif fg < 30: + fg_adj = -0.06 + sources.append(f"Fear&Greed: {fg} (fear)") + else: + fg_adj = (fg - 50) / 50 * 0.04 + sources.append(f"Fear&Greed: {fg} (neutral)") + + adjustments.append(fg_adj if is_price_above else -fg_adj) + + # Signal 3: BTC dominance — hurts altcoins when high + if (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance > 55: + dom_adj = -0.03 if is_price_above else 0.03 + adjustments.append(dom_adj) + sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (high → alt pressure)") + elif (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance < 45: + dom_adj = 0.03 if is_price_above else -0.03 + adjustments.append(dom_adj) + sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (low → alt season)") + + # Macro markets: lower weight, rely only on Fear&Greed signal already added + # Cap confidence below for macro to reflect weaker signal quality + confidence_cap = 0.70 if is_macro else 0.90 + + # Compute posterior using log-odds updating + log_odds_prior = math.log(prior / (1 - prior)) + total_adj = sum(adjustments) + estimated_prob = _sigmoid(log_odds_prior + total_adj * 2) + estimated_prob = max(0.05, min(0.95, estimated_prob)) + + # Compute edge + edge = estimated_prob - market.yes_price + direction = "BUY_YES" if edge > 0 else "BUY_NO" + abs_edge = abs(edge) + + # Confidence based on signal agreement + agreement = sum(1 for a in adjustments if (a > 0) == (total_adj > 0)) + confidence = min(confidence_cap, 0.4 + (agreement / max(len(adjustments), 1)) * 0.5) + + # Filter: only trade if edge and confidence thresholds met + if abs_edge < MIN_EDGE or confidence < MIN_CONFIDENCE: + log.debug( + "No signal: edge=%.3f confidence=%.2f market=%s", + abs_edge, confidence, market.question[:40] + ) + return None + + reasoning = ( + f"Prior=poly({prior:.3f}) → estimate={estimated_prob:.3f} | " + f"Poly price={market.yes_price:.3f} | " + f"Edge={edge:+.3f} | " + f"Direction={direction} | " + f"Signals: {', '.join(sources[1:])}" # skip the prior label already shown + ) + + self._signal_count += 1 + return TradingSignal( + market_id=market.id, + question=market.question, + polymarket_price=market.yes_price, + estimated_prob=estimated_prob, + edge=abs_edge, + confidence=confidence, + direction=direction, + reasoning=reasoning, + sources=sources, + ) + + +def _sigmoid(x: float) -> float: + return 1 / (1 + math.exp(-x)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ba871dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# Core +asyncpg==0.29.0 +httpx==0.27.0 +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +pydantic==2.7.0 + +# Polymarket (install from PyPI when ready for real trading) +# py-clob-client==0.17.0 + +# Utils +python-dotenv==1.0.1 + +# Testing +pytest==8.2.0 +pytest-asyncio==0.23.6 +httpx==0.27.0