HK Weather Prediction Market Pipeline: WeatherNext + HKO + Polymarket
- Open-Meteo WeatherNext API client for HK forecasts - HKO public data client (current conditions, 9-day forecast, typhoon warnings) - HK-specific weather extraction and calibration - Polymarket market scanning, price discovery, and market creation proposals - Trading strategy engine: edge detection, Kelly criterion sizing, probability calibration - End-to-end pipeline with dry-run mode and scheduled runner - Interactive dashboard with live HK weather + forecasts + trading signals Dependencies: Python 3.10+, openmeteo-requests, pandas No API keys needed for dry-run mode. Polymarket trading requires private key in .env.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Polymarket integration for HK weather prediction markets."""
|
||||
|
||||
from .polymarket_client import PolymarketClient
|
||||
from .trader import Trader
|
||||
|
||||
__all__ = ["PolymarketClient", "Trader"]
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Polymarket API client for market data and trading.
|
||||
|
||||
Uses the Gamma Markets API for market discovery and CLOB for order execution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from config import (
|
||||
POLYMARKET_GAMMA_API,
|
||||
POLYMARKET_CLOB_API,
|
||||
MIN_LIQUIDITY_USDC,
|
||||
)
|
||||
|
||||
|
||||
class PolymarketClient:
|
||||
"""Client for Polymarket prediction markets."""
|
||||
|
||||
def __init__(self):
|
||||
self.gamma_url = POLYMARKET_GAMMA_API
|
||||
self.clob_url = POLYMARKET_CLOB_API
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "HK-Weather-Market/1.0",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
|
||||
def search_markets(
|
||||
self,
|
||||
query: str = "hong kong weather",
|
||||
active: bool = True,
|
||||
limit: int = 20,
|
||||
) -> List[Dict]:
|
||||
"""Search for weather-related markets on Polymarket."""
|
||||
params = {
|
||||
"query": query,
|
||||
"active": str(active).lower(),
|
||||
"closed": "false",
|
||||
"limit": limit,
|
||||
"archived": "false",
|
||||
"order": "liquidity",
|
||||
}
|
||||
|
||||
try:
|
||||
url = f"{self.gamma_url}/markets?{urlencode(params)}"
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
markets = resp.json()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.get("id"),
|
||||
"question": m.get("question"),
|
||||
"condition_id": m.get("conditionId"),
|
||||
"slug": m.get("slug"),
|
||||
"volume": float(m.get("volume", 0)),
|
||||
"liquidity": float(m.get("liquidity", 0)),
|
||||
"volume_24hr": float(m.get("volume24hr", 0)),
|
||||
"end_date": m.get("endDateIso"),
|
||||
"start_date": m.get("startDateIso"),
|
||||
"active": m.get("active"),
|
||||
"closed": m.get("closed"),
|
||||
"outcome_prices": json.loads(m.get("outcomePrices", "[]")),
|
||||
"outcomes": json.loads(m.get("outcomes", "[]")),
|
||||
"description": m.get("description", ""),
|
||||
"category": m.get("category", ""),
|
||||
"tags": m.get("tags", []),
|
||||
}
|
||||
for m in markets
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Polymarket search error: {e}")
|
||||
return []
|
||||
|
||||
def get_market(self, condition_id: str) -> Optional[Dict]:
|
||||
"""Get a single market by condition ID."""
|
||||
try:
|
||||
url = f"{self.gamma_url}/markets/{condition_id}"
|
||||
resp = self.session.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
m = resp.json()
|
||||
|
||||
return {
|
||||
"id": m.get("id"),
|
||||
"question": m.get("question"),
|
||||
"condition_id": m.get("conditionId"),
|
||||
"slug": m.get("slug"),
|
||||
"volume": float(m.get("volume", 0)),
|
||||
"liquidity": float(m.get("liquidity", 0)),
|
||||
"end_date": m.get("endDateIso"),
|
||||
"active": m.get("active"),
|
||||
"closed": m.get("closed"),
|
||||
"outcome_prices": json.loads(m.get("outcomePrices", "[]")),
|
||||
"outcomes": json.loads(m.get("outcomes", "[]")),
|
||||
"description": m.get("description", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Polymarket get_market error: {e}")
|
||||
return None
|
||||
|
||||
def get_market_price(self, token_id: str) -> Optional[float]:
|
||||
"""Get current price for a specific outcome token."""
|
||||
try:
|
||||
url = f"{self.clob_url}/price?token_id={token_id}&side=buy"
|
||||
resp = self.session.get(url, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return float(data.get("price", 0))
|
||||
except Exception as e:
|
||||
print(f"Polymarket price error: {e}")
|
||||
return None
|
||||
|
||||
def get_order_book(self, token_id: str) -> Dict:
|
||||
"""Get order book for a token."""
|
||||
try:
|
||||
url = f"{self.clob_url}/book?token_id={token_id}"
|
||||
resp = self.session.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"Polymarket orderbook error: {e}")
|
||||
return {}
|
||||
|
||||
def find_relevant_weather_markets(self) -> List[Dict]:
|
||||
"""Find all weather-related markets relevant to Hong Kong."""
|
||||
queries = [
|
||||
"hong kong weather",
|
||||
"hong kong temperature",
|
||||
"hong kong typhoon",
|
||||
"hong kong rain",
|
||||
"asia typhoon",
|
||||
"south china sea",
|
||||
]
|
||||
|
||||
all_markets = []
|
||||
seen_ids = set()
|
||||
|
||||
for query in queries:
|
||||
markets = self.search_markets(query=query)
|
||||
for m in markets:
|
||||
if m["id"] not in seen_ids and m.get("active") and not m.get("closed"):
|
||||
seen_ids.add(m["id"])
|
||||
all_markets.append(m)
|
||||
|
||||
all_markets.sort(key=lambda m: m.get("liquidity", 0), reverse=True)
|
||||
|
||||
return all_markets
|
||||
|
||||
def get_market_implied_probability(
|
||||
self, condition_id: str, outcome_index: int = 0
|
||||
) -> Optional[float]:
|
||||
"""Get market-implied probability for a specific outcome.
|
||||
|
||||
Uses midpoint of best bid/ask when available, otherwise last price.
|
||||
"""
|
||||
market = self.get_market(condition_id)
|
||||
if not market or "outcome_prices" not in market:
|
||||
return None
|
||||
|
||||
if outcome_index < len(market["outcome_prices"]):
|
||||
return float(market["outcome_prices"][outcome_index])
|
||||
|
||||
return None
|
||||
|
||||
def get_clob_token_id(self, condition_id: str, outcome_index: int = 0) -> Optional[str]:
|
||||
"""Get CLOB token ID for a market outcome.
|
||||
|
||||
Token IDs are derived deterministically from condition ID + outcome index.
|
||||
"""
|
||||
try:
|
||||
url = f"{self.clob_url}/markets/{condition_id}"
|
||||
resp = self.session.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
tokens = data.get("tokens", [])
|
||||
if outcome_index < len(tokens):
|
||||
return tokens[outcome_index].get("token_id")
|
||||
except Exception as e:
|
||||
print(f"CLOB token ID error: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def create_market(
|
||||
self,
|
||||
question: str,
|
||||
outcomes: List[str],
|
||||
end_date: str,
|
||||
description: str = "",
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Create a new market on Polymarket.
|
||||
NOTE: Requires whitelisted API key and Polygonscan approval.
|
||||
Markets go through curation before going live.
|
||||
"""
|
||||
print("Market creation requires curation approval from Polymarket.")
|
||||
print(f"Would create: {question}")
|
||||
print(f"Outcomes: {outcomes}")
|
||||
print(f"End date: {end_date}")
|
||||
return {
|
||||
"status": "proposed",
|
||||
"question": question,
|
||||
"outcomes": outcomes,
|
||||
"end_date": end_date,
|
||||
"note": "Submit via Polymarket UI or contact partnerships@polymarket.com",
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Trading execution engine for Polymarket weather markets.
|
||||
|
||||
Handles order placement, position sizing, and risk management
|
||||
for the HK weather prediction market strategy.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .polymarket_client import PolymarketClient
|
||||
from config import MIN_EDGE_BPS, MAX_POSITION_USDC, MIN_LIQUIDITY_USDC
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeSignal:
|
||||
"""A trading signal from the strategy engine."""
|
||||
market_id: str
|
||||
condition_id: str
|
||||
question: str
|
||||
outcome_index: int
|
||||
outcome_label: str
|
||||
model_probability: float # Our model-implied probability (0-100)
|
||||
market_probability: float # Market-implied probability (0-100)
|
||||
edge_bps: float # Edge in basis points
|
||||
recommended_size_usdc: float # Kelly-recommended bet size
|
||||
max_size_usdc: float # Maximum allowed position
|
||||
signal_type: str # "buy_yes", "buy_no", "pass"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Result of a trade execution."""
|
||||
signal: TradeSignal
|
||||
success: bool
|
||||
order_id: Optional[str] = None
|
||||
filled_amount: float = 0.0
|
||||
avg_price: float = 0.0
|
||||
error: Optional[str] = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
|
||||
class Trader:
|
||||
"""Execute trades based on strategy signals."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: PolymarketClient,
|
||||
private_key: str = "",
|
||||
funder_address: str = "",
|
||||
dry_run: bool = True,
|
||||
):
|
||||
self.client = client
|
||||
self.private_key = private_key
|
||||
self.funder_address = funder_address
|
||||
self.dry_run = dry_run
|
||||
self.clob = None
|
||||
self.positions: Dict[str, float] = {}
|
||||
self.trade_history: List[ExecutionResult] = []
|
||||
|
||||
if not dry_run and private_key:
|
||||
self._init_clob()
|
||||
|
||||
def _init_clob(self):
|
||||
"""Initialize CLOB client for live trading."""
|
||||
try:
|
||||
from py_clob_client.client import ClobClient
|
||||
from py_clob_client.clob_types import OrderArgs
|
||||
|
||||
host = "https://clob.polymarket.com"
|
||||
chain_id = 137 # Polygon mainnet
|
||||
|
||||
self.clob = ClobClient(
|
||||
host=host,
|
||||
key=self.private_key,
|
||||
chain_id=chain_id,
|
||||
funder=self.funder_address,
|
||||
signature_type=2,
|
||||
)
|
||||
print("CLOB client initialized for live trading")
|
||||
except Exception as e:
|
||||
print(f"CLOB init failed: {e}. Running in dry-run mode.")
|
||||
self.dry_run = True
|
||||
|
||||
def execute_signal(self, signal: TradeSignal) -> ExecutionResult:
|
||||
"""Execute a single trade signal."""
|
||||
if signal.signal_type == "pass":
|
||||
return ExecutionResult(
|
||||
signal=signal,
|
||||
success=True,
|
||||
note="No trade: edge below threshold",
|
||||
)
|
||||
|
||||
# Get token ID
|
||||
token_id = self.client.get_clob_token_id(
|
||||
signal.condition_id, signal.outcome_index
|
||||
)
|
||||
|
||||
if not token_id:
|
||||
return ExecutionResult(
|
||||
signal=signal,
|
||||
success=False,
|
||||
error="Could not get token ID",
|
||||
)
|
||||
|
||||
# Calculate number of shares at size (each share = $1 if correct)
|
||||
price = signal.market_probability / 100.0
|
||||
size = min(signal.recommended_size_usdc, signal.max_size_usdc)
|
||||
|
||||
if size < 1.0:
|
||||
return ExecutionResult(
|
||||
signal=signal,
|
||||
success=False,
|
||||
error=f"Size too small: ${size:.2f}",
|
||||
)
|
||||
|
||||
if self.dry_run:
|
||||
return self._execute_dry_run(signal, token_id, size, price)
|
||||
else:
|
||||
return self._execute_live(signal, token_id, size, price)
|
||||
|
||||
def _execute_dry_run(
|
||||
self, signal: TradeSignal, token_id: str, size: float, price: float
|
||||
) -> ExecutionResult:
|
||||
"""Simulate trade execution for testing."""
|
||||
result = ExecutionResult(
|
||||
signal=signal,
|
||||
success=True,
|
||||
order_id=f"DRY_RUN_{datetime.now().timestamp()}",
|
||||
filled_amount=size,
|
||||
avg_price=price,
|
||||
)
|
||||
self.trade_history.append(result)
|
||||
|
||||
position_key = f"{signal.condition_id}_{signal.outcome_index}"
|
||||
self.positions[position_key] = self.positions.get(position_key, 0) + size
|
||||
|
||||
print(f" [DRY RUN] {signal.signal_type}: ${size:.2f} on '{signal.question}'"
|
||||
f" @ {price:.4f} (edge: {signal.edge_bps:.0f}bps)")
|
||||
|
||||
return result
|
||||
|
||||
def _execute_live(
|
||||
self, signal: TradeSignal, token_id: str, size: float, price: float
|
||||
) -> ExecutionResult:
|
||||
"""Execute real trade on Polymarket CLOB."""
|
||||
if not self.clob:
|
||||
return ExecutionResult(
|
||||
signal=signal,
|
||||
success=False,
|
||||
error="CLOB not initialized",
|
||||
)
|
||||
|
||||
try:
|
||||
# Create a limit order (IOC to avoid partial fills on stale prices)
|
||||
order_args = {
|
||||
"token_id": token_id,
|
||||
"price": price,
|
||||
"size": size,
|
||||
"side": "BUY" if signal.signal_type == "buy_yes" else "SELL",
|
||||
}
|
||||
|
||||
response = self.clob.create_and_post_order(
|
||||
order_args, orderType="GTC"
|
||||
)
|
||||
|
||||
result = ExecutionResult(
|
||||
signal=signal,
|
||||
success=True,
|
||||
order_id=response.get("orderID", ""),
|
||||
filled_amount=float(response.get("filled_size", 0)),
|
||||
avg_price=float(response.get("avg_price", price)),
|
||||
)
|
||||
self.trade_history.append(result)
|
||||
|
||||
print(f" [LIVE] {signal.signal_type}: ${size:.2f} on '{signal.question}'"
|
||||
f" @ {price:.4f} (edge: {signal.edge_bps:.0f}bps)")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return ExecutionResult(
|
||||
signal=signal,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def get_positions_summary(self) -> Dict:
|
||||
"""Get summary of current positions and P&L."""
|
||||
total_bet = sum(self.positions.values())
|
||||
open_trades = len([t for t in self.trade_history if t.success])
|
||||
|
||||
return {
|
||||
"total_positions_value_usdc": total_bet,
|
||||
"num_open_trades": open_trades,
|
||||
"num_markets": len(self.positions),
|
||||
"positions": self.positions,
|
||||
"dry_run": self.dry_run,
|
||||
}
|
||||
|
||||
def cancel_all_orders(self):
|
||||
"""Cancel all open orders. Only works in live mode."""
|
||||
if self.dry_run or not self.clob:
|
||||
print("Cannot cancel orders in dry-run mode")
|
||||
return
|
||||
|
||||
try:
|
||||
self.clob.cancel_all()
|
||||
print("All orders cancelled")
|
||||
except Exception as e:
|
||||
print(f"Cancellation error: {e}")
|
||||
Reference in New Issue
Block a user