Files

1330 lines
48 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Weather Trading Bot v3 — Polymarket CLOB Real Trading
======================================================
bot_v2 strategy logic + py_clob_client on-chain order execution.
Only trades US cities (F) for now — EU/Asia cities need CLOB market support.
Usage:
python bot_v3.py run # Full trading loop (scan + monitor)
python bot_v3.py scan # One-shot scan + trade signals
python bot_v3.py status # Show open positions + balance
python bot_v3.py cancel # Cancel all open orders
python bot_v3.py cancel --market <market_id> # Cancel orders for a market
"""
import re
import sys
import json
import math
import time
import os
import logging
import dotenv
import requests
import threading
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Callable, Any
# =============================================================================
# CONFIG
# =============================================================================
BOT_DIR = Path(__file__).parent
dotenv.load_dotenv(BOT_DIR / ".env")
with open(BOT_DIR / "config.json", encoding="utf-8") as f:
_cfg = json.load(f)
# --- Wallet ---
PK = os.getenv("PK", "")
WALLET = os.getenv("WALLET", "")
SIG_TYPE = int(os.getenv("SIG_TYPE", "0"))
# --- Trading ---
MAX_BET = _cfg.get("max_bet", 2.0)
MIN_EV = _cfg.get("min_ev", 0.10)
MAX_PRICE = _cfg.get("max_price", 0.45)
MIN_VOLUME = _cfg.get("min_volume", 500)
MIN_HOURS = _cfg.get("min_hours", 2.0)
MAX_HOURS = _cfg.get("max_hours", 72.0)
KELLY_FRAC = _cfg.get("kelly_fraction", 0.25)
MAX_SLIPPAGE = _cfg.get("max_slippage", 0.03)
SCAN_INTERVAL = _cfg.get("scan_interval", 3600)
VC_KEY = _cfg.get("vc_key", "")
# --- CLOB ---
CLOB_HOST = "https://clob.polymarket.com"
CHAIN_ID = 137 # Polygon
# --- Telegram ---
TELEGRAM_BOT_TOKEN = _cfg.get("telegram_bot_token", "")
TELEGRAM_CHAT_ID = _cfg.get("telegram_chat_id", "")
# --- Contract addresses (Polygon) ---
USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
CTF_EXCHANGE = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
NEG_RISK_EXCHANGE = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
ROUTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"
CONDITIONAL_TOKENS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
# --- Gas ---
MAX_FEE_PER_GAS = 200e9 # 200 gwei
# =============================================================================
# MATH
# =============================================================================
def norm_cdf(x):
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
def bucket_prob(forecast, t_low, t_high, sigma=2.0):
"""
Gaussian probability that forecast falls in [t_low, t_high].
Uses error function (math.erf) — no scipy needed.
"""
if t_low == -999:
return norm_cdf((t_high - float(forecast)) / sigma)
if t_high == 999:
return 1.0 - norm_cdf((t_low - float(forecast)) / sigma)
# Bounded range: P(t_low <= X <= t_high) = CDF(t_high) - CDF(t_low)
z_low = (t_low - float(forecast)) / sigma
z_high = (t_high - float(forecast)) / sigma
return norm_cdf(z_high) - norm_cdf(z_low)
def calc_ev(p, price):
if price <= 0 or price >= 1: return 0.0
return round(p * (1.0 / price - 1.0) - (1.0 - p), 4)
def calc_kelly(p, price):
if price <= 0 or price >= 1: return 0.0
b = 1.0 / price - 1.0
f = (p * b - (1.0 - p)) / b
return round(min(max(0.0, f) * KELLY_FRAC, 1.0), 4)
def bet_size(kelly):
"""Calculate bet size from Kelly fraction. Always uses MAX_BET as cap for consistency."""
raw = kelly * MAX_BET
return round(min(raw, MAX_BET), 2)
# =============================================================================
# COLORS
# =============================================================================
class C:
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
CYAN = "\033[96m"
GRAY = "\033[90m"
RESET = "\033[0m"
BOLD = "\033[1m"
def ok(msg): print(f"{C.GREEN}{msg}{C.RESET}")
def warn(msg): print(f"{C.YELLOW} ⚠️ {msg}{C.RESET}")
def info(msg): print(f"{C.CYAN} {msg}{C.RESET}")
def skip(msg): print(f"{C.GRAY} ⏸️ {msg}{C.RESET}")
def live(msg): print(f"{C.GREEN} {msg}{C.RESET}")
# =============================================================================
# TIMEOUT WRAPPER — prevents CLOB/HTTP calls from hanging forever
# =============================================================================
def _timeout_call(func: Callable, args: tuple = (), kwargs: dict = None,
timeout: float = 10.0, default: Any = None) -> Any:
"""Run func in a thread with a timeout. Returns default on timeout."""
kwargs = kwargs or {}
result = [default]
error = [None]
def target():
try:
result[0] = func(*args, **kwargs)
except Exception as e:
error[0] = e
t = threading.Thread(target=target, daemon=True)
t.start()
t.join(timeout=timeout)
if t.is_alive():
return default
if error[0]:
raise error[0]
return result[0]
# =============================================================================
# SELF-LEARNING SYSTEM — adapts strategy based on trade history
# =============================================================================
LEARNING_DIR = BOT_DIR / "data" / "learning"
LEARNING_DIR.mkdir(exist_ok=True)
TRADE_LOG = LEARNING_DIR / "trade_log.json"
MODEL_FILE = LEARNING_DIR / "model.json"
LEARNING_WINDOW = 30 # Consider last N trades for adaptation
# Default model (conservative start)
_DEFAULT_MODEL = {
"version": 1,
"city_knowledge": {}, # city_slug -> {wins, losses, total_pnl, trades}
"bucket_knowledge": {}, # bucket_range -> {wins, losses}
"global": {"wins": 0, "losses": 0, "total_pnl": 0.0, "trades": 0},
"kelly_adjustment": 1.0, # multiplier on Kelly fraction
"ev_floor": MIN_EV, # adaptive EV threshold
"max_kelly_frac": KELLY_FRAC,
"confidence": 0.0, # 0-1, how much to trust learned params
}
def _load_model() -> dict:
if MODEL_FILE.exists():
return json.loads(MODEL_FILE.read_text(encoding="utf-8"))
return _DEFAULT_MODEL.copy()
def _save_model(model: dict):
MODEL_FILE.write_text(json.dumps(model, indent=2, ensure_ascii=False), encoding="utf-8")
def record_trade(city_slug: str, bucket_low: int, bucket_high: int,
outcome: str, pnl: float, cost: float, kelly: float, ev: float):
"""
Record a completed trade for self-learning.
outcome: 'win' | 'loss' | 'pending'
pnl: profit/loss amount in USDC
"""
model = _load_model()
# Load existing trade log
log = []
if TRADE_LOG.exists():
log = json.loads(TRADE_LOG.read_text(encoding="utf-8"))
# Append new trade
trade = {
"id": len(log) + 1,
"timestamp": datetime.now(timezone.utc).isoformat(),
"city": city_slug,
"bucket": f"{bucket_low}-{bucket_high}",
"outcome": outcome,
"pnl": round(pnl, 4),
"cost": round(cost, 4),
"kelly": round(kelly, 4),
"ev": round(ev, 4),
}
log.append(trade)
# Keep only recent trades
log = log[-LEARNING_WINDOW:]
TRADE_LOG.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
# Update model based on resolved trades only
resolved = [t for t in log if t["outcome"] in ("win", "loss")]
if not resolved:
_save_model(model)
return
wins = sum(1 for t in resolved if t["outcome"] == "win")
losses = sum(1 for t in resolved if t["outcome"] == "loss")
total_pnl = sum(t["pnl"] for t in resolved)
total_trades = len(resolved)
winrate = wins / total_trades if total_trades > 0 else 0.5
avg_win = sum(t["pnl"] for t in resolved if t["outcome"] == "win") / wins if wins > 0 else 1.0
avg_loss = abs(sum(t["pnl"] for t in resolved if t["outcome"] == "loss") / losses) if losses > 0 else 1.0
# Global update
model["global"] = {
"wins": wins, "losses": losses,
"total_pnl": round(total_pnl, 4),
"trades": total_trades,
}
# City-level knowledge
for city in set(t["city"] for t in resolved):
city_trades = [t for t in resolved if t["city"] == city]
city_wins = sum(1 for t in city_trades if t["outcome"] == "win")
city_losses = sum(1 for t in city_trades if t["outcome"] == "loss")
city_pnl = sum(t["pnl"] for t in city_trades)
model["city_knowledge"][city] = {
"wins": city_wins, "losses": city_losses,
"total_pnl": round(city_pnl, 4),
"trades": len(city_trades),
}
# Bucket-level knowledge
for bucket in set(t["bucket"] for t in resolved):
b_trades = [t for t in resolved if t["bucket"] == bucket]
b_wins = sum(1 for t in b_trades if t["outcome"] == "win")
b_losses = sum(1 for t in b_trades if t["outcome"] == "loss")
model["bucket_knowledge"][bucket] = {
"wins": b_wins, "losses": b_losses,
}
# Adaptive Kelly: lower if winrate < 50% or poor PnL
if total_trades >= 5:
if winrate < 0.45 or total_pnl < -1.0:
model["kelly_adjustment"] = max(0.25, model["kelly_adjustment"] * 0.8)
model["ev_floor"] = min(0.20, model["ev_floor"] * 1.1)
elif winrate > 0.55 and total_pnl > 2.0:
model["kelly_adjustment"] = min(1.0, model["kelly_adjustment"] * 1.1)
model["ev_floor"] = max(MIN_EV, model["ev_floor"] * 0.95)
model["max_kelly_frac"] = round(KELLY_FRAC * model["kelly_adjustment"], 4)
model["confidence"] = min(1.0, total_trades / 20.0)
_save_model(model)
def get_adjusted_kelly(base_kelly: float) -> float:
"""Apply learned adjustment to Kelly fraction."""
model = _load_model()
adj = model.get("kelly_adjustment", 1.0)
capped = min(base_kelly * adj, model.get("max_kelly_frac", KELLY_FRAC))
return round(capped, 4)
def get_adjusted_ev_floor() -> float:
"""Get adaptive EV threshold based on recent performance."""
model = _load_model()
return model.get("ev_floor", MIN_EV)
def get_city_winrate(city_slug: str) -> float:
"""Get learned winrate for a specific city (0.5 if unknown)."""
model = _load_model()
city = model.get("city_knowledge", {}).get(city_slug)
if not city or city["trades"] < 2:
return 0.5
total = city["wins"] + city["losses"]
return city["wins"] / total
def get_learning_stats() -> dict:
"""Return current learning model summary."""
model = _load_model()
g = model.get("global", {})
trades = g.get("trades", 0)
if trades == 0:
return {"trades": 0, "winrate": "N/A", "pnl": "$0.00", "confidence": "0%",
"kelly_adj": "1.0x", "ev_floor": f"{MIN_EV*100:.0f}%"}
wr = g.get("wins", 0) / trades
return {
"trades": trades,
"winrate": f"{wr:.0%}",
"pnl": f"${g.get('total_pnl', 0):.2f}",
"confidence": f"{model.get('confidence', 0)*100:.0f}%",
"kelly_adj": f"{model.get('kelly_adjustment', 1.0):.2f}x",
"ev_floor": f"{model.get('ev_floor', MIN_EV)*100:.0f}%",
}
# =============================================================================
# CLOB CLIENT
# =============================================================================
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import OrderArgs, MarketOrderArgs, OrderType
_clob: ClobClient = None
def get_clob() -> ClobClient:
global _clob
if _clob is None:
_clob = ClobClient(
host=CLOB_HOST,
chain_id=CHAIN_ID,
key=PK,
)
return _clob
# =============================================================================
# ON-CHAIN HELPERS
# =============================================================================
from web3 import Web3
from eth_account import Account
_w3: Web3 = None
def get_w3() -> Web3:
global _w3
if _w3 is None:
_w3 = Web3(Web3.HTTPProvider("https://1rpc.io/matic"))
return _w3
def get_nonce(wallet: str) -> int:
return get_w3().eth.get_transaction_count(wallet)
def send_tx(w3, signed_txn):
return w3.eth.send_raw_transaction(signed_txn).hex()
def wait_for_receipt(w3, tx_hash: str, timeout=120):
start = time.time()
while time.time() - start < timeout:
try:
receipt = w3.eth.get_transaction_receipt(tx_hash)
if receipt and receipt["status"] == 1:
return receipt
except Exception:
pass
time.sleep(2)
return None
# =============================================================================
# BALANCE CHECK
# =============================================================================
def get_usdc_balance(wallet: str) -> float:
"""Get USDC.e balance on Polygon via raw eth_call (avoids web3 contract ABI issues)."""
w3 = get_w3()
wallet_checksum = Web3.to_checksum_address(wallet)
usdc_checksum = Web3.to_checksum_address(USDC_ADDRESS)
#balanceOf(address) — the "data" is the function selector hash + padded address
selector = "0x70a08231" # balanceOf(address)
data = selector + wallet_checksum[2:].lower().rjust(64, '0')
try:
result = w3.eth.call({
"to": usdc_checksum,
"data": data,
})
bal = int.from_bytes(result, "big")
return bal / 1e6 # USDC.e = 6 decimals
except Exception as e:
warn(f"Balance check failed: {e}")
return 0.0
def get_pol_balance(wallet: str) -> float:
w3 = get_w3()
bal = w3.eth.get_balance(Web3.to_checksum_address(wallet))
return int(bal) / 1e18
# =============================================================================
# TELEGRAM NOTIFICATIONS
# =============================================================================
_tg_session = requests.Session()
def send_telegram(text: str, retry=2) -> bool:
"""Send a message via Telegram Bot API. Returns True on success."""
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return False
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
for attempt in range(retry + 1):
try:
r = _tg_session.post(url, json=payload, timeout=(5, 10))
if r.status_code == 200:
return True
except Exception:
pass
if attempt < retry:
time.sleep(1)
return False
def tg_signal(city: str, horizon: str, date: str, bucket_label: str,
forecast_temp: float, entry_price: float, cost: float,
ev: float, kelly: float, success: bool, reason: str = ""):
"""Send a trade signal notification to Telegram."""
if success:
msg = (
f"📍 <b>{city} {horizon}</b> — {date}\n"
f"🌡 Forecast: <b>{forecast_temp}°F</b>\n"
f"🎯 Bucket: <b>{bucket_label}</b>\n"
f"💰 Cost: <b>${cost:.2f}</b> @ <b>${entry_price:.3f}</b>\n"
f"📈 EV: <b>+{ev:.2f}</b> | Kelly: <b>{kelly:.2f}</b>\n"
f"✅ <b>ORDER FILLED</b>"
)
else:
msg = (
f"📍 <b>{city} {horizon}</b> — {date}\n"
f"🌡 Forecast: <b>{forecast_temp}°F</b>\n"
f"🎯 Bucket: <b>{bucket_label}</b>\n"
f"❌ <b>ORDER FAILED:</b> {reason}"
)
send_telegram(msg)
def tg_scan_summary(new_trades: int, errors: int, balance: float, cities: int,
top_signals: list = None, open_positions: list = None):
"""Send a detailed scan summary to Telegram."""
status_emoji = "✅" if errors == 0 else "⚠️"
# Build header
lines = [
f"🔔 <b>Weather Bot — Scan Report</b>",
f"{status_emoji} Cities: {cities} | New trades: {new_trades} | Errors: {errors}",
f"💰 Balance: <b>${balance:.4f}</b> USDC.e",
]
# Open positions
if open_positions:
lines.append("")
lines.append(f"📊 <b>Open Positions ({len(open_positions)}):</b>")
for pos in open_positions[:5]: # Max 5 shown
label = f"{pos['bucket_low']}-{pos['bucket_high']}°F"
pnl_str = f"${pos.get('pnl', 0):.2f}" if pos.get('pnl') else "pending"
entry = pos.get('entry_price', 0)
cost = pos.get('cost', 0)
lines.append(
f" • {pos['city_name']} {pos['date']} | {label} | "
f"entry ${entry:.3f} | cost ${cost:.2f} | PnL {pnl_str}"
)
if len(open_positions) > 5:
lines.append(f" ...and {len(open_positions) - 5} more")
else:
lines.append("")
lines.append("📊 <b>Open Positions:</b> 0")
# Top signals from this scan
if top_signals:
lines.append("")
lines.append(f"🎯 <b>Top EV Signals ({len(top_signals)} found):</b>")
for sig in top_signals[:5]: # Max 5 shown
lines.append(
f" • {sig['city']} {sig['horizon']} | "
f"{sig['bucket']} | EV <b>+{sig['ev']:.2f}</b> | "
f"${sig['price']:.3f} (market) vs ${sig['true_prob']:.3f} (model)"
)
msg = "\n".join(lines)
send_telegram(msg)
# =============================================================================
# APPROVAL CHECK
# =============================================================================
def is_approved(token: str, spender: str, wallet: str) -> bool:
"""Check if spender is approved for token (USDC.e)."""
w3 = get_w3()
usdc_abi = [
{
"name": "allowance",
"inputs": [
{"name": "owner", "type": "address"},
{"name": "spender", "type": "address"}
],
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
}
]
usdc = w3.eth.contract(
address=Web3.to_checksum_address(token),
abi=usdc_abi
)
try:
allowance = usdc.functions.allowance(
Web3.to_checksum_address(wallet),
Web3.to_checksum_address(spender)
).call()
return allowance > 0
except Exception:
return False
def approve_token(token: str, spender: str, wallet: str, private_key: str,
amount_wei: int = 2**256 - 1, max_fee: int = MAX_FEE_PER_GAS):
"""Approve spender to spend token on behalf of wallet."""
w3 = get_w3()
usdc_abi = [
{
"name": "approve",
"inputs": [
{"name": "spender", "type": "address"},
{"name": "amount", "type": "uint256"}
],
"outputs": [{"name": "", "type": "bool"}],
"stateMutability": "nonpayable",
"type": "function"
}
]
usdc = w3.eth.contract(
address=Web3.to_checksum_address(token),
abi=usdc_abi
)
nonce = get_nonce(wallet)
build = usdc.functions.approve(
Web3.to_checksum_address(spender),
amount_wei
).build_transaction({
"from": wallet,
"nonce": nonce,
"maxFeePerGas": max_fee,
"maxPriorityFeePerGas": 25e9,
"chainId": CHAIN_ID,
})
signed = w3.eth.account.sign_transaction(build, private_key)
tx_hash = send_tx(w3, signed.raw_transaction)
live(f"Approve tx: {tx_hash}")
receipt = wait_for_receipt(w3, tx_hash)
if receipt:
ok(f"Approved {spender} for {token[:10]}...")
return True
warn(f"Approval tx failed: {tx_hash}")
return False
def ensure_approvals():
"""Ensure all required approvals are set before trading."""
wallet = WALLET
required = [
(USDC_ADDRESS, CTF_EXCHANGE),
(USDC_ADDRESS, NEG_RISK_EXCHANGE),
(USDC_ADDRESS, ROUTER),
]
for token, spender in required:
if not is_approved(token, spender, wallet):
warn(f"Missing approval: {spender[:10]} for {token[:10]}")
ok(f"Approving {spender[:10]}...")
approve_token(token, spender, wallet, PK)
time.sleep(5) # Wait for confirmation
else:
ok(f"Already approved: {spender[:10]}")
# =============================================================================
# ORDER EXECUTION
# =============================================================================
def place_buy_order(market_id: str, token_id: str, price: float, shares: float,
private_key: str, wallet: str) -> dict:
"""
Place a BUY order on Polymarket CLOB.
Uses FOK (Fill-Or-Kill) market order to guarantee execution.
Returns dict with success status and details.
Uses _timeout_call to prevent indefinite hangs.
Balance check is done on-chain — we always attempt the order for consistency.
"""
cost = round(shares * price, 4)
if not is_approved(USDC_ADDRESS, ROUTER, wallet):
return {"success": False, "reason": "Router approval missing"}
# --- Market order via CLOB (with 10s timeout) ---
order_args = MarketOrderArgs(
token_id=token_id,
amount=cost, # For BUY: amount is in dollars (USDC)
side="BUY",
price=price,
)
try:
clob = get_clob()
# assert_level_1_auth first (fast, with timeout)
auth_ok = _timeout_call(clob.assert_level_1_auth, timeout=10.0)
if auth_ok is None:
return {"success": False, "reason": "CLOB auth timeout (>10s)"}
# create_market_order (network call, with 10s timeout)
order_result = _timeout_call(
clob.create_market_order, args=(order_args,), timeout=10.0
)
if order_result is None:
return {"success": False, "reason": "Order execution timeout (>10s)"}
live(f"Market order placed: {order_result}")
except Exception as e:
return {"success": False, "reason": f"Order failed: {e}"}
return {
"success": True,
"market_id": market_id,
"token_id": token_id,
"price": price,
"shares": shares,
"cost": cost,
"order_id": order_result.get("orderID") if isinstance(order_result, dict) else str(order_result),
}
def cancel_order(order_id: str) -> bool:
"""Cancel a specific order by ID."""
clob = get_clob()
try:
clob.cancel(order_id)
ok(f"Cancelled order: {order_id[:20]}...")
return True
except Exception as e:
warn(f"Cancel failed: {e}")
return False
def cancel_all_orders() -> int:
"""Cancel all open orders. Returns count of cancelled orders."""
clob = get_clob()
try:
result = clob.cancel_all()
count = result.get("count", 0) if isinstance(result, dict) else 0
ok(f"Cancelled {count} orders")
return count
except Exception as e:
warn(f"Cancel all failed: {e}")
return 0
# =============================================================================
# LOCATIONS & WEATHER DATA
# =============================================================================
LOCATIONS = {
"nyc": {"lat": 40.7772, "lon": -73.8726, "name": "New York City", "station": "KLGA", "unit": "F", "region": "us"},
"chicago": {"lat": 41.9742, "lon": -87.9073, "name": "Chicago", "station": "KORD", "unit": "F", "region": "us"},
"miami": {"lat": 25.7959, "lon": -80.2870, "name": "Miami", "station": "KMIA", "unit": "F", "region": "us"},
"dallas": {"lat": 32.8471, "lon": -96.8518, "name": "Dallas", "station": "KDAL", "unit": "F", "region": "us"},
"seattle": {"lat": 47.4502, "lon":-122.3088, "name": "Seattle", "station": "KSEA", "unit": "F", "region": "us"},
"atlanta": {"lat": 33.6407, "lon": -84.4277, "name": "Atlanta", "station": "KATL", "unit": "F", "region": "us"},
}
TIMEZONES = {
"nyc": "America/New_York", "chicago": "America/Chicago",
"miami": "America/New_York", "dallas": "America/Chicago",
"seattle": "America/Los_Angeles", "atlanta": "America/New_York",
}
MONTHS = ["january","february","march","april","may","june",
"july","august","september","october","november","december"]
def get_ecmwf(city_slug, dates):
"""ECMWF via Open-Meteo. Returns dict {date: temp_f}."""
loc = LOCATIONS[city_slug]
url = (
f"https://api.open-meteo.com/v1/forecast"
f"?latitude={loc['lat']}&longitude={loc['lon']}"
f"&daily=temperature_2m_max&temperature_unit=fahrenheit"
f"&forecast_days=7&timezone={TIMEZONES.get(city_slug, 'UTC')}"
f"&models=ecmwf_ifs025&bias_correction=true"
)
result = {}
for attempt in range(3):
try:
data = requests.get(url, timeout=(5, 10)).json()
if "error" not in data:
for date, temp in zip(data["daily"]["time"], data["daily"]["temperature_2m_max"]):
if date in dates and temp is not None:
result[date] = round(temp)
break
except Exception as e:
if attempt < 2:
time.sleep(2)
else:
warn(f"ECMWF error for {city_slug}: {e}")
return result
def get_metar(city_slug):
"""Current observed temperature from METAR station. D+0 only."""
loc = LOCATIONS[city_slug]
try:
url = f"https://aviationweather.gov/api/data/metar?ids={loc['station']}&format=json"
data = requests.get(url, timeout=(5, 8)).json()
if data and isinstance(data, list):
temp_c = data[0].get("temp")
if temp_c is not None:
return round(float(temp_c) * 9/5 + 32)
except Exception as e:
warn(f"METAR error for {city_slug}: {e}")
return None
def get_forecast_snapshot(city_slug, dates):
"""Get best temperature forecast for each date. Returns {date: temp_f}."""
ecmwf = get_ecmwf(city_slug, dates)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
result = {}
for date in dates:
best = ecmwf.get(date)
best_source = "ecmwf"
# METAR for today if available
if date == today:
metar = get_metar(city_slug)
if metar is not None:
best = metar
best_source = "metar"
if best is not None:
result[date] = {"temp": best, "source": best_source}
return result
# =============================================================================
# POLYMARKET
# =============================================================================
def get_polymarket_event(city_slug, month, day, year):
slug = f"highest-temperature-in-{city_slug}-on-{month}-{day}-{year}"
try:
r = requests.get(f"https://gamma-api.polymarket.com/events?slug={slug}", timeout=(5, 8))
data = r.json()
if data and isinstance(data, list) and len(data) > 0:
return data[0]
except Exception as e:
warn(f"Polymarket API error: {e}")
return None
def get_market_price(market_id):
try:
r = requests.get(f"https://gamma-api.polymarket.com/markets/{market_id}", timeout=(3, 5))
data = r.json()
prices = json.loads(data.get("outcomePrices", "[0.5,0.5]"))
return float(prices[0]), float(prices[1]) if len(prices) > 1 else float(prices[0])
except Exception:
return None, None
def parse_temp_range(question):
if not question: return None
num = r'(-?\d+(?:\.\d+)?)'
if re.search(r'or below', question, re.IGNORECASE):
m = re.search(num + r'[°]?[FC] or below', question, re.IGNORECASE)
if m: return (-999.0, float(m.group(1)))
if re.search(r'or higher', question, re.IGNORECASE):
m = re.search(num + r'[°]?[FC] or higher', question, re.IGNORECASE)
if m: return (float(m.group(1)), 999.0)
m = re.search(r'between ' + num + r'-' + num + r'[°]?[FC]', question, re.IGNORECASE)
if m: return (float(m.group(1)), float(m.group(2)))
m = re.search(r'be ' + num + r'[°]?[FC] on', question, re.IGNORECASE)
if m:
v = float(m.group(1))
return (v, v)
return None
def hours_to_resolution(end_date_str):
try:
end = datetime.fromisoformat(end_date_str.replace("Z", "+00:00"))
return max(0.0, (end - datetime.now(timezone.utc)).total_seconds() / 3600)
except Exception:
return 999.0
def in_bucket(forecast, t_low, t_high):
if t_low == t_high:
return round(float(forecast)) == round(t_low)
return t_low <= float(forecast) <= t_high
def get_condition_id(market_id: str) -> str:
"""Get condition ID for a market from Polymarket (with 8s timeout)."""
try:
r = _timeout_call(
requests.get,
args=(f"https://gamma-api.polymarket.com/markets/{market_id}",),
kwargs={"timeout": (5, 8)},
timeout=8.0,
)
if r is None:
warn(f"get_condition_id timeout for {market_id[:16]}...")
return ""
data = r.json()
return data.get("conditionId", "")
except Exception:
return ""
# =============================================================================
# STATE (local JSON)
# =============================================================================
DATA_DIR = BOT_DIR / "data"
DATA_DIR.mkdir(exist_ok=True)
MARKETS_DIR = DATA_DIR / "markets"
MARKETS_DIR.mkdir(exist_ok=True)
STATE_FILE = DATA_DIR / "state_v3.json"
def load_state():
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
return {
"balance": 0.0,
"starting_balance": 0.0,
"total_trades": 0,
"wins": 0,
"losses": 0,
"open_orders": {},
}
def save_state(state):
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
def market_path(city_slug, date_str):
return MARKETS_DIR / f"{city_slug}_{date_str}.json"
def load_market(city_slug, date_str):
p = market_path(city_slug, date_str)
if p.exists():
return json.loads(p.read_text(encoding="utf-8"))
return None
def save_market(market):
p = market_path(market["city"], market["date"])
p.write_text(json.dumps(market, indent=2, ensure_ascii=False), encoding="utf-8")
def load_all_markets():
markets = []
for f in MARKETS_DIR.glob("*.json"):
try:
markets.append(json.loads(f.read_text(encoding="utf-8")))
except Exception:
pass
return markets
# =============================================================================
# SIGMA (weather forecast uncertainty)
# =============================================================================
SIGMA_F = 2.0
def get_sigma(city_slug):
return SIGMA_F # Flat sigma for now; calibration can be added later
# =============================================================================
# OPEN POSITIONS from CLOB
# =============================================================================
def get_clob_positions():
"""Get all open orders/positions from CLOB."""
clob = get_clob()
try:
orders = clob.get_orders()
return orders if orders else []
except Exception as e:
warn(f"Failed to fetch CLOB orders: {e}")
return []
# =============================================================================
# SCAN & TRADE (one shot)
# =============================================================================
def scan_and_trade():
"""
One-shot scan: check all cities for trade signals and execute real orders.
Returns (new_trades, errors).
"""
now = datetime.now(timezone.utc)
state = load_state()
balance = get_usdc_balance(WALLET)
if balance != state.get("balance"):
state["balance"] = balance
save_state(state)
print(f"\n{C.BOLD}{C.CYAN}🌤 Weather Trading Bot v3 — Live Mode{C.RESET}")
print("=" * 60)
print(f" Wallet: {WALLET[:8]}...{WALLET[-4:]}")
print(f" USDC.e: ${balance:.4f}")
print(f" POL balance: {get_pol_balance(WALLET):.4f} POL")
print(f" Max bet: ${MAX_BET} | Min EV: {MIN_EV*100:.0f}%")
print()
new_trades = 0
errors = []
# Collect city market data for Telegram report (top signals)
city_market_data = []
for city_slug, loc in LOCATIONS.items():
print(f" -> {loc['name']}...", end=" ", flush=True)
unit_sym = "F"
try:
# --- Step 1: Fetch forecasts ---
t0 = time.time()
dates = [(now + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(4)]
forecasts = get_forecast_snapshot(city_slug, dates)
info(f"[{loc['name']}] forecast loaded in {time.time()-t0:.1f}s")
time.sleep(0.3)
except Exception as e:
print(f"error ({e})")
continue
# --- Step 2: Find signal per date ---
city_found_signal = False
for i, date in enumerate(dates):
t0 = time.time()
try:
event = get_polymarket_event(
city_slug,
MONTHS[datetime.strptime(date, "%Y-%m-%d").month - 1],
datetime.strptime(date, "%Y-%m-%d").day,
datetime.strptime(date, "%Y-%m-%d").year
)
info(f" [{loc['name']} D+{i}] event fetched in {time.time()-t0:.1f}s")
except Exception as e:
warn(f"Polymarket error for {loc['name']} D+{i}: {e}")
continue
if not event:
continue
end_date = event.get("endDate", "")
hours = hours_to_resolution(end_date) if end_date else 0
horizon = f"D+{i}"
if hours < MIN_HOURS or hours > MAX_HOURS:
continue
# Parse all outcome buckets from Polymarket
outcomes = []
for market in event.get("markets", []):
question = market.get("question", "")
mid = str(market.get("id", ""))
volume = float(market.get("volume", 0))
rng = parse_temp_range(question)
if not rng:
continue
try:
prices = json.loads(market.get("outcomePrices", "[0.5,0.5]"))
bid = float(prices[0])
ask = float(prices[1]) if len(prices) > 1 else bid
except Exception:
continue
outcomes.append({
"question": question,
"market_id": mid,
"range": rng,
"bid": round(bid, 4),
"ask": round(ask, 4),
"price": round(bid, 4),
"spread": round(ask - bid, 4),
"volume": round(volume, 0),
})
if not outcomes:
continue
forecastsnap = forecasts.get(date, {})
forecast_temp = forecastsnap.get("temp")
best_source = forecastsnap.get("source", "ecmwf")
if forecast_temp is None:
continue
# Guard against invalid API readings (e.g. -999 from ECMWF)
if forecast_temp < -40 or forecast_temp > 130:
warn(f" ⚠️ Invalid forecast temp {forecast_temp}°F — skipping city")
break
# Skip sentinel/unbounded bucket ranges (t_high=999 or t_low=-999 from Polymarket)
sentinel_buckets = [(o["range"][0], o["range"][1]) for o in outcomes
if o["range"][0] == -999 or o["range"][1] == 999]
if sentinel_buckets:
info(f" ⚠️ Sentinel buckets detected — ignoring for EV calculation")
# Collect for Telegram top-signals report
city_market_data.append((city_slug, loc, outcomes, forecastsnap, horizon, end_date, date))
sigma = get_sigma(city_slug)
best_signal = None
# Find the bucket that matches our forecast
for o in outcomes:
t_low, t_high = o["range"]
# Skip unbounded sentinel buckets from Polymarket
if t_low == -999 or t_high == 999:
continue
if not in_bucket(forecast_temp, t_low, t_high):
continue
volume = o["volume"]
ask = o["ask"]
spread = o["spread"]
if volume < MIN_VOLUME:
continue
if ask >= MAX_PRICE:
continue
if spread > MAX_SLIPPAGE:
continue
# Calculate probability FIRST (needed for Kelly and EV)
p = bucket_prob(forecast_temp, t_low, t_high, sigma)
# Use adaptive EV floor and Kelly from self-learning
adaptive_ev_floor = get_adjusted_ev_floor()
base_kelly = calc_kelly(p, ask)
adjusted_kelly = get_adjusted_kelly(base_kelly)
ev = calc_ev(p, ask)
if ev < adaptive_ev_floor:
continue
size = bet_size(adjusted_kelly)
if size < 0.50:
continue
shares = round(size / ask, 2)
token_id = get_condition_id(o["market_id"])
best_signal = {
"market_id": o["market_id"],
"token_id": token_id,
"question": o["question"],
"bucket_low": t_low,
"bucket_high": t_high,
"entry_price": ask,
"bid": o["bid"],
"spread": spread,
"shares": shares,
"cost": round(shares * ask, 4),
"p": round(p, 4),
"ev": round(ev, 4),
"kelly": round(adjusted_kelly, 4),
"forecast_temp": forecast_temp,
"forecast_src": best_source,
"sigma": sigma,
"volume": volume,
}
break # Only one bucket per market
if best_signal:
city_found_signal = True
bucket_label = f"{best_signal['bucket_low']}-{best_signal['bucket_high']}{unit_sym}"
print(f"\n {C.BOLD}📍 {loc['name']} {horizon}{date}{C.RESET}")
print(f" {C.CYAN} Forecast: {forecast_temp}°F ({best_source}) | {bucket_label}{C.RESET}")
print(f" {C.GREEN} ✅ BUY SIGNAL | ${best_signal['cost']:.2f} @ ${ask:.3f} | "
f"EV {best_signal['ev']:+.2f} | Kel {best_signal['kelly']:.2f}{C.RESET}")
# --- EXECUTE REAL ORDER ---
result = place_buy_order(
market_id=best_signal["market_id"],
token_id=best_signal["token_id"],
price=best_signal["entry_price"],
shares=best_signal["shares"],
private_key=PK,
wallet=WALLET,
)
if result["success"]:
new_trades += 1
state["total_trades"] += 1
balance -= best_signal["cost"]
# Record trade for self-learning (outcome='pending' until resolved)
record_trade(
city_slug=city_slug,
bucket_low=best_signal["bucket_low"],
bucket_high=best_signal["bucket_high"],
outcome="pending",
pnl=0.0, # will be updated when market resolves
cost=best_signal["cost"],
kelly=best_signal["kelly"],
ev=best_signal["ev"],
)
live(f" [LIVE] BUY {loc['name']} {horizon} | {bucket_label} @ ${best_signal['entry_price']:.3f} "
f"| EV {best_signal['ev']:+.2f} | ${best_signal['cost']:.2f}")
# Save to market record
mkt_record = load_market(city_slug, date) or {
"city": city_slug,
"city_name": loc["name"],
"date": date,
"unit": "F",
"event_end_date": end_date,
"status": "open",
"position": None,
}
mkt_record["position"] = {
**best_signal,
"order_id": result.get("order_id"),
"opened_at": datetime.now(timezone.utc).isoformat(),
"status": "open",
"closed_at": None,
"close_reason": None,
"exit_price": None,
"pnl": None,
}
save_market(mkt_record)
# Telegram notification — success
tg_signal(
city=loc["name"], horizon=horizon, date=date,
bucket_label=bucket_label, forecast_temp=best_signal["forecast_temp"],
entry_price=best_signal["entry_price"], cost=best_signal["cost"],
ev=best_signal["ev"], kelly=best_signal["kelly"],
success=True,
)
else:
errors.append(f"{loc['name']} {horizon}: {result['reason']}")
warn(f" ❌ Order failed: {result['reason']}")
# Telegram notification — failure
tg_signal(
city=loc["name"], horizon=horizon, date=date,
bucket_label=bucket_label, forecast_temp=best_signal["forecast_temp"],
entry_price=best_signal["entry_price"], cost=best_signal.get("cost", 0),
ev=best_signal["ev"], kelly=best_signal["kelly"],
success=False, reason=result.get("reason", "unknown"),
)
else:
# No signal — show why (skip sentinel buckets to avoid confusing EV)
for o in outcomes:
t_low, t_high = o["range"]
if t_low == -999 or t_high == 999:
continue
if not in_bucket(forecast_temp, t_low, t_high):
continue
ask = o["ask"]
p = bucket_prob(forecast_temp, t_low, t_high, sigma)
ev = calc_ev(p, ask)
skip(f" {forecast_temp}°F bucket {t_low}-{t_high}F @ ${ask:.3f} EV={ev:.2f} — skipped")
break
# Print "ok" regardless of whether signal found
if not city_found_signal:
# Show first skip reason for this city
print("ok", end="", flush=True)
print() # newline after city
# Build top signals from this scan for Telegram
top_signals = []
for city_slug, loc, outcomes, forecastsnap, horizon, end_date, date in city_market_data:
if not outcomes or not forecastsnap:
continue
forecast_temp = forecastsnap.get("temp")
if forecast_temp is None:
continue
if forecast_temp < -40 or forecast_temp > 130:
continue
sigma = get_sigma(city_slug)
for o in outcomes:
t_low, t_high = o["range"]
# Skip sentinel/unbounded buckets — they give false EV signals
if t_low == -999 or t_high == 999:
continue
if not in_bucket(forecast_temp, t_low, t_high):
continue
p = bucket_prob(forecast_temp, t_low, t_high, sigma)
ev = calc_ev(p, o["ask"])
if ev > 0:
top_signals.append({
"city": loc["name"],
"horizon": horizon,
"bucket": f"{t_low}-{t_high}°F",
"ev": ev,
"price": o["ask"],
"true_prob": p,
})
top_signals.sort(key=lambda x: x["ev"], reverse=True)
# Open positions
markets = load_all_markets()
open_positions = [
m for m in markets
if m.get("position") and m["position"].get("status") == "open"
]
# Save updated balance
state["balance"] = round(balance, 4)
save_state(state)
print(f"\n{'=' * 60}")
print(f" Scanned: {len(LOCATIONS)} cities")
print(f" New trades: {C.GREEN}{new_trades}{C.RESET}")
print(f" Errors: {len(errors)}")
print(f" Balance: ${balance:.4f}")
print(f"{'=' * 60}\n")
# Telegram scan summary
tg_scan_summary(new_trades=new_trades, errors=len(errors),
balance=balance, cities=len(LOCATIONS),
top_signals=top_signals,
open_positions=open_positions)
return new_trades, errors
# =============================================================================
# STATUS
# =============================================================================
def show_status():
"""Show current balance, positions, and open orders."""
balance = get_usdc_balance(WALLET)
pol_bal = get_pol_balance(WALLET)
print(f"\n{C.BOLD}{C.CYAN}📊 Bot v3 — Status{C.RESET}")
print("=" * 60)
print(f" Wallet: {WALLET[:8]}...{WALLET[-4:]}")
print(f" USDC.e: ${balance:.4f}")
print(f" POL: {pol_bal:.4f}")
print()
# Open orders from CLOB
orders = get_clob_positions()
if orders:
print(f" Open orders: {len(orders)}")
for o in orders:
print(f" {o.get('side','?')} {o.get('size','?')} @ ${o.get('price','?')} "
f"[{o.get('marketID','')[:16]}...]")
else:
print(f" Open orders: 0")
# Local market positions
markets = load_all_markets()
open_pos = [m for m in markets if m.get("position") and m["position"].get("status") == "open"]
if open_pos:
print(f"\n Open positions (local): {len(open_pos)}")
for m in open_pos:
pos = m["position"]
unit_sym = "F"
label = f"{pos['bucket_low']}-{pos['bucket_high']}{unit_sym}"
print(f" {m['city_name']} {m['date']} | {label} | "
f"entry ${pos['entry_price']:.3f} | cost ${pos.get('cost',0):.2f}")
else:
print(f"\n Open positions: 0")
print(f"{'=' * 60}\n")
# =============================================================================
# MAIN LOOP
# =============================================================================
MONITOR_INTERVAL = 600 # 10 minutes between monitor cycles
def run_loop():
print(f"\n{C.BOLD}{C.CYAN}🌤 Weather Trading Bot v3 — LIVE{C.RESET}")
print("=" * 60)
print(f" Wallet: {WALLET[:8]}...{WALLET[-4:]}")
print(f" Cities: {len(LOCATIONS)}")
print(f" Max bet: ${MAX_BET} | Kelly fraction: {KELLY_FRAC}")
print(f" Min EV: {MIN_EV*100:.0f}%")
print(f" Scan: every {SCAN_INTERVAL//60} min")
print(f" Monitor: every {MONITOR_INTERVAL//60} min")
print()
# Check approvals on startup
ok("Checking approvals...")
ensure_approvals()
last_full_scan = 0
while True:
now_ts = time.time()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if now_ts - last_full_scan >= SCAN_INTERVAL:
print(f"[{now_str}] Full scan...")
try:
new_trades, errors = scan_and_trade()
last_full_scan = time.time()
except Exception as e:
warn(f"Scan error: {e}")
time.sleep(60)
continue
else:
print(f"[{now_str}] Monitoring...")
time.sleep(MONITOR_INTERVAL)
# =============================================================================
# CLI
# =============================================================================
if __name__ == "__main__":
if not PK or not WALLET:
print("ERROR: PK and WALLET must be set in weatherbot/.env")
sys.exit(1)
cmd = sys.argv[1] if len(sys.argv) > 1 else "scan"
if cmd == "run":
run_loop()
elif cmd == "scan":
scan_and_trade()
elif cmd == "status":
show_status()
elif cmd == "cancel":
market_id = sys.argv[2] if len(sys.argv) > 2 else None
if market_id:
print(f"Cancelling orders for market: {market_id}")
else:
count = cancel_all_orders()
print(f"Cancelled {count} orders")
else:
print(f"Usage: python bot_v3.py [scan|run|status|cancel]")