913 lines
32 KiB
Python
913 lines
32 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 dotenv
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
|
|
# =============================================================================
|
|
# 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
|
|
|
|
# --- 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):
|
|
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)
|
|
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_FRAC, 1.0), 4)
|
|
|
|
def bet_size(kelly, balance):
|
|
raw = kelly * balance
|
|
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}")
|
|
|
|
# =============================================================================
|
|
# 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."""
|
|
w3 = get_w3()
|
|
usdc_abi = [
|
|
{
|
|
"name": "balanceOf",
|
|
"inputs": [{"name": "account", "type": "address"}],
|
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"name": "decimals",
|
|
"inputs": [],
|
|
"outputs": [{"name": "", "type": "uint8"}],
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
}
|
|
]
|
|
usdc = w3.eth.contract(
|
|
address=Web3.to_checksum_address(USDC_ADDRESS),
|
|
abi=usdc_abi
|
|
)
|
|
try:
|
|
decimals = usdc.functions.decimals().call()
|
|
bal = usdc.functions.balanceOf(Web3.to_checksum_address(wallet)).call()
|
|
return bal / (10 ** 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
|
|
|
|
# =============================================================================
|
|
# 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,
|
|
balance: 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.
|
|
"""
|
|
w3 = get_w3()
|
|
clob = get_clob()
|
|
|
|
cost = round(shares * price, 4)
|
|
if cost > balance:
|
|
return {"success": False, "reason": f"Insufficient balance (${balance:.2f} < ${cost:.2f})"}
|
|
|
|
if not is_approved(USDC_ADDRESS, ROUTER, wallet):
|
|
return {"success": False, "reason": "Router approval missing"}
|
|
|
|
# --- Market order via CLOB ---
|
|
order_args = MarketOrderArgs(
|
|
token_id=token_id,
|
|
amount=cost, # For BUY: amount is in dollars (USDC)
|
|
side="BUY",
|
|
price=price,
|
|
)
|
|
|
|
try:
|
|
# First check allowance
|
|
clob.assert_level_1_auth()
|
|
order_result = clob.create_market_order(order_args)
|
|
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"]
|
|
|
|
import requests
|
|
|
|
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."""
|
|
try:
|
|
r = requests.get(f"https://gamma-api.polymarket.com/markets/{market_id}", timeout=(5, 8))
|
|
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 = []
|
|
|
|
for city_slug, loc in LOCATIONS.items():
|
|
print(f" -> {loc['name']}...", end=" ", flush=True)
|
|
unit_sym = "F"
|
|
|
|
try:
|
|
dates = [(now + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(4)]
|
|
forecasts = get_forecast_snapshot(city_slug, dates)
|
|
time.sleep(0.3)
|
|
except Exception as e:
|
|
print(f"error ({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}"
|
|
|
|
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
|
|
|
|
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"]
|
|
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
|
|
|
|
p = bucket_prob(forecast_temp, t_low, t_high, sigma)
|
|
ev = calc_ev(p, ask)
|
|
if ev < MIN_EV:
|
|
continue
|
|
|
|
kelly = calc_kelly(p, ask)
|
|
size = bet_size(kelly, balance)
|
|
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(kelly, 4),
|
|
"forecast_temp": forecast_temp,
|
|
"forecast_src": best_source,
|
|
"sigma": sigma,
|
|
"volume": volume,
|
|
}
|
|
break # Only one bucket per market
|
|
|
|
if best_signal:
|
|
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"],
|
|
balance=balance,
|
|
private_key=PK,
|
|
wallet=WALLET,
|
|
)
|
|
|
|
if result["success"]:
|
|
new_trades += 1
|
|
state["total_trades"] += 1
|
|
balance -= best_signal["cost"]
|
|
|
|
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)
|
|
else:
|
|
errors.append(f"{loc['name']} {horizon}: {result['reason']}")
|
|
warn(f" ❌ Order failed: {result['reason']}")
|
|
else:
|
|
# No signal — show why
|
|
for o in outcomes:
|
|
t_low, t_high = o["range"]
|
|
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")
|
|
|
|
# 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")
|
|
|
|
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
|
|
|
|
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]")
|