feat: initial commit — polymarket-bot source + CI/CD pipeline
CI/CD / build-and-push (push) Failing after 30s

This commit is contained in:
2026-04-13 16:05:45 +00:00
commit 4fda34df3b
23 changed files with 1436 additions and 0 deletions
+81
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.env
.env.*
*.egg-info/
dist/
build/
.pytest_cache/
+11
View File
@@ -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"]
+11
View File
@@ -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"]
View File
+75
View File
@@ -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
),
}
View File
View File
+84
View File
@@ -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]
+99
View File
@@ -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()
+213
View File
@@ -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()
+57
View File
@@ -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);
View File
+142
View File
@@ -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
+63
View File
@@ -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")
+120
View File
@@ -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())
View File
+130
View File
@@ -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,
}
View File
+128
View File
@@ -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,
)
View File
+196
View File
@@ -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))
+17
View File
@@ -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