c93af97059
- 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.
212 lines
6.8 KiB
Python
212 lines
6.8 KiB
Python
"""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}")
|