feat: initial commit — polymarket-bot source + CI/CD pipeline
CI/CD / build-and-push (push) Failing after 30s
CI/CD / build-and-push (push) Failing after 30s
This commit is contained in:
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user