diff --git a/weather/openmeteo_client.py b/weather/openmeteo_client.py
index 219e6ff..9e3b3c4 100644
--- a/weather/openmeteo_client.py
+++ b/weather/openmeteo_client.py
@@ -149,9 +149,11 @@ class OpenMeteoClient:
daily_df.index = daily_df.index.tz_convert("Asia/Hong_Kong")
daily_df.attrs["model"] = self.model
- daily_df.attrs["hourly"] = hourly_df
daily_df.attrs["fetch_time"] = datetime.now()
+ # Store hourly separately to avoid pandas attrs bug with DataFrames
+ self._last_hourly = hourly_df
+
return daily_df
def get_current_conditions(self) -> dict:
@@ -196,12 +198,13 @@ class OpenMeteoClient:
def get_precipitation_probability(self, hours_ahead: int = 24) -> float:
"""Get precipitation probability for next N hours."""
df = self.get_forecast(lead_days=2)
- if df is not None and hasattr(df, 'attrs') and 'hourly' in df.attrs:
- hourly = df.attrs['hourly']
- now = pd.Timestamp.now(tz="Asia/Hong_Kong")
- future = hourly[hourly.index <= now + pd.Timedelta(hours=hours_ahead)]
- if 'precipitation_probability' in future.columns:
- return float(future['precipitation_probability'].max())
+ if df is not None:
+ hourly = getattr(self, '_last_hourly', pd.DataFrame())
+ if not hourly.empty:
+ now = pd.Timestamp.now(tz="Asia/Hong_Kong")
+ future = hourly[hourly.index <= now + pd.Timedelta(hours=hours_ahead)]
+ if 'precipitation_probability' in future.columns:
+ return float(future['precipitation_probability'].max())
return 0.0
def get_scoring_window_summary(self, target_date: str) -> dict:
@@ -220,7 +223,7 @@ class OpenMeteoClient:
return {}
row = df.loc[target]
- hourly = df.attrs.get("hourly", pd.DataFrame())
+ hourly = getattr(self, '_last_hourly', pd.DataFrame())
if not hourly.empty:
day_hourly = hourly[
diff --git a/web_dashboard.py b/web_dashboard.py
new file mode 100644
index 0000000..3c4f901
--- /dev/null
+++ b/web_dashboard.py
@@ -0,0 +1,644 @@
+#!/usr/bin/env python3
+"""
+HK Weather Prediction Market — Dev Dashboard
+
+Flask web server with interactive charts:
+ - Real-time HKO observations + WeatherNext forecasts
+ - Temperature, rain probability, wind speed charts
+ - HKO vs WeatherNext model comparison
+ - Trading signals + Kelly sizing panel
+
+Usage:
+ python web_dashboard.py
+ # Open http://localhost:5000
+"""
+
+import json
+import math
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from flask import Flask, jsonify, render_template_string
+
+from weather.hk_extractor import HKExtractor
+from weather.hko_client import HKOClient
+from weather.openmeteo_client import OpenMeteoClient
+from strategy.kelly import KellyCriterion
+from config import HK_COORDS
+
+app = Flask(__name__)
+
+HTML_TEMPLATE = r'''
+
+
+
+
+
+HK Weather Prediction Market — Dev Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
Current Conditions HKO
+
Loading...
+
+
+ Temperature
+ --
+
+
+ Feels Like
+ --
+
+
+ Humidity
+ --
+
+
+ Wind
+ --
+
+
+ Pressure
+ --
+
+
+
+
+
+
+
+
+
Tomorrow
+
+ Temp Max
+ --
+
+
+ Temp Min
+ --
+
+
+ Rain Probability
+ --
+
+
+ Rain Total
+ --
+
+
+ Wind Max
+ --
+
+
+ Gusts Max
+ --
+
+
+
+
+
+
+
5-Day Temperature Forecast
+
+
Open-Meteo + HKO comparison
+
+
+
+
+
+
+
+
Wind Speed Forecast
+
+
+
+
+
+
HKO vs WeatherNext Model Comparison
+
+
+
+
+
+
Trading Signals
+
Computing...
+
+
+
+
+
+
Kelly Sizing Simulator fraction=0.25
+
Computing...
+
+
+
+
+
+
WeatherNext Raw Output (Open-Meteo)
+
Loading...
+
+
+
+
+
+
+
+'''
+
+
+@app.route("/api/dashboard")
+def api_dashboard():
+ """Return all dashboard data as JSON."""
+ try:
+ hko = HKOClient()
+ om = OpenMeteoClient()
+
+ forecast = om.get_forecast(lead_days=5)
+
+ current_data = {}
+ try:
+ hko_cur = hko.get_current_weather()
+ om_cur = om.get_current_conditions()
+ if hko_cur and hko_cur.get("temperature"):
+ current_data["temperature"] = hko_cur["temperature"][0]["value"]
+ else:
+ current_data["temperature"] = round(float(om_cur.get("temperature", 0)), 1)
+ if hko_cur and hko_cur.get("humidity"):
+ current_data["humidity"] = hko_cur["humidity"][0]["value"]
+ else:
+ current_data["humidity"] = round(float(om_cur.get("humidity", 0)), 1)
+ current_data["feels_like"] = round(float(om_cur.get("apparent_temp", 0)), 1)
+ current_data["wind_speed"] = round(float(om_cur.get("wind_speed", 0)), 1)
+ current_data["wind_direction"] = round(float(om_cur.get("wind_direction", 0)), 0)
+ current_data["pressure"] = round(float(om_cur.get("surface_pressure", 0)), 1)
+ w = hko_cur.get("warning_message", "") if hko_cur else ""
+ current_data["warning"] = w[0] if isinstance(w, list) and w else (w if isinstance(w, str) else "")
+ except Exception:
+ current_data = {"temperature": "--", "humidity": "--"}
+
+ typhoon_data = {"signal": hko.get_current_signal_level()}
+
+ tomorrow_data = {}
+ if forecast is not None and len(forecast) > 0:
+ d0 = forecast.iloc[0]
+ d1 = forecast.iloc[1] if len(forecast) > 1 else d0
+ tomorrow_data = {
+ "date": d1.name.strftime("%Y-%m-%d"),
+ "temp_max": round(float(d1.get("temperature_2m_max", 0)), 1),
+ "temp_min": round(float(d1.get("temperature_2m_min", 0)), 1),
+ "rain_prob": round(float(d1.get("precipitation_probability_max", 0)), 1),
+ "rain_sum": round(float(d1.get("precipitation_sum", 0)), 1),
+ "wind_max": round(float(d1.get("wind_speed_10m_max", 0)), 1),
+ "wind_gust": round(float(d1.get("wind_gusts_10m_max", 0)), 1),
+ }
+
+ fc_series = {"dates": [], "tmax": [], "tmin": [], "rain_prob": [],
+ "rain_sum": [], "wind_max": [], "wind_gust": [],
+ "hko_tmax": [], "hko_tmin": []}
+
+ if forecast is not None:
+ for idx, row in forecast.iterrows():
+ fc_series["dates"].append(idx.strftime("%m/%d"))
+ fc_series["tmax"].append(round(float(row.get("temperature_2m_max", 0)), 1))
+ fc_series["tmin"].append(round(float(row.get("temperature_2m_min", 0)), 1))
+ fc_series["rain_prob"].append(round(float(row.get("precipitation_probability_max", 0)), 1))
+ fc_series["rain_sum"].append(round(float(row.get("precipitation_sum", 0)), 1))
+ fc_series["wind_max"].append(round(float(row.get("wind_speed_10m_max", 0)), 1))
+ fc_series["wind_gust"].append(round(float(row.get("wind_gusts_10m_max", 0)), 1))
+
+ try:
+ hko_fc = hko.get_forecast()
+ if hko_fc:
+ for day in hko_fc[:5]:
+ fc_series["hko_tmax"].append(int(day.get("forecast_temp_max", 0)) if day.get("forecast_temp_max") else None)
+ fc_series["hko_tmin"].append(int(day.get("forecast_temp_min", 0)) if day.get("forecast_temp_min") else None)
+ while len(fc_series["hko_tmax"]) < len(fc_series["tmax"]):
+ fc_series["hko_tmax"].append(None)
+ fc_series["hko_tmin"].append(None)
+ except Exception:
+ pass
+
+ signals = []
+ if forecast is not None and len(forecast) > 1:
+ d1 = forecast.iloc[1]
+ tmrw = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")
+ rain_p = round(float(d1.get("precipitation_probability_max", 50)), 1)
+ temp_p = round(float(d1.get("temperature_2m_max", 30)), 1)
+ signals = [
+ {
+ "question": f"Rain in HK on {tmrw}?",
+ "model_prob": rain_p,
+ "market_prob": 50.0,
+ "edge_bps": round((rain_p - 50) * 100, 0),
+ "signal": "buy_no" if rain_p < 45 else ("buy_yes" if rain_p > 55 else "pass"),
+ "size": "—"
+ },
+ {
+ "question": f"Temp > 35°C in HK on {tmrw}?",
+ "model_prob": min(95, max(5, 50 + (temp_p - 35) * 20)),
+ "market_prob": 40.0,
+ "edge_bps": round((min(95, max(5, 50 + (temp_p - 35) * 20)) - 40) * 100, 0),
+ "signal": "buy_yes" if temp_p >= 35 else "pass",
+ "size": "—"
+ },
+ {
+ "question": f"Temp > 33°C in HK on {tmrw}?",
+ "model_prob": min(95, max(5, 50 + (temp_p - 33) * 20)),
+ "market_prob": 70.0,
+ "edge_bps": round((min(95, max(5, 50 + (temp_p - 33) * 20)) - 70) * 100, 0),
+ "signal": "buy_yes" if temp_p >= 33 else "pass",
+ "size": "—"
+ },
+ ]
+
+ kelly = KellyCriterion(bankroll_usdc=1000.0)
+ kelly_results = []
+ if forecast is not None and len(forecast) > 1:
+ d1 = forecast.iloc[1]
+ rain_p = round(float(d1.get("precipitation_probability_max", 50)), 1)
+ temp_p = round(float(d1.get("temperature_2m_max", 30)), 1)
+
+ scenarios = [
+ ("Rain tomorrow", rain_p, 50),
+ ("Temp > 35°C", min(95, max(5, 50 + (temp_p - 35) * 20)), 35),
+ ("Temp > 33°C", min(95, max(5, 50 + (temp_p - 33) * 20)), 75),
+ ]
+ for name, our_p, mkt_p in scenarios:
+ r_yes = kelly.size_bet(our_p, mkt_p, "buy_yes")
+ r_no = kelly.size_bet(our_p, mkt_p, "buy_no")
+ if r_yes.kelly_active and r_yes.size_usdc > r_no.size_usdc:
+ r = r_yes
+ side = "buy_yes"
+ elif r_no.kelly_active:
+ r = r_no
+ side = "buy_no"
+ else:
+ r = r_yes
+ side = "pass"
+
+ kelly_results.append({
+ "name": name,
+ "our_prob": round(our_p, 1),
+ "mkt_prob": round(mkt_p, 1),
+ "edge": round(r.edge, 3),
+ "side": side,
+ "active": bool(r.kelly_active),
+ "size": round(r.size_usdc, 2) if r.kelly_active else 0,
+ })
+
+ return jsonify({
+ "fetch_time": datetime.now().strftime("%H:%M:%S"),
+ "current": current_data,
+ "typhoon": typhoon_data,
+ "tomorrow": tomorrow_data,
+ "forecast": fc_series,
+ "signals": signals,
+ "kelly": kelly_results,
+ })
+
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route("/")
+def index():
+ return render_template_string(HTML_TEMPLATE)
+
+
+if __name__ == "__main__":
+ import os
+ debug = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes")
+ print("\n" + "=" * 56)
+ print(" HK Weather Prediction Market — Dev Dashboard")
+ print(f" Starting at: http://localhost:5000")
+ print("=" * 56 + "\n")
+ app.run(host="0.0.0.0", port=5000, debug=debug)