Add web dev dashboard with real-time HK weather + model comparison
- Flask web server with interactive charts (Chart.js) - Temperature, rain probability, wind speed charts with HKO vs Open-Meteo comparison - Current conditions card with typhoon signal indicator - Kelly criterion sizing simulator panel - Trading signals panel with model-implied probabilities - Raw forecast data JSON viewer - Github-dark theme with responsive card layout - Fixed Open-Meteo SDK compatibility and numpy bool serialization Run: python web_dashboard.py # http://localhost:5000
This commit is contained in:
@@ -149,9 +149,11 @@ class OpenMeteoClient:
|
|||||||
daily_df.index = daily_df.index.tz_convert("Asia/Hong_Kong")
|
daily_df.index = daily_df.index.tz_convert("Asia/Hong_Kong")
|
||||||
|
|
||||||
daily_df.attrs["model"] = self.model
|
daily_df.attrs["model"] = self.model
|
||||||
daily_df.attrs["hourly"] = hourly_df
|
|
||||||
daily_df.attrs["fetch_time"] = datetime.now()
|
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
|
return daily_df
|
||||||
|
|
||||||
def get_current_conditions(self) -> dict:
|
def get_current_conditions(self) -> dict:
|
||||||
@@ -196,12 +198,13 @@ class OpenMeteoClient:
|
|||||||
def get_precipitation_probability(self, hours_ahead: int = 24) -> float:
|
def get_precipitation_probability(self, hours_ahead: int = 24) -> float:
|
||||||
"""Get precipitation probability for next N hours."""
|
"""Get precipitation probability for next N hours."""
|
||||||
df = self.get_forecast(lead_days=2)
|
df = self.get_forecast(lead_days=2)
|
||||||
if df is not None and hasattr(df, 'attrs') and 'hourly' in df.attrs:
|
if df is not None:
|
||||||
hourly = df.attrs['hourly']
|
hourly = getattr(self, '_last_hourly', pd.DataFrame())
|
||||||
now = pd.Timestamp.now(tz="Asia/Hong_Kong")
|
if not hourly.empty:
|
||||||
future = hourly[hourly.index <= now + pd.Timedelta(hours=hours_ahead)]
|
now = pd.Timestamp.now(tz="Asia/Hong_Kong")
|
||||||
if 'precipitation_probability' in future.columns:
|
future = hourly[hourly.index <= now + pd.Timedelta(hours=hours_ahead)]
|
||||||
return float(future['precipitation_probability'].max())
|
if 'precipitation_probability' in future.columns:
|
||||||
|
return float(future['precipitation_probability'].max())
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def get_scoring_window_summary(self, target_date: str) -> dict:
|
def get_scoring_window_summary(self, target_date: str) -> dict:
|
||||||
@@ -220,7 +223,7 @@ class OpenMeteoClient:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
row = df.loc[target]
|
row = df.loc[target]
|
||||||
hourly = df.attrs.get("hourly", pd.DataFrame())
|
hourly = getattr(self, '_last_hourly', pd.DataFrame())
|
||||||
|
|
||||||
if not hourly.empty:
|
if not hourly.empty:
|
||||||
day_hourly = hourly[
|
day_hourly = hourly[
|
||||||
|
|||||||
@@ -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'''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HK Weather Prediction Market — Dev Dashboard</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace;
|
||||||
|
background: #0d1117; color: #c9d1d9; min-height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 18px; margin-bottom: 4px; color: #58a6ff; }
|
||||||
|
h2 { font-size: 14px; margin-bottom: 12px; color: #8b949e; font-weight: 400; }
|
||||||
|
h3 { font-size: 13px; margin-bottom: 8px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
|
||||||
|
.card {
|
||||||
|
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||||
|
padding: 16px; position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card.full { grid-column: 1 / -1; }
|
||||||
|
.card.wide { grid-column: span 2; }
|
||||||
|
.stat-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||||
|
.stat-label { color: #8b949e; font-size: 12px; }
|
||||||
|
.stat-value { font-size: 24px; font-weight: 700; color: #f0f6fc; }
|
||||||
|
.stat-unit { font-size: 12px; color: #8b949e; }
|
||||||
|
.stat-sub { font-size: 11px; color: #8b949e; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 12px;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-warn { background: #d299221a; color: #d29922; border: 1px solid #d2992240; }
|
||||||
|
.badge-danger { background: #f851491a; color: #f85149; border: 1px solid #f8514940; }
|
||||||
|
.badge-info { background: #58a6ff1a; color: #58a6ff; border: 1px solid #58a6ff40; }
|
||||||
|
.badge-green { background: #3fb9501a; color: #3fb950; border: 1px solid #3fb95040; }
|
||||||
|
canvas { width: 100% !important; max-height: 220px; }
|
||||||
|
.chart-wrap { position: relative; margin-top: 8px; }
|
||||||
|
.loading { text-align: center; padding: 20px; color: #8b949e; font-size: 13px; }
|
||||||
|
.error { color: #f85149; font-size: 12px; }
|
||||||
|
.timestamp { font-size: 11px; color: #484f58; text-align: right; margin-top: 8px; }
|
||||||
|
.signal-row { padding: 6px 0; border-bottom: 1px solid #21262d; font-size: 12px; }
|
||||||
|
.signal-row:last-child { border-bottom: none; }
|
||||||
|
.edge-pos { color: #3fb950; }
|
||||||
|
.edge-neg { color: #f85149; }
|
||||||
|
.signal-side { font-weight: 600; }
|
||||||
|
.btn {
|
||||||
|
background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #30363d; }
|
||||||
|
.btn:active { background: #161b22; }
|
||||||
|
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.model-tag { font-size: 10px; color: #8b949e; margin-left: 6px; background: #21262d; padding: 1px 6px; border-radius: 8px; }
|
||||||
|
.refresh-indicator { font-size: 10px; color: #3fb950; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { text-align: left; color: #8b949e; padding: 4px 8px; border-bottom: 1px solid #30363d; font-weight: 400; }
|
||||||
|
td { padding: 4px 8px; border-bottom: 1px solid #21262d; }
|
||||||
|
tr:hover td { background: #1c2128; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header-row">
|
||||||
|
<div>
|
||||||
|
<h1>HK Weather Prediction Market</h1>
|
||||||
|
<h2>WeatherNext + HKO + Polymarket Strategy</h2>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="refresh-indicator" id="status">● Live</span>
|
||||||
|
<span class="model-tag">HKO + Open-Meteo</span>
|
||||||
|
<button class="btn" onclick="refresh()" style="margin-left: 8px;">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<!-- CURRENT CONDITIONS -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Current Conditions <span class="model-tag">HKO</span></h3>
|
||||||
|
<div id="current-loading" class="loading">Loading...</div>
|
||||||
|
<div id="current-content" style="display:none">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Temperature</span>
|
||||||
|
<span class="stat-value" id="cur-temp">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Feels Like</span>
|
||||||
|
<span class="stat-value" id="cur-feels">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Humidity</span>
|
||||||
|
<span class="stat-value" id="cur-hum">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Wind</span>
|
||||||
|
<span class="stat-value" id="cur-wind">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Pressure</span>
|
||||||
|
<span class="stat-value" id="cur-pres">--</span>
|
||||||
|
</div>
|
||||||
|
<div id="warnings-container" style="margin-top:8px"></div>
|
||||||
|
<div id="typhoon-container" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOMORROW SUMMARY -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Tomorrow <span id="tomorrow-date" class="model-tag"></span></h3>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Temp Max</span>
|
||||||
|
<span class="stat-value" id="tom-tmax">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Temp Min</span>
|
||||||
|
<span class="stat-value" id="tom-tmin">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Rain Probability</span>
|
||||||
|
<span class="stat-value" id="tom-rain">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Rain Total</span>
|
||||||
|
<span class="stat-value" id="tom-rain-mm">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Wind Max</span>
|
||||||
|
<span class="stat-value" id="tom-wind">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Gusts Max</span>
|
||||||
|
<span class="stat-value" id="tom-gust">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="timestamp" id="model-source"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TEMPERATURE CHART -->
|
||||||
|
<div class="card wide">
|
||||||
|
<h3>5-Day Temperature Forecast</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="tempChart"></canvas></div>
|
||||||
|
<div class="timestamp">Open-Meteo + HKO comparison</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RAIN PROBABILITY CHART -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Rain Probability</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="rainChart"></canvas></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WIND SPEED CHART -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Wind Speed Forecast</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="windChart"></canvas></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HKO vs WEATHERNEXT COMPARISON -->
|
||||||
|
<div class="card wide">
|
||||||
|
<h3>HKO vs WeatherNext Model Comparison</h3>
|
||||||
|
<div id="comparison-table"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TRADING SIGNALS -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Trading Signals</h3>
|
||||||
|
<div id="signals-loading" class="loading">Computing...</div>
|
||||||
|
<div id="signals-content" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KELLY SIZING -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Kelly Sizing Simulator <span class="model-tag">fraction=0.25</span></h3>
|
||||||
|
<div id="kelly-loading" class="loading">Computing...</div>
|
||||||
|
<div id="kelly-content" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RAW DATA -->
|
||||||
|
<div class="card full">
|
||||||
|
<h3>WeatherNext Raw Output (Open-Meteo)</h3>
|
||||||
|
<div id="raw-data" class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let charts = {};
|
||||||
|
|
||||||
|
function destroyChart(id) {
|
||||||
|
if (charts[id]) { charts[id].destroy(); charts[id] = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
document.getElementById('status').textContent = '⟳ Loading...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard');
|
||||||
|
const data = await resp.json();
|
||||||
|
renderAll(data);
|
||||||
|
document.getElementById('status').textContent = `● Live · ${data.fetch_time}`;
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('status').textContent = '● Error';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll(data) {
|
||||||
|
renderCurrent(data.current, data.typhoon);
|
||||||
|
renderTomorrow(data.tomorrow);
|
||||||
|
renderCharts(data.forecast);
|
||||||
|
renderComparison(data.forecast);
|
||||||
|
renderSignals(data.signals);
|
||||||
|
renderKelly(data.kelly);
|
||||||
|
renderRaw(data.forecast);
|
||||||
|
renderTyphoon(data.typhoon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrent(current, typhoon) {
|
||||||
|
document.getElementById('current-loading').style.display = 'none';
|
||||||
|
document.getElementById('current-content').style.display = '';
|
||||||
|
|
||||||
|
if (!current) return;
|
||||||
|
document.getElementById('cur-temp').innerHTML = current.temperature + '°C';
|
||||||
|
document.getElementById('cur-feels').innerHTML = current.feels_like + '°C';
|
||||||
|
document.getElementById('cur-hum').innerHTML = current.humidity + '%';
|
||||||
|
document.getElementById('cur-wind').innerHTML = current.wind_speed + ' km/h';
|
||||||
|
document.getElementById('cur-pres').innerHTML = current.pressure + ' hPa';
|
||||||
|
|
||||||
|
const wc = document.getElementById('warnings-container');
|
||||||
|
wc.innerHTML = '';
|
||||||
|
if (current.warning) {
|
||||||
|
wc.innerHTML = '<span class="badge badge-warn">' + current.warning + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTyphoon(typhoon) {
|
||||||
|
const tc = document.getElementById('typhoon-container');
|
||||||
|
if (!typhoon || typhoon.signal === 0) {
|
||||||
|
tc.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cls = typhoon.signal >= 8 ? 'badge badge-danger' : 'badge badge-warn';
|
||||||
|
tc.innerHTML = '<span class="' + cls + '">T' + typhoon.signal + ' Signal Active</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTomorrow(tom) {
|
||||||
|
if (!tom) return;
|
||||||
|
document.getElementById('tomorrow-date').textContent = tom.date;
|
||||||
|
document.getElementById('tom-tmax').innerHTML = tom.temp_max + '<span class="stat-unit">°C</span>';
|
||||||
|
document.getElementById('tom-tmin').innerHTML = tom.temp_min + '<span class="stat-unit">°C</span>';
|
||||||
|
document.getElementById('tom-rain').innerHTML = tom.rain_prob + '<span class="stat-unit">%</span>';
|
||||||
|
document.getElementById('tom-rain-mm').innerHTML = tom.rain_sum + '<span class="stat-unit">mm</span>';
|
||||||
|
document.getElementById('tom-wind').innerHTML = tom.wind_max + '<span class="stat-unit">km/h</span>';
|
||||||
|
document.getElementById('tom-gust').innerHTML = tom.wind_gust + '<span class="stat-unit">km/h</span>';
|
||||||
|
document.getElementById('model-source').textContent = 'Source: Open-Meteo + HKO';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts(fc) {
|
||||||
|
if (!fc || !fc.dates) return;
|
||||||
|
|
||||||
|
const dates = fc.dates;
|
||||||
|
const ctx = document.getElementById.bind(document);
|
||||||
|
|
||||||
|
// Temperature chart
|
||||||
|
destroyChart('temp');
|
||||||
|
charts.temp = new Chart(ctx('tempChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: dates,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Tmax (Open-Meteo)',
|
||||||
|
data: fc.tmax, borderColor: '#f85149', backgroundColor: '#f8514910',
|
||||||
|
fill: false, tension: 0.3, pointRadius: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tmin (Open-Meteo)',
|
||||||
|
data: fc.tmin, borderColor: '#58a6ff', backgroundColor: '#58a6ff10',
|
||||||
|
fill: false, tension: 0.3, pointRadius: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tmax (HKO)',
|
||||||
|
data: fc.hko_tmax, borderColor: '#f85149', backgroundColor: 'transparent',
|
||||||
|
fill: false, tension: 0.3, pointRadius: 4, pointStyle: 'rect',
|
||||||
|
borderDash: [5,5], borderWidth: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tmin (HKO)',
|
||||||
|
data: fc.hko_tmin, borderColor: '#58a6ff', backgroundColor: 'transparent',
|
||||||
|
fill: false, tension: 0.3, pointRadius: 4, pointStyle: 'rect',
|
||||||
|
borderDash: [5,5], borderWidth: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '35°C Threshold',
|
||||||
|
data: dates.map(() => 35), borderColor: '#d29922',
|
||||||
|
fill: false, pointRadius: 0, borderDash: [2,2], borderWidth: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { labels: { color: '#8b949e', font: { size: 10 }, boxWidth: 12, padding: 8 } } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#484f58', font: { size: 10 } }, grid: { color: '#21262d' } },
|
||||||
|
y: { ticks: { color: '#484f58', callback: v => v + '°C', font: { size: 10 } }, grid: { color: '#21262d' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rain probability chart
|
||||||
|
destroyChart('rain');
|
||||||
|
charts.rain = new Chart(ctx('rainChart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: dates,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Rain Prob (%)',
|
||||||
|
data: fc.rain_prob, backgroundColor: fc.rain_prob.map(p => {
|
||||||
|
if (p > 75) return '#f8514960';
|
||||||
|
if (p > 50) return '#d2992260';
|
||||||
|
return '#3fb95060';
|
||||||
|
}),
|
||||||
|
borderColor: fc.rain_prob.map(p => {
|
||||||
|
if (p > 75) return '#f85149';
|
||||||
|
if (p > 50) return '#d29922';
|
||||||
|
return '#3fb950';
|
||||||
|
}),
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#484f58', font: { size: 10 } }, grid: { color: '#21262d' } },
|
||||||
|
y: { ticks: { color: '#484f58', callback: v => v + '%', max: 100, font: { size: 10 } }, grid: { color: '#21262d' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wind chart
|
||||||
|
destroyChart('wind');
|
||||||
|
charts.wind = new Chart(ctx('windChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: dates,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Wind Max', data: fc.wind_max, borderColor: '#7ee787', fill: false, tension: 0.3, pointRadius: 3 },
|
||||||
|
{ label: 'Gusts Max', data: fc.wind_gust, borderColor: '#d29922', fill: false, tension: 0.3, pointRadius: 3, borderDash: [3,3] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { labels: { color: '#8b949e', font: { size: 10 }, boxWidth: 12, padding: 8 } } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#484f58', font: { size: 10 } }, grid: { color: '#21262d' } },
|
||||||
|
y: { ticks: { color: '#484f58', callback: v => v + ' km/h', font: { size: 10 } }, grid: { color: '#21262d' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComparison(fc) {
|
||||||
|
if (!fc || !fc.dates) return;
|
||||||
|
const tb = document.getElementById('comparison-table');
|
||||||
|
let html = '<table><tr><th>Date</th><th>Tmax (Open-Meteo)</th><th>Tmax (HKO)</th><th>Δ</th><th>Rain Prob</th><th>Rain (mm)</th></tr>';
|
||||||
|
for (let i = 0; i < fc.dates.length; i++) {
|
||||||
|
const om = fc.tmax[i] || '-';
|
||||||
|
const hk = fc.hko_tmax[i] || '-';
|
||||||
|
let delta = (om !== '-' && hk !== '-') ? (om - hk).toFixed(1) : '-';
|
||||||
|
let dClass = '';
|
||||||
|
if (delta !== '-') {
|
||||||
|
dClass = Math.abs(parseFloat(delta)) > 2 ? (parseFloat(delta) > 0 ? 'error' : '') : '';
|
||||||
|
}
|
||||||
|
html += `<tr>
|
||||||
|
<td>${fc.dates[i]}</td>
|
||||||
|
<td>${om}°C</td>
|
||||||
|
<td>${hk}°C</td>
|
||||||
|
<td class="${dClass}">${delta !== '-' ? (parseFloat(delta) > 0 ? '+' : '') + delta + '°C' : '-'}</td>
|
||||||
|
<td>${fc.rain_prob[i] || '-'}%</td>
|
||||||
|
<td>${fc.rain_sum[i] || '-'}mm</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</table>';
|
||||||
|
tb.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSignals(signals) {
|
||||||
|
document.getElementById('signals-loading').style.display = 'none';
|
||||||
|
const div = document.getElementById('signals-content');
|
||||||
|
div.style.display = '';
|
||||||
|
|
||||||
|
if (!signals || signals.length === 0) {
|
||||||
|
div.innerHTML = '<div class="stat-sub">No active markets on Polymarket. Showing model-implied probabilities:</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const s of signals) {
|
||||||
|
const edgeCls = s.edge_bps > 0 ? 'edge-pos' : 'edge-neg';
|
||||||
|
const side = s.signal || 'pass';
|
||||||
|
html += `<div class="signal-row">
|
||||||
|
<div style="font-weight:600">${s.question}</div>
|
||||||
|
<div>Model: <b>${s.model_prob}%</b> Market: <b>${s.market_prob}%</b> Edge: <span class="${edgeCls}">${s.edge_bps < 0 ? '' : '+'}${s.edge_bps} bps</span></div>
|
||||||
|
<div>Side: <span class="signal-side">${side}</span> Size: $${s.size}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
div.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKelly(kelly) {
|
||||||
|
document.getElementById('kelly-loading').style.display = 'none';
|
||||||
|
const div = document.getElementById('kelly-content');
|
||||||
|
div.style.display = '';
|
||||||
|
|
||||||
|
if (!kelly || kelly.length === 0) {
|
||||||
|
div.innerHTML = '<div class="stat-sub">No trades recommended</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const k of kelly) {
|
||||||
|
const sideCls = k.side === 'buy_yes' ? 'badge-green' : 'badge-danger';
|
||||||
|
html += `<div class="signal-row">
|
||||||
|
<div style="font-weight:600">${k.name}</div>
|
||||||
|
<div>Our: <b>${k.our_prob}%</b> vs Mkt: <b>${k.mkt_prob}%</b></div>
|
||||||
|
<div><span class="badge ${sideCls}">${k.side}</span> ${k.active ? '$' + k.size.toFixed(2) : 'Pass'} ${k.active ? '<span class="stat-sub" style="margin-left:4px">edge: ' + (k.edge * 100).toFixed(0) + ' bps</span>' : ''}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
div.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRaw(fc) {
|
||||||
|
if (!fc) return;
|
||||||
|
const div = document.getElementById('raw-data');
|
||||||
|
div.innerHTML = '<pre style="font-size:11px;color:#8b949e;overflow-x:auto">' + JSON.stringify(fc, null, 2) + '</pre>';
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 300000); // Every 5 min
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
Reference in New Issue
Block a user