feat: introduce PolyWeather web map application with FastAPI backend and Leaflet frontend.
This commit is contained in:
+10
-37
@@ -26,11 +26,6 @@ from src.utils.config_loader import load_config
|
|||||||
from src.data_collection.weather_sources import WeatherDataCollector
|
from src.data_collection.weather_sources import WeatherDataCollector
|
||||||
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
|
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
|
||||||
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
|
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
|
||||||
from src.data_collection.polymarket_client import (
|
|
||||||
fetch_weather_markets,
|
|
||||||
get_city_markets,
|
|
||||||
compute_divergence,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────
|
||||||
# Setup
|
# Setup
|
||||||
@@ -76,6 +71,7 @@ ALIASES = {
|
|||||||
# ──────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────
|
||||||
_cache: Dict[str, Dict] = {}
|
_cache: Dict[str, Dict] = {}
|
||||||
CACHE_TTL = 300
|
CACHE_TTL = 300
|
||||||
|
CACHE_TTL_ANKARA = 60 # Ankara measurement updates frequent, narrower cache
|
||||||
|
|
||||||
|
|
||||||
def _sf(v) -> Optional[float]:
|
def _sf(v) -> Optional[float]:
|
||||||
@@ -91,12 +87,15 @@ def _sf(v) -> Optional[float]:
|
|||||||
# ──────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────
|
||||||
# Core Analysis (replicates bot_listener logic → JSON)
|
# Core Analysis (replicates bot_listener logic → JSON)
|
||||||
# ──────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────
|
||||||
def _analyze(city: str) -> Dict[str, Any]:
|
def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
"""Fetch, analyse, and return structured weather data for one city."""
|
"""Fetch, analyse, and return structured weather data for one city."""
|
||||||
# Check cache
|
# Check cache
|
||||||
cached = _cache.get(city)
|
ttl = CACHE_TTL_ANKARA if city.lower() == "ankara" else CACHE_TTL
|
||||||
if cached and _time.time() - cached["t"] < CACHE_TTL:
|
|
||||||
return cached["d"]
|
if not force_refresh:
|
||||||
|
cached = _cache.get(city)
|
||||||
|
if cached and _time.time() - cached["t"] < ttl:
|
||||||
|
return cached["d"]
|
||||||
|
|
||||||
info = CITIES[city]
|
info = CITIES[city]
|
||||||
lat, lon, is_f = info["lat"], info["lon"], info["f"]
|
lat, lon, is_f = info["lat"], info["lon"], info["f"]
|
||||||
@@ -495,32 +494,6 @@ def _analyze(city: str) -> Dict[str, Any]:
|
|||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 16. Polymarket odds (web-only, non-blocking) ──
|
|
||||||
try:
|
|
||||||
proxy = _config.get("proxy")
|
|
||||||
fetch_weather_markets(proxy=proxy, timeout=10)
|
|
||||||
# We don't filter by target_date here, we want ALL active contracts for the city
|
|
||||||
city_mkts = get_city_markets(city)
|
|
||||||
if city_mkts:
|
|
||||||
result["polymarket"] = {
|
|
||||||
"markets": [
|
|
||||||
{
|
|
||||||
"question": m["question"],
|
|
||||||
"yes_price": m["yes_price"],
|
|
||||||
"volume": m.get("volume"),
|
|
||||||
"threshold": m.get("threshold"),
|
|
||||||
"threshold_unit": m.get("threshold_unit"),
|
|
||||||
"url": m.get("url", ""),
|
|
||||||
}
|
|
||||||
for m in city_mkts[:5]
|
|
||||||
],
|
|
||||||
"divergence": compute_divergence(
|
|
||||||
city_mkts, probabilities, sym, use_fahrenheit=info.get("f", False)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Polymarket data skipped for {city}: {e}")
|
|
||||||
|
|
||||||
_cache[city] = {"t": _time.time(), "d": result}
|
_cache[city] = {"t": _time.time(), "d": result}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -556,13 +529,13 @@ async def list_cities():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/city/{name}")
|
@app.get("/api/city/{name}")
|
||||||
async def city_detail(name: str):
|
async def city_detail(name: str, force_refresh: bool = False):
|
||||||
"""Return full weather analysis for a single city."""
|
"""Return full weather analysis for a single city."""
|
||||||
name = name.lower().strip().replace("-", " ")
|
name = name.lower().strip().replace("-", " ")
|
||||||
name = ALIASES.get(name, name)
|
name = ALIASES.get(name, name)
|
||||||
if name not in CITIES:
|
if name not in CITIES:
|
||||||
raise HTTPException(404, detail=f"Unknown city: {name}")
|
raise HTTPException(404, detail=f"Unknown city: {name}")
|
||||||
return _analyze(name)
|
return _analyze(name, force_refresh=force_refresh)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/history/{name}")
|
@app.get("/api/history/{name}")
|
||||||
|
|||||||
+8
-93
@@ -220,9 +220,11 @@ async function fetchCities() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCityDetail(cityName) {
|
async function fetchCityDetail(cityName, force = false) {
|
||||||
const urlName = cityName.replace(/\s/g, "-");
|
const urlName = cityName.replace(/\s/g, "-");
|
||||||
const res = await fetch(`/api/city/${encodeURIComponent(urlName)}`);
|
const res = await fetch(
|
||||||
|
`/api/city/${encodeURIComponent(urlName)}?force_refresh=${force}`,
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
@@ -230,13 +232,13 @@ async function fetchCityDetail(cityName) {
|
|||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// Load & Render City Detail
|
// Load & Render City Detail
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
async function loadCityDetail(cityName) {
|
async function loadCityDetail(cityName, force = false) {
|
||||||
selectedCity = cityName;
|
selectedCity = cityName;
|
||||||
selectedForecastDate = null; // Reset selection for new city
|
selectedForecastDate = null; // Reset selection for new city
|
||||||
setActiveCityItem(cityName);
|
setActiveCityItem(cityName);
|
||||||
setSelectedMarker(cityName);
|
setSelectedMarker(cityName);
|
||||||
|
|
||||||
if (cityDataCache[cityName]) {
|
if (!force && cityDataCache[cityName]) {
|
||||||
renderPanel(cityDataCache[cityName]);
|
renderPanel(cityDataCache[cityName]);
|
||||||
const cData = cityDataCache[cityName];
|
const cData = cityDataCache[cityName];
|
||||||
if (cData.lat != null && cData.lon != null) {
|
if (cData.lat != null && cData.lon != null) {
|
||||||
@@ -252,7 +254,7 @@ async function loadCityDetail(cityName) {
|
|||||||
showLoading(true);
|
showLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchCityDetail(cityName);
|
const data = await fetchCityDetail(cityName, force);
|
||||||
cityDataCache[cityName] = data;
|
cityDataCache[cityName] = data;
|
||||||
saveCache();
|
saveCache();
|
||||||
renderPanel(data);
|
renderPanel(data);
|
||||||
@@ -326,8 +328,6 @@ function renderPanel(data) {
|
|||||||
renderForecast(data);
|
renderForecast(data);
|
||||||
// AI
|
// AI
|
||||||
renderAI(data);
|
renderAI(data);
|
||||||
// Market Odds
|
|
||||||
renderMarket(data);
|
|
||||||
// Risk
|
// Risk
|
||||||
renderRisk(data);
|
renderRisk(data);
|
||||||
}
|
}
|
||||||
@@ -925,77 +925,6 @@ function renderAI(data) {
|
|||||||
container.innerHTML = text;
|
container.innerHTML = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarket(data) {
|
|
||||||
const widget = document.getElementById("marketFloatingWidget");
|
|
||||||
const container = document.getElementById("marketOdds");
|
|
||||||
if (
|
|
||||||
!data.polymarket ||
|
|
||||||
!data.polymarket.markets ||
|
|
||||||
data.polymarket.markets.length === 0
|
|
||||||
) {
|
|
||||||
widget.classList.add("hidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.classList.remove("hidden");
|
|
||||||
const { markets, divergence } = data.polymarket;
|
|
||||||
let html = "";
|
|
||||||
|
|
||||||
markets.forEach((mkt) => {
|
|
||||||
// Find matching divergence signal
|
|
||||||
const divSignal = divergence.find((d) => d.question === mkt.question);
|
|
||||||
|
|
||||||
let probText =
|
|
||||||
mkt.yes_price != null ? `${Math.round(mkt.yes_price * 100)}¢` : "N/A";
|
|
||||||
let noText =
|
|
||||||
mkt.yes_price != null
|
|
||||||
? `${Math.round((1 - mkt.yes_price) * 100)}¢`
|
|
||||||
: "N/A";
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="market-card">
|
|
||||||
<div class="market-question">
|
|
||||||
<a href="${mkt.url}" target="_blank" style="color:var(--text-primary);text-decoration:none;">${mkt.question} 🔗</a>
|
|
||||||
</div>
|
|
||||||
<div class="market-prices">
|
|
||||||
<span class="market-price yes">${probText}</span><span class="market-price-label">Yes</span>
|
|
||||||
<span style="margin: 0 4px; color:var(--text-muted)">|</span>
|
|
||||||
<span class="market-price no">${noText}</span><span class="market-price-label">No</span>
|
|
||||||
</div>
|
|
||||||
<div class="market-volume">
|
|
||||||
交易量: $${mkt.volume ? Math.round(mkt.volume).toLocaleString() : 0}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (divSignal) {
|
|
||||||
let divClass = divSignal.signal;
|
|
||||||
let divIcon = "⚪";
|
|
||||||
let divText = "市场定价合理";
|
|
||||||
let diffPct = (Math.abs(divSignal.divergence) * 100).toFixed(1);
|
|
||||||
|
|
||||||
if (divClass === "underpriced" || divClass === "slight_under") {
|
|
||||||
divIcon = "🟢";
|
|
||||||
divText = `低估 (偏离 +${diffPct}%)`;
|
|
||||||
} else if (divClass === "overpriced" || divClass === "slight_over") {
|
|
||||||
divIcon = "🔴";
|
|
||||||
divText = `高估 (偏离 -${diffPct}%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="market-divergence ${divClass.replace("slight_", "")}">
|
|
||||||
<div><b>${divIcon} 你的模型:</b> ${Math.round(divSignal.our_prob * 100)}%</div>
|
|
||||||
<div style="margin-top:2px;"><b>🆚 市场定价:</b> ${Math.round(divSignal.market_prob * 100)}%</div>
|
|
||||||
<div style="margin-top:6px;font-weight:600;">💡 信号: ${divText}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRisk(data) {
|
function renderRisk(data) {
|
||||||
const container = document.getElementById("riskInfo");
|
const container = document.getElementById("riskInfo");
|
||||||
const risk = data.risk || {};
|
const risk = data.risk || {};
|
||||||
@@ -1021,12 +950,6 @@ function closePanel() {
|
|||||||
panel.classList.remove("visible");
|
panel.classList.remove("visible");
|
||||||
setTimeout(() => panel.classList.add("hidden"), 400);
|
setTimeout(() => panel.classList.add("hidden"), 400);
|
||||||
|
|
||||||
// Hide Polymarket widget
|
|
||||||
const marketWidget = document.getElementById("marketFloatingWidget");
|
|
||||||
if (marketWidget) {
|
|
||||||
marketWidget.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedCity = null;
|
selectedCity = null;
|
||||||
setSelectedMarker(null);
|
setSelectedMarker(null);
|
||||||
document
|
document
|
||||||
@@ -1272,20 +1195,12 @@ function closeHistoryModal() {
|
|||||||
document.getElementById("historyModal").classList.add("hidden");
|
document.getElementById("historyModal").classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
|
||||||
// Init
|
|
||||||
// ──────────────────────────────────────────────────────────
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
initMap();
|
initMap();
|
||||||
|
|
||||||
// Panel close
|
// Panel close
|
||||||
document.getElementById("panelClose").addEventListener("click", closePanel);
|
document.getElementById("panelClose").addEventListener("click", closePanel);
|
||||||
|
|
||||||
// Market Widget close
|
|
||||||
document.getElementById("marketClose").addEventListener("click", () => {
|
|
||||||
document.getElementById("marketFloatingWidget").classList.add("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Escape key
|
// Escape key
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape") closePanel();
|
if (e.key === "Escape") closePanel();
|
||||||
@@ -1311,7 +1226,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
cityDataCache = {};
|
cityDataCache = {};
|
||||||
saveCache();
|
saveCache();
|
||||||
if (selectedCity) {
|
if (selectedCity) {
|
||||||
await loadCityDetail(selectedCity);
|
await loadCityDetail(selectedCity, true);
|
||||||
}
|
}
|
||||||
btn.classList.remove("spinning");
|
btn.classList.remove("spinning");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PolyWeather — 天气衍生品智能地图</title>
|
<title>PolyWeather — 天气衍生品智能地图</title>
|
||||||
<meta name="description" content="Polymarket 天气衍生品交易智能地图。实时 METAR、DEB 融合预报与 AI 分析。">
|
<meta name="description" content="PolyWeather 天气衍生品智能地图。实时 METAR、MGM、DEB 融合预报与 AI 分析。">
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
@@ -140,13 +140,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- ── Polymarket Floating Widget ── -->
|
|
||||||
<div id="marketFloatingWidget" class="market-floating hidden">
|
|
||||||
<div class="market-widget-header">
|
|
||||||
<h3>📈 Polymarket</h3>
|
|
||||||
<button class="market-close" id="marketClose">✕</button>
|
|
||||||
</div>
|
|
||||||
<div id="marketOdds" class="market-odds"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Loading Overlay ── -->
|
<!-- ── Loading Overlay ── -->
|
||||||
|
|||||||
@@ -804,153 +804,6 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Market Odds Section (Floating Widget) ── */
|
|
||||||
.market-floating {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 30px;
|
|
||||||
left: 20px;
|
|
||||||
width: 320px;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border: 1px solid var(--border-glass);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
z-index: 1000;
|
|
||||||
overflow: hidden;
|
|
||||||
transition:
|
|
||||||
opacity 0.3s ease,
|
|
||||||
transform 0.3s ease;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-floating.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-widget-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-widget-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
.market-close:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-odds {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-card {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-card:hover {
|
|
||||||
border-color: var(--border-glass);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-question {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-prices {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-price {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: "Inter", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-price.yes {
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
.market-price.no {
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
.market-price-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-volume {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-divergence {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-divergence.underpriced {
|
|
||||||
background: rgba(52, 211, 153, 0.08);
|
|
||||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-divergence.overpriced {
|
|
||||||
background: rgba(248, 113, 113, 0.08);
|
|
||||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-divergence.neutral {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.market-no-data {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Risk Section ── */
|
/* ── Risk Section ── */
|
||||||
.risk-info {
|
.risk-info {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user