1029 lines
43 KiB
Python
1029 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
weatherbet.py — Weather Trading Bot for Polymarket
|
|
=====================================================
|
|
Tracks weather forecasts from 3 sources (ECMWF, HRRR, METAR),
|
|
compares with Polymarket markets, paper trades using Kelly criterion.
|
|
|
|
Usage:
|
|
python weatherbet.py # main loop
|
|
python weatherbet.py report # full report
|
|
python weatherbet.py status # balance and open positions
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
import json
|
|
import math
|
|
import time
|
|
import requests
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
|
|
# =============================================================================
|
|
# CONFIG
|
|
# =============================================================================
|
|
|
|
with open("config.json", encoding="utf-8") as f:
|
|
_cfg = json.load(f)
|
|
|
|
BALANCE = _cfg.get("balance", 10000.0)
|
|
MAX_BET = _cfg.get("max_bet", 20.0) # max bet per trade
|
|
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_FRACTION = _cfg.get("kelly_fraction", 0.25)
|
|
MAX_SLIPPAGE = _cfg.get("max_slippage", 0.03) # max allowed ask-bid spread
|
|
SCAN_INTERVAL = _cfg.get("scan_interval", 3600) # every hour
|
|
CALIBRATION_MIN = _cfg.get("calibration_min", 30)
|
|
VC_KEY = _cfg.get("vc_key", "")
|
|
|
|
SIGMA_F = 2.0
|
|
SIGMA_C = 1.2
|
|
|
|
DATA_DIR = Path("data")
|
|
DATA_DIR.mkdir(exist_ok=True)
|
|
STATE_FILE = DATA_DIR / "state.json"
|
|
MARKETS_DIR = DATA_DIR / "markets"
|
|
MARKETS_DIR.mkdir(exist_ok=True)
|
|
CALIBRATION_FILE = DATA_DIR / "calibration.json"
|
|
|
|
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"},
|
|
"london": {"lat": 51.5048, "lon": 0.0495, "name": "London", "station": "EGLC", "unit": "C", "region": "eu"},
|
|
"paris": {"lat": 48.9962, "lon": 2.5979, "name": "Paris", "station": "LFPG", "unit": "C", "region": "eu"},
|
|
"munich": {"lat": 48.3537, "lon": 11.7750, "name": "Munich", "station": "EDDM", "unit": "C", "region": "eu"},
|
|
"ankara": {"lat": 40.1281, "lon": 32.9951, "name": "Ankara", "station": "LTAC", "unit": "C", "region": "eu"},
|
|
"seoul": {"lat": 37.4691, "lon": 126.4505, "name": "Seoul", "station": "RKSI", "unit": "C", "region": "asia"},
|
|
"tokyo": {"lat": 35.7647, "lon": 140.3864, "name": "Tokyo", "station": "RJTT", "unit": "C", "region": "asia"},
|
|
"shanghai": {"lat": 31.1443, "lon": 121.8083, "name": "Shanghai", "station": "ZSPD", "unit": "C", "region": "asia"},
|
|
"singapore": {"lat": 1.3502, "lon": 103.9940, "name": "Singapore", "station": "WSSS", "unit": "C", "region": "asia"},
|
|
"lucknow": {"lat": 26.7606, "lon": 80.8893, "name": "Lucknow", "station": "VILK", "unit": "C", "region": "asia"},
|
|
"tel-aviv": {"lat": 32.0114, "lon": 34.8867, "name": "Tel Aviv", "station": "LLBG", "unit": "C", "region": "asia"},
|
|
"toronto": {"lat": 43.6772, "lon": -79.6306, "name": "Toronto", "station": "CYYZ", "unit": "C", "region": "ca"},
|
|
"sao-paulo": {"lat": -23.4356, "lon": -46.4731, "name": "Sao Paulo", "station": "SBGR", "unit": "C", "region": "sa"},
|
|
"buenos-aires": {"lat": -34.8222, "lon": -58.5358, "name": "Buenos Aires", "station": "SAEZ", "unit": "C", "region": "sa"},
|
|
"wellington": {"lat": -41.3272, "lon": 174.8052, "name": "Wellington", "station": "NZWN", "unit": "C", "region": "oc"},
|
|
}
|
|
|
|
TIMEZONES = {
|
|
"nyc": "America/New_York", "chicago": "America/Chicago",
|
|
"miami": "America/New_York", "dallas": "America/Chicago",
|
|
"seattle": "America/Los_Angeles", "atlanta": "America/New_York",
|
|
"london": "Europe/London", "paris": "Europe/Paris",
|
|
"munich": "Europe/Berlin", "ankara": "Europe/Istanbul",
|
|
"seoul": "Asia/Seoul", "tokyo": "Asia/Tokyo",
|
|
"shanghai": "Asia/Shanghai", "singapore": "Asia/Singapore",
|
|
"lucknow": "Asia/Kolkata", "tel-aviv": "Asia/Jerusalem",
|
|
"toronto": "America/Toronto", "sao-paulo": "America/Sao_Paulo",
|
|
"buenos-aires": "America/Argentina/Buenos_Aires", "wellington": "Pacific/Auckland",
|
|
}
|
|
|
|
MONTHS = ["january","february","march","april","may","june",
|
|
"july","august","september","october","november","december"]
|
|
|
|
# =============================================================================
|
|
# 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=None):
|
|
"""For regular buckets — exact match. For edge buckets — normal distribution."""
|
|
s = sigma or 2.0
|
|
if t_low == -999:
|
|
return norm_cdf((t_high - float(forecast)) / s)
|
|
if t_high == 999:
|
|
return 1.0 - norm_cdf((t_low - float(forecast)) / s)
|
|
return 1.0 if in_bucket(forecast, t_low, t_high) else 0.0
|
|
|
|
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_FRACTION, 1.0), 4)
|
|
|
|
def bet_size(kelly, balance):
|
|
raw = kelly * balance
|
|
return round(min(raw, MAX_BET), 2)
|
|
|
|
# =============================================================================
|
|
# CALIBRATION
|
|
# =============================================================================
|
|
|
|
_cal: dict = {}
|
|
|
|
def load_cal():
|
|
if CALIBRATION_FILE.exists():
|
|
return json.loads(CALIBRATION_FILE.read_text(encoding="utf-8"))
|
|
return {}
|
|
|
|
def get_sigma(city_slug, source="ecmwf"):
|
|
key = f"{city_slug}_{source}"
|
|
if key in _cal:
|
|
return _cal[key]["sigma"]
|
|
return SIGMA_F if LOCATIONS[city_slug]["unit"] == "F" else SIGMA_C
|
|
|
|
def run_calibration(markets):
|
|
"""Recalculates sigma from resolved markets."""
|
|
resolved = [m for m in markets if m.get("resolved") and m.get("actual_temp") is not None]
|
|
cal = load_cal()
|
|
updated = []
|
|
|
|
for source in ["ecmwf", "hrrr", "metar"]:
|
|
for city in set(m["city"] for m in resolved):
|
|
group = [m for m in resolved if m["city"] == city]
|
|
errors = []
|
|
for m in group:
|
|
snap = next((s for s in reversed(m.get("forecast_snapshots", []))
|
|
if s["source"] == source), None)
|
|
if snap and snap.get("temp") is not None:
|
|
errors.append(abs(snap["temp"] - m["actual_temp"]))
|
|
if len(errors) < CALIBRATION_MIN:
|
|
continue
|
|
mae = sum(errors) / len(errors)
|
|
key = f"{city}_{source}"
|
|
old = cal.get(key, {}).get("sigma", SIGMA_F if LOCATIONS[city]["unit"] == "F" else SIGMA_C)
|
|
new = round(mae, 3)
|
|
cal[key] = {"sigma": new, "n": len(errors), "updated_at": datetime.now(timezone.utc).isoformat()}
|
|
if abs(new - old) > 0.05:
|
|
updated.append(f"{LOCATIONS[city]['name']} {source}: {old:.2f}->{new:.2f}")
|
|
|
|
CALIBRATION_FILE.write_text(json.dumps(cal, indent=2), encoding="utf-8")
|
|
if updated:
|
|
print(f" [CAL] {', '.join(updated)}")
|
|
return cal
|
|
|
|
# =============================================================================
|
|
# FORECASTS
|
|
# =============================================================================
|
|
|
|
def get_ecmwf(city_slug, dates):
|
|
"""ECMWF via Open-Meteo with bias correction. For all cities."""
|
|
loc = LOCATIONS[city_slug]
|
|
unit = loc["unit"]
|
|
temp_unit = "fahrenheit" if unit == "F" else "celsius"
|
|
result = {}
|
|
url = (
|
|
f"https://api.open-meteo.com/v1/forecast"
|
|
f"?latitude={loc['lat']}&longitude={loc['lon']}"
|
|
f"&daily=temperature_2m_max&temperature_unit={temp_unit}"
|
|
f"&forecast_days=7&timezone={TIMEZONES.get(city_slug, 'UTC')}"
|
|
f"&models=ecmwf_ifs025&bias_correction=true"
|
|
)
|
|
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, 1) if unit == "C" else round(temp)
|
|
break
|
|
except Exception as e:
|
|
if attempt < 2:
|
|
time.sleep(3)
|
|
else:
|
|
print(f" [ECMWF] {city_slug}: {e}")
|
|
return result
|
|
|
|
def get_hrrr(city_slug, dates):
|
|
"""HRRR via Open-Meteo. US cities only, up to 48h horizon."""
|
|
loc = LOCATIONS[city_slug]
|
|
if loc["region"] != "us":
|
|
return {}
|
|
result = {}
|
|
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=3&timezone={TIMEZONES.get(city_slug, 'UTC')}"
|
|
f"&models=gfs_seamless" # HRRR+GFS seamless — best option for US
|
|
)
|
|
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(3)
|
|
else:
|
|
print(f" [HRRR] {city_slug}: {e}")
|
|
return result
|
|
|
|
def get_metar(city_slug):
|
|
"""Current observed temperature from METAR station. D+0 only."""
|
|
loc = LOCATIONS[city_slug]
|
|
station = loc["station"]
|
|
unit = loc["unit"]
|
|
try:
|
|
url = f"https://aviationweather.gov/api/data/metar?ids={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:
|
|
if unit == "F":
|
|
return round(float(temp_c) * 9/5 + 32)
|
|
return round(float(temp_c), 1)
|
|
except Exception as e:
|
|
print(f" [METAR] {city_slug}: {e}")
|
|
return None
|
|
|
|
def get_actual_temp(city_slug, date_str):
|
|
"""Actual temperature via Visual Crossing for closed markets."""
|
|
loc = LOCATIONS[city_slug]
|
|
station = loc["station"]
|
|
unit = loc["unit"]
|
|
vc_unit = "us" if unit == "F" else "metric"
|
|
url = (
|
|
f"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline"
|
|
f"/{station}/{date_str}/{date_str}"
|
|
f"?unitGroup={vc_unit}&key={VC_KEY}&include=days&elements=tempmax"
|
|
)
|
|
try:
|
|
data = requests.get(url, timeout=(5, 8)).json()
|
|
days = data.get("days", [])
|
|
if days and days[0].get("tempmax") is not None:
|
|
return round(float(days[0]["tempmax"]), 1)
|
|
except Exception as e:
|
|
print(f" [VC] {city_slug} {date_str}: {e}")
|
|
return None
|
|
|
|
def check_market_resolved(market_id):
|
|
"""
|
|
Checks if the market closed on Polymarket and who won.
|
|
Returns: None (still open), True (YES won), False (NO won)
|
|
"""
|
|
try:
|
|
r = requests.get(f"https://gamma-api.polymarket.com/markets/{market_id}", timeout=(5, 8))
|
|
data = r.json()
|
|
closed = data.get("closed", False)
|
|
if not closed:
|
|
return None
|
|
# Check YES price — if ~1.0 then WIN, if ~0.0 then LOSS
|
|
prices = json.loads(data.get("outcomePrices", "[0.5,0.5]"))
|
|
yes_price = float(prices[0])
|
|
if yes_price >= 0.95:
|
|
return True # WIN
|
|
elif yes_price <= 0.05:
|
|
return False # LOSS
|
|
return None # not yet determined
|
|
except Exception as e:
|
|
print(f" [RESOLVE] {market_id}: {e}")
|
|
return None
|
|
|
|
# =============================================================================
|
|
# 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:
|
|
pass
|
|
return None
|
|
|
|
def get_market_price(market_id):
|
|
try:
|
|
r = requests.get(f"https://gamma-api.polymarket.com/markets/{market_id}", timeout=(3, 5))
|
|
prices = json.loads(r.json().get("outcomePrices", "[0.5,0.5]"))
|
|
return float(prices[0])
|
|
except Exception:
|
|
return 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
|
|
|
|
# =============================================================================
|
|
# MARKET DATA STORAGE
|
|
# Each market is stored in a separate file: data/markets/{city}_{date}.json
|
|
# =============================================================================
|
|
|
|
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
|
|
|
|
def new_market(city_slug, date_str, event, hours):
|
|
loc = LOCATIONS[city_slug]
|
|
return {
|
|
"city": city_slug,
|
|
"city_name": loc["name"],
|
|
"date": date_str,
|
|
"unit": loc["unit"],
|
|
"station": loc["station"],
|
|
"event_end_date": event.get("endDate", ""),
|
|
"hours_at_discovery": round(hours, 1),
|
|
"status": "open", # open | closed | resolved
|
|
"position": None, # filled when position opens
|
|
"actual_temp": None, # filled after resolution
|
|
"resolved_outcome": None, # win / loss / no_position
|
|
"pnl": None,
|
|
"forecast_snapshots": [], # list of forecast snapshots
|
|
"market_snapshots": [], # list of market price snapshots
|
|
"all_outcomes": [], # all market buckets
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
# =============================================================================
|
|
# STATE (balance and open positions)
|
|
# =============================================================================
|
|
|
|
def load_state():
|
|
if STATE_FILE.exists():
|
|
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
return {
|
|
"balance": BALANCE,
|
|
"starting_balance": BALANCE,
|
|
"total_trades": 0,
|
|
"wins": 0,
|
|
"losses": 0,
|
|
"peak_balance": BALANCE,
|
|
}
|
|
|
|
def save_state(state):
|
|
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
# =============================================================================
|
|
# CORE LOGIC
|
|
# =============================================================================
|
|
|
|
def take_forecast_snapshot(city_slug, dates):
|
|
"""Fetches forecasts from all sources and returns a snapshot."""
|
|
now_str = datetime.now(timezone.utc).isoformat()
|
|
ecmwf = get_ecmwf(city_slug, dates)
|
|
hrrr = get_hrrr(city_slug, dates)
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
|
|
snapshots = {}
|
|
for date in dates:
|
|
snap = {
|
|
"ts": now_str,
|
|
"ecmwf": ecmwf.get(date),
|
|
"hrrr": hrrr.get(date) if date <= (datetime.now(timezone.utc) + timedelta(days=2)).strftime("%Y-%m-%d") else None,
|
|
"metar": get_metar(city_slug) if date == today else None,
|
|
}
|
|
# Best forecast: HRRR for US D+0/D+1, otherwise ECMWF
|
|
loc = LOCATIONS[city_slug]
|
|
if loc["region"] == "us" and snap["hrrr"] is not None:
|
|
snap["best"] = snap["hrrr"]
|
|
snap["best_source"] = "hrrr"
|
|
elif snap["ecmwf"] is not None:
|
|
snap["best"] = snap["ecmwf"]
|
|
snap["best_source"] = "ecmwf"
|
|
else:
|
|
snap["best"] = None
|
|
snap["best_source"] = None
|
|
snapshots[date] = snap
|
|
return snapshots
|
|
|
|
def scan_and_update():
|
|
"""Main function of one cycle: updates forecasts, opens/closes positions."""
|
|
global _cal
|
|
now = datetime.now(timezone.utc)
|
|
state = load_state()
|
|
balance = state["balance"]
|
|
new_pos = 0
|
|
closed = 0
|
|
resolved = 0
|
|
|
|
for city_slug, loc in LOCATIONS.items():
|
|
unit = loc["unit"]
|
|
unit_sym = "F" if unit == "F" else "C"
|
|
print(f" -> {loc['name']}...", end=" ", flush=True)
|
|
|
|
try:
|
|
dates = [(now + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(4)]
|
|
snapshots = take_forecast_snapshot(city_slug, dates)
|
|
time.sleep(0.3)
|
|
except Exception as e:
|
|
print(f"skipped ({e})")
|
|
continue
|
|
|
|
for i, date in enumerate(dates):
|
|
dt = datetime.strptime(date, "%Y-%m-%d")
|
|
event = get_polymarket_event(city_slug, MONTHS[dt.month - 1], dt.day, dt.year)
|
|
if not event:
|
|
continue
|
|
|
|
end_date = event.get("endDate", "")
|
|
hours = hours_to_resolution(end_date) if end_date else 0
|
|
horizon = f"D+{i}"
|
|
|
|
# Load or create market record
|
|
mkt = load_market(city_slug, date)
|
|
if mkt is None:
|
|
if hours < MIN_HOURS or hours > MAX_HOURS:
|
|
continue
|
|
mkt = new_market(city_slug, date, event, hours)
|
|
|
|
# Skip if market already resolved
|
|
if mkt["status"] == "resolved":
|
|
continue
|
|
|
|
# Update outcomes list — prices taken directly from event
|
|
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), # for compatibility
|
|
"spread": round(ask - bid, 4),
|
|
"volume": round(volume, 0),
|
|
})
|
|
|
|
outcomes.sort(key=lambda x: x["range"][0])
|
|
mkt["all_outcomes"] = outcomes
|
|
|
|
# Forecast snapshot
|
|
snap = snapshots.get(date, {})
|
|
forecast_snap = {
|
|
"ts": snap.get("ts"),
|
|
"horizon": horizon,
|
|
"hours_left": round(hours, 1),
|
|
"ecmwf": snap.get("ecmwf"),
|
|
"hrrr": snap.get("hrrr"),
|
|
"metar": snap.get("metar"),
|
|
"best": snap.get("best"),
|
|
"best_source": snap.get("best_source"),
|
|
}
|
|
mkt["forecast_snapshots"].append(forecast_snap)
|
|
|
|
# Market price snapshot
|
|
top = max(outcomes, key=lambda x: x["price"]) if outcomes else None
|
|
market_snap = {
|
|
"ts": snap.get("ts"),
|
|
"top_bucket": f"{top['range'][0]}-{top['range'][1]}{unit_sym}" if top else None,
|
|
"top_price": top["price"] if top else None,
|
|
}
|
|
mkt["market_snapshots"].append(market_snap)
|
|
|
|
forecast_temp = snap.get("best")
|
|
best_source = snap.get("best_source")
|
|
|
|
# --- STOP-LOSS AND TRAILING STOP ---
|
|
if mkt.get("position") and mkt["position"].get("status") == "open":
|
|
pos = mkt["position"]
|
|
current_price = None
|
|
for o in outcomes:
|
|
if o["market_id"] == pos["market_id"]:
|
|
current_price = o["price"]
|
|
break
|
|
|
|
if current_price is not None:
|
|
current_price = o.get("bid", current_price) # sell at bid
|
|
entry = pos["entry_price"]
|
|
stop = pos.get("stop_price", entry * 0.80) # 20% stop by default
|
|
|
|
# Trailing: if up 20%+ — move stop to breakeven
|
|
if current_price >= entry * 1.20 and stop < entry:
|
|
pos["stop_price"] = entry
|
|
pos["trailing_activated"] = True
|
|
|
|
# Check stop
|
|
if current_price <= stop:
|
|
pnl = round((current_price - entry) * pos["shares"], 2)
|
|
balance += pos["cost"] + pnl
|
|
pos["closed_at"] = snap.get("ts")
|
|
pos["close_reason"] = "stop_loss" if current_price < entry else "trailing_stop"
|
|
pos["exit_price"] = current_price
|
|
pos["pnl"] = pnl
|
|
pos["status"] = "closed"
|
|
closed += 1
|
|
reason = "STOP" if current_price < entry else "TRAILING BE"
|
|
print(f" [{reason}] {loc['name']} {date} | entry ${entry:.3f} exit ${current_price:.3f} | PnL: {'+'if pnl>=0 else ''}{pnl:.2f}")
|
|
|
|
# --- CLOSE POSITION if forecast shifted 2+ degrees ---
|
|
if mkt.get("position") and forecast_temp is not None:
|
|
pos = mkt["position"]
|
|
old_bucket_low = pos["bucket_low"]
|
|
old_bucket_high = pos["bucket_high"]
|
|
# 2-degree buffer — avoid closing on small forecast fluctuations
|
|
unit = loc["unit"]
|
|
buffer = 2.0 if unit == "F" else 1.0
|
|
mid_bucket = (old_bucket_low + old_bucket_high) / 2 if old_bucket_low != -999 and old_bucket_high != 999 else forecast_temp
|
|
forecast_far = abs(forecast_temp - mid_bucket) > (abs(mid_bucket - old_bucket_low) + buffer)
|
|
if not in_bucket(forecast_temp, old_bucket_low, old_bucket_high) and forecast_far:
|
|
current_price = None
|
|
for o in outcomes:
|
|
if o["market_id"] == pos["market_id"]:
|
|
current_price = o["price"]
|
|
break
|
|
if current_price is not None:
|
|
pnl = round((current_price - pos["entry_price"]) * pos["shares"], 2)
|
|
balance += pos["cost"] + pnl
|
|
mkt["position"]["closed_at"] = snap.get("ts")
|
|
mkt["position"]["close_reason"] = "forecast_changed"
|
|
mkt["position"]["exit_price"] = current_price
|
|
mkt["position"]["pnl"] = pnl
|
|
mkt["position"]["status"] = "closed"
|
|
closed += 1
|
|
print(f" [CLOSE] {loc['name']} {date} — forecast changed | PnL: {'+'if pnl>=0 else ''}{pnl:.2f}")
|
|
|
|
# --- OPEN POSITION ---
|
|
if not mkt.get("position") and forecast_temp is not None and hours >= MIN_HOURS:
|
|
sigma = get_sigma(city_slug, best_source or "ecmwf")
|
|
best_signal = None
|
|
|
|
# Find exactly ONE bucket that matches the forecast
|
|
# If forecast doesn't fit any bucket cleanly — skip this market
|
|
matched_bucket = None
|
|
for o in outcomes:
|
|
t_low, t_high = o["range"]
|
|
if in_bucket(forecast_temp, t_low, t_high):
|
|
matched_bucket = o
|
|
break
|
|
|
|
if matched_bucket:
|
|
o = matched_bucket
|
|
t_low, t_high = o["range"]
|
|
volume = o["volume"]
|
|
bid = o.get("bid", o["price"])
|
|
ask = o.get("ask", o["price"])
|
|
spread = o.get("spread", 0)
|
|
|
|
# All filters — if any fails, skip this market entirely
|
|
if volume >= MIN_VOLUME:
|
|
p = bucket_prob(forecast_temp, t_low, t_high, sigma)
|
|
ev = calc_ev(p, ask)
|
|
if ev >= MIN_EV:
|
|
kelly = calc_kelly(p, ask)
|
|
size = bet_size(kelly, balance)
|
|
if size >= 0.50:
|
|
best_signal = {
|
|
"market_id": o["market_id"],
|
|
"question": o["question"],
|
|
"bucket_low": t_low,
|
|
"bucket_high": t_high,
|
|
"entry_price": ask,
|
|
"bid_at_entry": bid,
|
|
"spread": spread,
|
|
"shares": round(size / ask, 2),
|
|
"cost": size,
|
|
"p": round(p, 4),
|
|
"ev": round(ev, 4),
|
|
"kelly": round(kelly, 4),
|
|
"forecast_temp":forecast_temp,
|
|
"forecast_src": best_source,
|
|
"sigma": sigma,
|
|
"opened_at": snap.get("ts"),
|
|
"status": "open",
|
|
"pnl": None,
|
|
"exit_price": None,
|
|
"close_reason": None,
|
|
"closed_at": None,
|
|
}
|
|
|
|
if best_signal:
|
|
# Fetch real bestAsk from Polymarket API for accurate entry price
|
|
skip_position = False
|
|
try:
|
|
r = requests.get(f"https://gamma-api.polymarket.com/markets/{best_signal['market_id']}", timeout=(3, 5))
|
|
mdata = r.json()
|
|
real_ask = float(mdata.get("bestAsk", best_signal["entry_price"]))
|
|
real_bid = float(mdata.get("bestBid", best_signal["bid_at_entry"]))
|
|
real_spread = round(real_ask - real_bid, 4)
|
|
# Re-check slippage and price with real values
|
|
if real_spread > MAX_SLIPPAGE or real_ask >= MAX_PRICE:
|
|
print(f" [SKIP] {loc['name']} {date} — real ask ${real_ask:.3f} spread ${real_spread:.3f}")
|
|
skip_position = True
|
|
else:
|
|
best_signal["entry_price"] = real_ask
|
|
best_signal["bid_at_entry"] = real_bid
|
|
best_signal["spread"] = real_spread
|
|
best_signal["shares"] = round(best_signal["cost"] / real_ask, 2)
|
|
best_signal["ev"] = round(calc_ev(best_signal["p"], real_ask), 4)
|
|
except Exception as e:
|
|
print(f" [WARN] Could not fetch real ask for {best_signal['market_id']}: {e}")
|
|
|
|
if not skip_position and best_signal["entry_price"] < MAX_PRICE:
|
|
balance -= best_signal["cost"]
|
|
mkt["position"] = best_signal
|
|
state["total_trades"] += 1
|
|
new_pos += 1
|
|
bucket_label = f"{best_signal['bucket_low']}-{best_signal['bucket_high']}{unit_sym}"
|
|
print(f" [BUY] {loc['name']} {horizon} {date} | {bucket_label} | "
|
|
f"${best_signal['entry_price']:.3f} | EV {best_signal['ev']:+.2f} | "
|
|
f"${best_signal['cost']:.2f} ({best_signal['forecast_src'].upper()})")
|
|
|
|
# Market closed by time
|
|
if hours < 0.5 and mkt["status"] == "open":
|
|
mkt["status"] = "closed"
|
|
|
|
save_market(mkt)
|
|
time.sleep(0.1)
|
|
|
|
print("ok")
|
|
|
|
# --- AUTO-RESOLUTION ---
|
|
for mkt in load_all_markets():
|
|
if mkt["status"] == "resolved":
|
|
continue
|
|
|
|
pos = mkt.get("position")
|
|
if not pos or pos.get("status") != "open":
|
|
continue
|
|
|
|
market_id = pos.get("market_id")
|
|
if not market_id:
|
|
continue
|
|
|
|
# Check if market closed on Polymarket
|
|
won = check_market_resolved(market_id)
|
|
if won is None:
|
|
continue # market still open
|
|
|
|
# Market closed — record result
|
|
price = pos["entry_price"]
|
|
size = pos["cost"]
|
|
shares = pos["shares"]
|
|
pnl = round(shares * (1 - price), 2) if won else round(-size, 2)
|
|
|
|
balance += size + pnl
|
|
pos["exit_price"] = 1.0 if won else 0.0
|
|
pos["pnl"] = pnl
|
|
pos["close_reason"] = "resolved"
|
|
pos["closed_at"] = now.isoformat()
|
|
pos["status"] = "closed"
|
|
mkt["pnl"] = pnl
|
|
mkt["status"] = "resolved"
|
|
mkt["resolved_outcome"] = "win" if won else "loss"
|
|
|
|
if won:
|
|
state["wins"] += 1
|
|
else:
|
|
state["losses"] += 1
|
|
|
|
result = "WIN" if won else "LOSS"
|
|
print(f" [{result}] {mkt['city_name']} {mkt['date']} | PnL: {'+'if pnl>=0 else ''}{pnl:.2f}")
|
|
resolved += 1
|
|
|
|
save_market(mkt)
|
|
time.sleep(0.3)
|
|
|
|
state["balance"] = round(balance, 2)
|
|
state["peak_balance"] = max(state.get("peak_balance", balance), balance)
|
|
save_state(state)
|
|
|
|
# Run calibration if enough data collected
|
|
all_mkts = load_all_markets()
|
|
resolved_count = len([m for m in all_mkts if m["status"] == "resolved"])
|
|
if resolved_count >= CALIBRATION_MIN:
|
|
global _cal
|
|
_cal = run_calibration(all_mkts)
|
|
|
|
return new_pos, closed, resolved
|
|
|
|
# =============================================================================
|
|
# REPORT
|
|
# =============================================================================
|
|
|
|
def print_status():
|
|
state = load_state()
|
|
markets = load_all_markets()
|
|
open_pos = [m for m in markets if m.get("position") and m["position"].get("status") == "open"]
|
|
resolved = [m for m in markets if m["status"] == "resolved" and m.get("pnl") is not None]
|
|
|
|
bal = state["balance"]
|
|
start = state["starting_balance"]
|
|
ret_pct = (bal - start) / start * 100
|
|
wins = state["wins"]
|
|
losses = state["losses"]
|
|
total = wins + losses
|
|
|
|
print(f"\n{'='*55}")
|
|
print(f" WEATHERBET — STATUS")
|
|
print(f"{'='*55}")
|
|
print(f" Balance: ${bal:,.2f} (start ${start:,.2f}, {'+'if ret_pct>=0 else ''}{ret_pct:.1f}%)")
|
|
print(f" Trades: {total} | W: {wins} | L: {losses} | WR: {wins/total:.0%}" if total else " No trades yet")
|
|
print(f" Open: {len(open_pos)}")
|
|
print(f" Resolved: {len(resolved)}")
|
|
|
|
if open_pos:
|
|
print(f"\n Open positions:")
|
|
total_unrealized = 0.0
|
|
for m in open_pos:
|
|
pos = m["position"]
|
|
unit_sym = "F" if m["unit"] == "F" else "C"
|
|
label = f"{pos['bucket_low']}-{pos['bucket_high']}{unit_sym}"
|
|
|
|
# Current price from latest market snapshot
|
|
current_price = pos["entry_price"]
|
|
snaps = m.get("market_snapshots", [])
|
|
if snaps:
|
|
# Find our bucket price in all_outcomes
|
|
for o in m.get("all_outcomes", []):
|
|
if o["market_id"] == pos["market_id"]:
|
|
current_price = o["price"]
|
|
break
|
|
|
|
unrealized = round((current_price - pos["entry_price"]) * pos["shares"], 2)
|
|
total_unrealized += unrealized
|
|
pnl_str = f"{'+'if unrealized>=0 else ''}{unrealized:.2f}"
|
|
|
|
print(f" {m['city_name']:<16} {m['date']} | {label:<14} | "
|
|
f"entry ${pos['entry_price']:.3f} -> ${current_price:.3f} | "
|
|
f"PnL: {pnl_str} | {pos['forecast_src'].upper()}")
|
|
|
|
sign = "+" if total_unrealized >= 0 else ""
|
|
print(f"\n Unrealized PnL: {sign}{total_unrealized:.2f}")
|
|
|
|
print(f"{'='*55}\n")
|
|
|
|
def print_report():
|
|
markets = load_all_markets()
|
|
resolved = [m for m in markets if m["status"] == "resolved" and m.get("pnl") is not None]
|
|
|
|
print(f"\n{'='*55}")
|
|
print(f" WEATHERBET — FULL REPORT")
|
|
print(f"{'='*55}")
|
|
|
|
if not resolved:
|
|
print(" No resolved markets yet.")
|
|
return
|
|
|
|
total_pnl = sum(m["pnl"] for m in resolved)
|
|
wins = [m for m in resolved if m["resolved_outcome"] == "win"]
|
|
losses = [m for m in resolved if m["resolved_outcome"] == "loss"]
|
|
|
|
print(f"\n Total resolved: {len(resolved)}")
|
|
print(f" Wins: {len(wins)} | Losses: {len(losses)}")
|
|
print(f" Win rate: {len(wins)/len(resolved):.0%}")
|
|
print(f" Total PnL: {'+'if total_pnl>=0 else ''}{total_pnl:.2f}")
|
|
|
|
print(f"\n By city:")
|
|
for city in sorted(set(m["city"] for m in resolved)):
|
|
group = [m for m in resolved if m["city"] == city]
|
|
w = len([m for m in group if m["resolved_outcome"] == "win"])
|
|
pnl = sum(m["pnl"] for m in group)
|
|
name = LOCATIONS[city]["name"]
|
|
print(f" {name:<16} {w}/{len(group)} ({w/len(group):.0%}) PnL: {'+'if pnl>=0 else ''}{pnl:.2f}")
|
|
|
|
print(f"\n Market details:")
|
|
for m in sorted(resolved, key=lambda x: x["date"]):
|
|
pos = m.get("position", {})
|
|
unit_sym = "F" if m["unit"] == "F" else "C"
|
|
snaps = m.get("forecast_snapshots", [])
|
|
first_fc = snaps[0]["best"] if snaps else None
|
|
last_fc = snaps[-1]["best"] if snaps else None
|
|
label = f"{pos.get('bucket_low')}-{pos.get('bucket_high')}{unit_sym}" if pos else "no position"
|
|
result = m["resolved_outcome"].upper()
|
|
pnl_str = f"{'+'if m['pnl']>=0 else ''}{m['pnl']:.2f}" if m["pnl"] is not None else "-"
|
|
fc_str = f"forecast {first_fc}->{last_fc}{unit_sym}" if first_fc else "no forecast"
|
|
actual = f"actual {m['actual_temp']}{unit_sym}" if m["actual_temp"] else ""
|
|
print(f" {m['city_name']:<16} {m['date']} | {label:<14} | {fc_str} | {actual} | {result} {pnl_str}")
|
|
|
|
print(f"{'='*55}\n")
|
|
|
|
# =============================================================================
|
|
# MAIN LOOP
|
|
# =============================================================================
|
|
|
|
MONITOR_INTERVAL = 600 # monitor positions every 10 minutes
|
|
|
|
def monitor_positions():
|
|
"""Quick stop check on open positions without full scan."""
|
|
markets = load_all_markets()
|
|
open_pos = [m for m in markets if m.get("position") and m["position"].get("status") == "open"]
|
|
if not open_pos:
|
|
return 0
|
|
|
|
state = load_state()
|
|
balance = state["balance"]
|
|
closed = 0
|
|
|
|
for mkt in open_pos:
|
|
pos = mkt["position"]
|
|
mid = pos["market_id"]
|
|
|
|
# Fetch real bestBid from Polymarket API — actual sell price
|
|
current_price = None
|
|
try:
|
|
r = requests.get(f"https://gamma-api.polymarket.com/markets/{mid}", timeout=(3, 5))
|
|
mdata = r.json()
|
|
best_bid = mdata.get("bestBid")
|
|
if best_bid is not None:
|
|
current_price = float(best_bid)
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback to cached price if API failed
|
|
if current_price is None:
|
|
for o in mkt.get("all_outcomes", []):
|
|
if o["market_id"] == mid:
|
|
current_price = o.get("bid", o["price"])
|
|
break
|
|
|
|
if current_price is None:
|
|
continue
|
|
|
|
entry = pos["entry_price"]
|
|
stop = pos.get("stop_price", entry * 0.80)
|
|
city_name = LOCATIONS.get(mkt["city"], {}).get("name", mkt["city"])
|
|
|
|
# Hours left to resolution
|
|
end_date = mkt.get("event_end_date", "")
|
|
hours_left = hours_to_resolution(end_date) if end_date else 999.0
|
|
|
|
# Take-profit threshold based on hours to resolution
|
|
if hours_left < 24:
|
|
take_profit = None # hold to resolution
|
|
elif hours_left < 48:
|
|
take_profit = 0.85 # 24-48h: take profit at $0.85
|
|
else:
|
|
take_profit = 0.75 # 48h+: take profit at $0.75
|
|
|
|
# Trailing: if up 20%+ — move stop to breakeven
|
|
if current_price >= entry * 1.20 and stop < entry:
|
|
pos["stop_price"] = entry
|
|
pos["trailing_activated"] = True
|
|
print(f" [TRAILING] {city_name} {mkt['date']} — stop moved to breakeven ${entry:.3f}")
|
|
|
|
# Check take-profit
|
|
take_triggered = take_profit is not None and current_price >= take_profit
|
|
# Check stop
|
|
stop_triggered = current_price <= stop
|
|
|
|
if take_triggered or stop_triggered:
|
|
pnl = round((current_price - entry) * pos["shares"], 2)
|
|
balance += pos["cost"] + pnl
|
|
pos["closed_at"] = datetime.now(timezone.utc).isoformat()
|
|
if take_triggered:
|
|
pos["close_reason"] = "take_profit"
|
|
reason = "TAKE"
|
|
elif current_price < entry:
|
|
pos["close_reason"] = "stop_loss"
|
|
reason = "STOP"
|
|
else:
|
|
pos["close_reason"] = "trailing_stop"
|
|
reason = "TRAILING BE"
|
|
pos["exit_price"] = current_price
|
|
pos["pnl"] = pnl
|
|
pos["status"] = "closed"
|
|
closed += 1
|
|
print(f" [{reason}] {city_name} {mkt['date']} | entry ${entry:.3f} exit ${current_price:.3f} | {hours_left:.0f}h left | PnL: {'+'if pnl>=0 else ''}{pnl:.2f}")
|
|
save_market(mkt)
|
|
|
|
if closed:
|
|
state["balance"] = round(balance, 2)
|
|
save_state(state)
|
|
|
|
return closed
|
|
|
|
|
|
def run_loop():
|
|
global _cal
|
|
_cal = load_cal()
|
|
|
|
print(f"\n{'='*55}")
|
|
print(f" WEATHERBET — STARTING")
|
|
print(f"{'='*55}")
|
|
print(f" Cities: {len(LOCATIONS)}")
|
|
print(f" Balance: ${BALANCE:,.0f} | Max bet: ${MAX_BET}")
|
|
print(f" Scan: {SCAN_INTERVAL//60} min | Monitor: {MONITOR_INTERVAL//60} min")
|
|
print(f" Sources: ECMWF + HRRR(US) + METAR(D+0)")
|
|
print(f" Data: {DATA_DIR.resolve()}")
|
|
print(f" Ctrl+C to stop\n")
|
|
|
|
last_full_scan = 0
|
|
|
|
while True:
|
|
now_ts = time.time()
|
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
# Full scan once per hour
|
|
if now_ts - last_full_scan >= SCAN_INTERVAL:
|
|
print(f"[{now_str}] full scan...")
|
|
try:
|
|
new_pos, closed, resolved = scan_and_update()
|
|
state = load_state()
|
|
print(f" balance: ${state['balance']:,.2f} | "
|
|
f"new: {new_pos} | closed: {closed} | resolved: {resolved}")
|
|
last_full_scan = time.time()
|
|
except KeyboardInterrupt:
|
|
print(f"\n Stopping — saving state...")
|
|
save_state(load_state())
|
|
print(f" Done. Bye!")
|
|
break
|
|
except requests.exceptions.ConnectionError:
|
|
print(f" Connection lost — waiting 60 sec")
|
|
time.sleep(60)
|
|
continue
|
|
except Exception as e:
|
|
print(f" Error: {e} — waiting 60 sec")
|
|
time.sleep(60)
|
|
continue
|
|
else:
|
|
# Quick stop monitoring
|
|
print(f"[{now_str}] monitoring positions...")
|
|
try:
|
|
stopped = monitor_positions()
|
|
if stopped:
|
|
state = load_state()
|
|
print(f" balance: ${state['balance']:,.2f}")
|
|
except Exception as e:
|
|
print(f" Monitor error: {e}")
|
|
|
|
try:
|
|
time.sleep(MONITOR_INTERVAL)
|
|
except KeyboardInterrupt:
|
|
print(f"\n Stopping — saving state...")
|
|
save_state(load_state())
|
|
print(f" Done. Bye!")
|
|
break
|
|
|
|
# =============================================================================
|
|
# CLI
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "run"
|
|
if cmd == "run":
|
|
run_loop()
|
|
elif cmd == "status":
|
|
_cal = load_cal()
|
|
print_status()
|
|
elif cmd == "report":
|
|
_cal = load_cal()
|
|
print_report()
|
|
else:
|
|
print("Usage: python weatherbet.py [run|status|report]")
|