Files
hk-weather-mkt/ml/predictor.py
T
ramseshk 11182b47f8 Fix ML calibration: logistic regression + Platt/isotonic + realistic NWP errors
Calibration overhaul:
- Logistic regression mode for synthetic/bootstrap data (prevents LightGBM overfit)
- 3-layer calibration stack: raw LR → Platt scaling → isotonic regression
- Extreme probability smoothing: blend toward 0.5 when raw>0.95 or raw<0.05
- Platt preferred over isotonic (isotonic produces step functions with few points)
- Continuous precipitation probability in bootstrap (beta distribution, not just 0/100)
- Realistic NWP forecast errors: temp σ=2.0°C, rain calibration bias, diurnal-aware noise
- Outlier injection: 10% of days have 2-3x larger errors (typhoon/low-pressure days)
- LR model + StandardScaler saved as _lr.pkl alongside .lgb marker

Results:
- temp_gt_30c: AUC=0.987, Brier=0.049, predictions vary 20-85% per day
- rain_gt_0mm: AUC=0.979, Brier=0.042, predictions vary 15-85% per day
- temp_gt_35c: AUC=0.713 (realistic — extreme heat is hard to predict)
2026-08-11 10:50:05 +08:00

451 lines
18 KiB
Python

"""ML-powered signal generator for HK weather prediction markets.
Uses three-layer calibrated LightGBM models (raw → Platt → isotonic)
combined with spatial features, typhoon model, and portfolio Kelly.
"""
import sys
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, Optional, Tuple, List
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from ml.features import FeatureEngine
from ml.model import ModelEnsemble, TARGET_DEFINITIONS
from ml.spatial import SpatialWeatherClient
from ml.typhoon import TyphoonModel
from weather.openmeteo_client import OpenMeteoClient
from weather.hko_client import HKOClient
from strategy.calibrator import ProbabilityCalibrator
from strategy.kelly import KellyCriterion
from strategy.portfolio_kelly import PortfolioKelly
from config import HK_COORDS, MIN_EDGE_BPS, KELLY_FRACTION, MAX_POSITION_USDC
class MLPredictor:
"""
ML-based weather probability predictor for Polymarket trading.
Combines:
1. Feature engineering from NWP model output
2. Trained LightGBM probability models
3. Platt scaling calibration on historical outcomes
4. Ensemble disagreement as edge amplifier
5. Kelly criterion position sizing
"""
def __init__(
self,
bankroll_usdc: float = 1000.0,
min_edge_bps: float = MIN_EDGE_BPS,
kelly_fraction: float = KELLY_FRACTION,
):
self.engine = FeatureEngine()
self.ensemble = ModelEnsemble()
self.calibrator = ProbabilityCalibrator()
self.kelly = KellyCriterion(bankroll_usdc=bankroll_usdc, fraction=kelly_fraction)
self.portfolio_kelly = PortfolioKelly(fraction=kelly_fraction)
self.openmeteo = OpenMeteoClient()
self.hko = HKOClient()
self.spatial = SpatialWeatherClient()
self.typhoon = TyphoonModel()
self.min_edge_bps = min_edge_bps
# Ensemble disagreement tracking
self._last_forecast: Optional[pd.DataFrame] = None
self._last_hourly: Optional[pd.DataFrame] = None
self._last_features: Optional[np.ndarray] = None
self._last_predictions: Optional[Dict[str, float]] = None
self._last_spatial: Optional[Dict[str, float]] = None
self._last_typhoon: Optional[Dict[str, float]] = None
self._ensemble_spread: Optional[Dict[str, float]] = None
# Load trained models
self.models_loaded = self._load_models()
def _load_models(self) -> bool:
"""Load trained models if available."""
try:
self.ensemble.load_all()
return len(self.ensemble.models) > 0
except Exception as e:
print(f"ML models not loaded (train first): {e}")
return False
def fetch_and_predict(self, target_date: Optional[str] = None) -> Dict[str, float]:
"""
Fetch latest forecast and predict all targets.
Returns dict of {target_name: calibrated_probability_0_100}
"""
# Fetch data
daily = self.openmeteo.get_forecast(lead_days=7)
if daily is None:
print("MLPredictor: No forecast data available")
return self._fallback_predictions()
# Get hourly data from the client's internal cache
hourly = getattr(self.openmeteo, '_last_hourly', None)
self._last_forecast = daily
self._last_hourly = hourly
# Feature engineering
X = self.engine.transform(daily, hourly)
self._last_features = X
# If models loaded, use ML predictions
if self.models_loaded and len(self.ensemble.models) > 0:
predictions = {}
for day_idx in range(min(len(daily), 7)):
date_str = daily.index[day_idx].strftime("%Y-%m-%d") if hasattr(daily.index[day_idx], 'strftime') else str(daily.index[day_idx])
if target_date and date_str != target_date and day_idx > 1:
continue
# Predict all loaded targets for this day
X_day = X[day_idx].reshape(1, -1)
day_probs = self.ensemble.predict_all(X_day)
# Calibrate
calibrated = {}
for target, raw_prob in day_probs.items():
calibrated[target] = self.calibrator.calibrate(target, raw_prob)
# Compute ensemble disagreement (multi-level)
spread = self._compute_multimodel_spread(daily, hourly, day_idx)
self._ensemble_spread = spread
# Amplify edge based on spread
for target in calibrated:
adjusted = self._adjust_with_spread(
calibrated[target], target, spread
)
calibrated[target] = adjusted
# Store date-str tagged predictions
if day_idx <= 2: # Keep near-term predictions
for target, prob in calibrated.items():
predictions[f"{target}_{date_str}"] = prob
# Also store as raw target key (overwrites with latest)
if day_idx == 1: # Tomorrow
for target, prob in calibrated.items():
predictions[target] = prob
self._last_predictions = predictions
# Add typhoon predictions (not from LightGBM — separate model)
self._last_typhoon = self._predict_typhoon()
predictions.update(self._last_typhoon)
# Add spatial features
self._last_spatial = self._compute_spatial_features()
return predictions
# Fallback: use heuristic predictions
return self._fallback_predictions()
def _predict_typhoon(self) -> Dict[str, float]:
"""Generate typhoon signal-level probabilities."""
hko = self.hko
current_signal = hko.get_current_signal_level()
typhoon_info = hko.get_typhoon_info()
month = datetime.now().month
probs = {}
for signal_level in ["T1", "T3", "T8"]:
for lead_hours in [24, 48, 72, 120]:
target = f"typhoon_{signal_level}_{lead_hours}h"
prob = self.typhoon.signal_probability(
signal_level=signal_level,
lead_hours=lead_hours,
current_signal=current_signal,
current_conditions=typhoon_info,
month=month,
)
if lead_hours == 24: # Store short key too
probs[f"typhoon_{signal_level}"] = prob
probs[target] = prob
return probs
def _compute_spatial_features(self) -> Dict[str, float]:
"""Extract spatial features from multi-station forecasts."""
station_data = self.spatial.fetch_all_stations(lead_days=5)
if not station_data:
return {}
return self.spatial.extract_spatial_features(station_data, day_index=1)
def _fallback_predictions(self) -> Dict[str, float]:
"""Fallback heuristic predictions when no ML models loaded."""
if self._last_forecast is None or len(self._last_forecast) == 0:
return {}
d1 = self._last_forecast.iloc[min(1, len(self._last_forecast) - 1)]
predictions = {}
for target, tdef in TARGET_DEFINITIONS.items():
var = tdef["variable"]
threshold = tdef["threshold"]
if var in self._last_forecast.columns:
val = float(d1.get(var, 0))
prob = self._heuristic_prob(val, threshold, target)
predictions[target] = prob
return predictions
def _heuristic_prob(self, value: float, threshold: float, target: str) -> float:
"""Fallback heuristic: sigmoid-based probability."""
if "temp" in target:
# Temperature: wider sigmoid, calibrated to HK summer
excess = value - threshold
return float(np.clip(50 + excess * 15, 3, 97))
elif "rain" in target:
# Rain probabilities from Open-Meteo directly
if threshold == 0:
return float(np.clip(value, 0.5, 99.5))
else:
return float(np.clip(value * 0.8 if threshold < 10 else value * 0.5, 1, 95))
elif "wind" in target:
excess = value - threshold
return float(np.clip(50 + excess * 5, 3, 97))
return 50.0
def _compute_multimodel_spread(
self, daily: pd.DataFrame, hourly: pd.DataFrame, day_idx: int
) -> Dict[str, float]:
"""Compute ensemble disagreement metrics across model outputs.
When multiple model outputs are available (GFS, ECMWF, WeatherNext),
disagreement signifies uncertainty that the market may misprice.
"""
spread = {}
# 1. Inter-day variability (persistence disagreement)
if day_idx > 0 and len(daily) > day_idx:
d0 = daily.iloc[day_idx - 1]
d1 = daily.iloc[day_idx]
spread["t2m_max_day_change"] = abs(
float(d1.get("temperature_2m_max", 0)) -
float(d0.get("temperature_2m_max", 0))
)
spread["precip_prob_day_change"] = abs(
float(d1.get("precipitation_probability_max", 0)) -
float(d0.get("precipitation_probability_max", 0))
)
spread["pressure_day_change"] = abs(
float(hourly["surface_pressure"].mean() if "surface_pressure" in hourly else 1013) -
float(hourly["surface_pressure"].iloc[max(0, day_idx * 24 - 24)] if "surface_pressure" in hourly else 1013)
) if hourly is not None and len(hourly) > 0 else 0.0
# 2. Wind direction variability (storm potential indicator)
if hourly is not None and len(hourly) > 0 and "wind_direction_10m" in hourly:
day_hourly = hourly.iloc[day_idx * 24:(day_idx + 1) * 24] if len(hourly) > (day_idx + 1) * 24 else hourly
if len(day_hourly) > 0:
wd = day_hourly["wind_direction_10m"].values
spread["wind_dir_variance"] = float(np.var(wd)) if len(wd) > 1 else 0.0
# 3. Cloud structure complexity (convection proxy)
if hourly is not None and len(hourly) > 0:
for level in ["cloud_cover_low", "cloud_cover_mid", "cloud_cover_high"]:
if level in hourly.columns:
day_hourly = hourly.iloc[day_idx * 24:(day_idx + 1) * 24] if len(hourly) > (day_idx + 1) * 24 else hourly
if len(day_hourly) > 0:
spread[f"{level}_std"] = float(day_hourly[level].std())
# 4. Compute composite spread score (0-1)
indicators = []
for k, v in spread.items():
if "t2m" in k:
indicators.append(np.clip(v / 5.0, 0, 1)) # 5°C change = full signal
elif "precip" in k:
indicators.append(np.clip(v / 50.0, 0, 1)) # 50% change = full signal
elif "pressure" in k:
indicators.append(np.clip(v / 10.0, 0, 1)) # 10 hPa = full signal
elif "variance" in k:
indicators.append(np.clip(v / 5000.0, 0, 1))
elif "_std" in k:
indicators.append(np.clip(v / 30.0, 0, 1))
spread["composite_spread"] = float(np.mean(indicators)) if indicators else 0.0
return spread
def _adjust_with_spread(
self, probability: float, target: str, spread: Dict[str, float]
) -> float:
"""
Adjust probability based on ensemble disagreement.
When models disagree → higher uncertainty → wider confidence interval.
In prediction markets, this often means the market price is LESS accurate
(traders anchor on the wrong model or over-weight consensus).
We amplify our edge when spread is high: push our probability
further from 50% to reflect our confidence in the direction.
"""
composite = spread.get("composite_spread", 0.0)
if composite < 0.1:
return probability
# Direction: is our prediction above or below 50%?
direction = 1 if probability > 50 else -1
# Amplification: move probability up to spread * 20 bps further from 50
# High spread = more uncertainty = wider market spread = more edge
amplification = min(composite * 20, 20) # Cap at 20 percentage points
adjusted = probability + direction * amplification
return float(np.clip(adjusted, 0.5, 99.5))
def generate_signal(
self,
target: str,
market_probability: float,
outcome: str = "YES",
) -> Dict:
"""
Generate a trading signal for a specific market.
Parameters
----------
target : str
Target name (e.g., 'temp_gt_30c_24h')
market_probability : float
Market-implied probability of the outcome (0-100)
outcome : str
Which outcome to bet on ('YES' or 'NO')
Returns
-------
Dict with model_prob, market_prob, edge_bps, kelly_size, side
"""
if not self._last_predictions:
self.fetch_and_predict()
model_prob = (self._last_predictions or {}).get(target, 50.0)
cal_prob = self.calibrator.calibrate(target, model_prob)
edge_bps = (cal_prob - market_probability)
if abs(edge_bps) < self.min_edge_bps:
return {
"signal": "pass",
"model_prob": cal_prob,
"market_prob": market_probability,
"edge_bps": edge_bps,
"size_usdc": 0.0,
}
side = "buy_yes" if edge_bps > 0 else "buy_no"
kelly_result = self.kelly.size_bet(
our_probability=cal_prob,
market_probability=market_probability,
side=side,
)
return {
"signal": side,
"model_prob": cal_prob,
"market_prob": market_probability,
"edge_bps": edge_bps,
"size_usdc": kelly_result.size_usdc if kelly_result.kelly_active else 0.0,
"kelly_fraction": kelly_result.fractional_kelly,
"ensemble_spread": (self._ensemble_spread or {}).get("composite_spread", 0.0),
}
def record_outcome(self, target: str, predicted_prob: float, actual: bool):
"""Record resolved market outcome for calibration and correlation."""
self.calibrator.record_outcome(
date=datetime.now().strftime("%Y-%m-%d"),
variable=target,
predicted_probability=predicted_prob,
actual_outcome=actual,
)
self.portfolio_kelly.record_outcomes({target: actual})
def get_top_signals(
self, markets: List[Dict], default_market_prob: float = 50.0
) -> List[Dict]:
"""Scan a list of market definitions and generate ranked signals."""
predictions = self._last_predictions or self.fetch_and_predict()
signals = []
for market in markets:
target = market.get("target", "")
if target not in predictions:
continue
market_prob = market.get("market_probability", default_market_prob)
signal = self.generate_signal(target, market_prob)
if signal["signal"] != "pass":
signals.append({
**signal,
"target": target,
"description": TARGET_DEFINITIONS.get(target, {}).get("description", ""),
"question": market.get("question", ""),
"condition_id": market.get("condition_id", ""),
})
signals.sort(key=lambda s: abs(s["edge_bps"]), reverse=True)
return signals
def summary(self) -> str:
"""Human-readable summary of current predictions."""
predictions = self._last_predictions or {}
lines = []
lines.append(f"\n=== ML Weather Predictions ({datetime.now():%Y-%m-%d %H:%M}) ===")
lines.append(f" Models loaded: {len(self.ensemble.models)}")
lines.append(f" Calibration records: {self.calibrator.get_calibration_stats(list(predictions.keys())[0] if predictions else 'temp_gt_30c_24h').get('n_observations', 0)}")
if not predictions:
lines.append(" No predictions available.")
return "\n".join(lines)
lines.append(f"\n Tomorrow's targets:")
for target in TARGET_DEFINITIONS:
if target in predictions:
tdef = TARGET_DEFINITIONS[target]
prob = predictions[target]
lines.append(f" {tdef['description']}: {prob:.1f}%")
if self._last_typhoon:
lines.append(f"\n Typhoon probabilities:")
for level in ["T1", "T3", "T8"]:
key = f"typhoon_{level}"
if key in self._last_typhoon:
lines.append(f" {level}: {self._last_typhoon[key]:.1f}%")
for h in [48, 72, 120]:
for level in ["T1", "T8"]:
key = f"typhoon_{level}_{h}h"
if key in self._last_typhoon:
lines.append(f" {level} in {h}h: {self._last_typhoon[key]:.1f}%")
if self._last_spatial:
uhi = self._last_spatial.get("uhi_tmax_delta", 0)
instability = self._last_spatial.get("spatial_instability", 0)
if abs(uhi) > 0.5 or instability > 0.2:
lines.append(f"\n Spatial features:")
if abs(uhi) > 0.5:
lines.append(f" UHI delta: {uhi:+.1f}°C")
if instability > 0.2:
lines.append(f" Instability: {instability:.2f}")
spread = self._ensemble_spread or {}
if spread.get("composite_spread", 0) > 0.1:
lines.append(f"\n Ensemble disagreement: {spread['composite_spread']:.2f} (amplified edge)")
if spread.get("t2m_max_day_change", 0) > 0:
lines.append(f" ΔTmax: {spread.get('t2m_max_day_change', 0):.1f}°C")
return "\n".join(lines)