feat: Implement PolyWeather application with a map-based frontend, Python web services, market alert engine, and supporting utilities.

This commit is contained in:
2569718930@qq.com
2026-03-06 12:31:15 +08:00
parent 1e9b8a0d11
commit 43b7c1b480
16 changed files with 159 additions and 2008 deletions
-3
View File
@@ -1,6 +1,3 @@
# Polymarket API Credentials
POLYMARKET_API_KEY=your_api_key_here
# Telegram Bot # Telegram Bot
TELEGRAM_BOT_TOKEN=your_bot_token_here TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_CHAT_ID=your_chat_id_here TELEGRAM_CHAT_ID=your_chat_id_here
@@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { name } = await context.params;
const params = new URLSearchParams();
const forceRefresh = req.nextUrl.searchParams.get("force_refresh");
const targetDate = req.nextUrl.searchParams.get("target_date");
if (forceRefresh != null) {
params.set("force_refresh", forceRefresh);
}
if (targetDate) {
params.set("target_date", targetDate);
}
const qs = params.toString();
const url = `${API_BASE}/api/polymarket/${encodeURIComponent(name)}${qs ? `?${qs}` : ""}`;
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch polymarket snapshot", detail: String(error) },
{ status: 500 },
);
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ export default function HomePage() {
<main className="h-screen w-screen overflow-hidden bg-black"> <main className="h-screen w-screen overflow-hidden bg-black">
<iframe <iframe
title="PolyWeather Legacy Dashboard" title="PolyWeather Legacy Dashboard"
src="/legacy/index.html?v=market-v1" src="/legacy/index.html?v=legacy-v2"
className="h-full w-full border-0" className="h-full w-full border-0"
/> />
</main> </main>
+1 -9
View File
@@ -108,14 +108,6 @@
</div> </div>
</section> </section>
<section class="market-section">
<h3>Market Prices</h3>
<div id="marketSummary" class="market-summary"></div>
<div id="marketBook" class="market-book">
<!-- Dynamically populated -->
</div>
</section>
<!-- ── Multi-Model Comparison ── --> <!-- ── Multi-Model Comparison ── -->
<section class="models-section"> <section class="models-section">
<h3>🔬 多模型预报</h3> <h3>🔬 多模型预报</h3>
@@ -222,4 +214,4 @@
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
-231
View File
@@ -255,85 +255,6 @@ async function fetchCityDetail(cityName, force = false) {
return await res.json(); return await res.json();
} }
async function fetchCityMarket(cityName, targetDate, force = false) {
const urlName = cityName.replace(/\s/g, "-");
const params = new URLSearchParams({
force_refresh: String(force),
});
if (targetDate) {
params.set("target_date", targetDate);
}
const res = await fetch(`/api/polymarket/${encodeURIComponent(urlName)}?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
}
function hasMarketSnapshot(data, targetDate) {
return Boolean(
data?.polymarket &&
data.polymarket.target_date === targetDate &&
!data.polymarket.loading &&
!data.polymarket.fetch_error &&
Array.isArray(data.polymarket.markets),
);
}
async function hydrateCityMarketData(data, force = false) {
if (!data?.name) return null;
const targetDate = data.local_date || null;
if (!force && data.polymarket?.loading && data.polymarket.target_date === targetDate) {
return data.polymarket;
}
if (!force && hasMarketSnapshot(data, targetDate)) {
return data.polymarket;
}
const existingMarkets = Array.isArray(data.polymarket?.markets)
? data.polymarket.markets
: [];
data.polymarket = {
...(data.polymarket || {}),
target_date: targetDate,
loading: true,
fetch_error: null,
markets: existingMarkets,
};
if (selectedCity === data.name) {
renderMarketPrices(data);
}
try {
const snapshot = await fetchCityMarket(data.name, targetDate, force);
data.polymarket = {
...snapshot,
loading: false,
fetch_error: null,
};
} catch (e) {
console.error(`Failed to load market for ${data.name}:`, e);
data.polymarket = {
...(data.polymarket || {}),
target_date: targetDate,
loading: false,
fetch_error: e.message || "Unknown error",
markets: existingMarkets,
};
}
cityDataCache[data.name] = data;
saveCache();
if (selectedCity === data.name) {
renderMarketPrices(data);
}
return data.polymarket;
}
// ────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────
// Nearby Map Stations Rendering // Nearby Map Stations Rendering
// ────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────
@@ -426,7 +347,6 @@ async function loadCityDetail(cityName, force = false) {
const cachedData = cityDataCache[cityName]; const cachedData = cityDataCache[cityName];
renderPanel(cachedData); renderPanel(cachedData);
renderNearbyStations(cachedData); renderNearbyStations(cachedData);
hydrateCityMarketData(cachedData, false);
return; return;
} }
@@ -437,7 +357,6 @@ async function loadCityDetail(cityName, force = false) {
cityDataCache[cityName] = data; cityDataCache[cityName] = data;
saveCache(); saveCache();
renderPanel(data); renderPanel(data);
hydrateCityMarketData(data, force);
// Render nearby stations and zoom camera (cinematic or bounds) // Render nearby stations and zoom camera (cinematic or bounds)
renderNearbyStations(data); renderNearbyStations(data);
@@ -494,8 +413,6 @@ function renderPanel(data) {
renderChart(data); renderChart(data);
// Probabilities // Probabilities
renderProbabilities(data); renderProbabilities(data);
// Market prices
renderMarketPrices(data);
// Multi-model & Forecast synchronization // Multi-model & Forecast synchronization
if (!selectedForecastDate) { if (!selectedForecastDate) {
selectedForecastDate = data.local_date; selectedForecastDate = data.local_date;
@@ -1008,154 +925,6 @@ function formatCents(price) {
return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`; return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
} }
function formatCompactUsd(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
if (n >= 1000) {
const compact = n >= 10000 ? (n / 1000).toFixed(0) : (n / 1000).toFixed(1);
return `$${compact}k`;
}
return `$${Math.round(n)}`;
}
function formatMarketThreshold(market, fallbackUnit) {
const threshold = Number(market?.threshold);
if (!Number.isFinite(threshold)) {
return market?.question || "Market";
}
const isInteger = Math.abs(threshold - Math.round(threshold)) < 0.001;
const value = isInteger ? threshold.toFixed(0) : threshold.toFixed(1);
const unitRaw = String(market?.threshold_unit || fallbackUnit || "C").toUpperCase();
const unit = unitRaw === "F" ? "°F" : "°C";
if (market?.contract_type === "exceed") {
return `${value}${unit}+`;
}
return `${value}${unit}`;
}
function findOutcome(market, outcomeName) {
const target = String(outcomeName || "").toLowerCase();
return (market?.outcomes || []).find(
(outcome) => String(outcome?.name || "").toLowerCase() === target,
);
}
function renderMarketPrices(data) {
const summary = document.getElementById("marketSummary");
const container = document.getElementById("marketBook");
const snapshot = data.polymarket || {};
const markets = Array.isArray(snapshot.markets) ? [...snapshot.markets] : [];
const targetDate = snapshot.target_date || data.local_date || "--";
if (snapshot.loading && markets.length === 0) {
summary.innerHTML =
'<span class="market-muted">Loading current Polymarket markets...</span>';
container.innerHTML = "";
return;
}
if (snapshot.fetch_error && markets.length === 0) {
summary.innerHTML = `<span class="market-error">Market load failed: ${escapeHtml(snapshot.fetch_error)}</span>`;
container.innerHTML = "";
return;
}
if (markets.length === 0) {
summary.innerHTML =
'<span class="market-muted">No Polymarket markets for this date</span>';
container.innerHTML = "";
return;
}
markets.sort((a, b) => {
const left = Number(a?.threshold);
const right = Number(b?.threshold);
const safeLeft = Number.isFinite(left) ? left : Number.MAX_SAFE_INTEGER;
const safeRight = Number.isFinite(right) ? right : Number.MAX_SAFE_INTEGER;
return safeLeft - safeRight;
});
const primaryUrl = markets.find((market) => market?.url)?.url;
const updatedAtTs = snapshot.updated_at ? Date.parse(snapshot.updated_at) : NaN;
const updatedAt = Number.isFinite(updatedAtTs)
? new Date(updatedAtTs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: null;
summary.innerHTML = `
<div class="market-summary-main">
<span>${escapeHtml(targetDate)} - ${markets.length} markets</span>
<span>Buy = best ask</span>
<span>Sell = best bid</span>
${updatedAt ? `<span>Updated ${escapeHtml(updatedAt)}</span>` : ""}
</div>
${primaryUrl ? `<a href="${escapeHtml(primaryUrl)}" target="_blank" rel="noreferrer">Open Polymarket</a>` : ""}
`;
container.innerHTML = markets
.map((market) => {
const yes = findOutcome(market, "yes") || {};
const no = findOutcome(market, "no") || {};
const label = formatMarketThreshold(
market,
String(data.temp_symbol || "").includes("F") ? "F" : "C",
);
const spread = Number(yes.spread);
const meta = [
`Volume ${formatCompactUsd(market.volume)}`,
`Liquidity ${formatCompactUsd(market.liquidity)}`,
Number.isFinite(spread) ? `Yes spread ${formatCents(spread)}` : null,
].filter(Boolean);
return `
<div class="market-row">
<div class="market-contract">
<div class="market-threshold">${escapeHtml(label)}</div>
<div class="market-contract-meta">${meta.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}</div>
<div class="market-question">${escapeHtml(market.question || "")}</div>
</div>
<div class="market-side yes">
<div class="market-side-header">
<span class="market-side-label yes">YES</span>
<span class="market-last">Last ${formatCents(yes.last_trade_price)}</span>
</div>
<div class="market-side-prices">
<div class="market-price-chip">
<div class="market-price-label">Buy</div>
<div class="market-price-value">${formatCents(yes.buy_price)}</div>
</div>
<div class="market-price-chip">
<div class="market-price-label">Sell</div>
<div class="market-price-value">${formatCents(yes.sell_price)}</div>
</div>
</div>
</div>
<div class="market-side no">
<div class="market-side-header">
<span class="market-side-label no">NO</span>
<span class="market-last">Last ${formatCents(no.last_trade_price)}</span>
</div>
<div class="market-side-prices">
<div class="market-price-chip">
<div class="market-price-label">Buy</div>
<div class="market-price-value">${formatCents(no.buy_price)}</div>
</div>
<div class="market-price-chip">
<div class="market-price-label">Sell</div>
<div class="market-price-value">${formatCents(no.sell_price)}</div>
</div>
</div>
</div>
</div>
`;
})
.join("");
}
function renderModels(data) { function renderModels(data) {
const container = document.getElementById("modelBars"); const container = document.getElementById("modelBars");
const targetDate = selectedForecastDate || data.local_date; const targetDate = selectedForecastDate || data.local_date;
-152
View File
@@ -668,155 +668,6 @@ body {
background: rgba(99, 102, 241, 0.15); background: rgba(99, 102, 241, 0.15);
} }
/* ── Market Section ── */
.market-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
font-size: 11px;
color: var(--text-muted);
}
.market-summary-main {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.market-summary a {
color: var(--accent-cyan);
text-decoration: none;
font-weight: 600;
}
.market-summary a:hover {
text-decoration: underline;
}
.market-book {
display: flex;
flex-direction: column;
gap: 10px;
}
.market-row {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.025);
}
.market-contract {
min-width: 0;
}
.market-threshold {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.market-contract-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 11px;
color: var(--text-muted);
}
.market-question {
margin-top: 8px;
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
word-break: break-word;
}
.market-side {
border-radius: 10px;
padding: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
}
.market-side.yes {
border-color: rgba(34, 197, 94, 0.18);
background: rgba(34, 197, 94, 0.05);
}
.market-side.no {
border-color: rgba(248, 113, 113, 0.18);
background: rgba(248, 113, 113, 0.05);
}
.market-side-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.market-side-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
}
.market-side-label.yes {
color: #4ade80;
}
.market-side-label.no {
color: #fda4af;
}
.market-last {
font-size: 10px;
color: var(--text-muted);
}
.market-side-prices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.market-price-chip {
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: rgba(15, 23, 42, 0.45);
padding: 8px;
}
.market-price-label {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 2px;
}
.market-price-value {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.market-muted {
color: var(--text-muted);
}
.market-error {
color: var(--accent-red);
}
/* ── Model Bars ── */ /* ── Model Bars ── */
.model-bars { .model-bars {
display: flex; display: flex;
@@ -1237,9 +1088,6 @@ body {
:root { :root {
--panel-width: 100%; --panel-width: 100%;
} }
.market-row {
grid-template-columns: 1fr;
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
+108 -234
View File
@@ -1,11 +1,10 @@
""" """
Rule-based weather/market alert engine for short-horizon Polymarket trading. Rule-based weather alert engine for short-horizon trading signals.
""" """
from __future__ import annotations from __future__ import annotations
import math import math
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -188,178 +187,6 @@ def _calc_forecast_breakthrough_alert(city_weather: Dict[str, Any], temp_symbol:
} }
def _convert_temp(value: float, from_unit: Optional[str], temp_symbol: str) -> float:
from_u = (from_unit or "").upper()
to_f = "F" in (temp_symbol or "").upper()
if from_u == "F" and not to_f:
return (value - 32.0) * 5.0 / 9.0
if from_u == "C" and to_f:
return (value * 9.0 / 5.0) + 32.0
return value
def _extract_numbers(text: str) -> List[float]:
out: List[float] = []
for m in re.finditer(r"-?\d+(?:\.\d+)?", text or ""):
try:
out.append(float(m.group(0)))
except Exception:
continue
return out
def _extract_market_strikes(
market_snapshot: Dict[str, Any],
temp_symbol: str,
) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = []
for market in market_snapshot.get("markets", []) or []:
m_question = market.get("question") or ""
m_id = market.get("id")
threshold = _sf(market.get("threshold"))
threshold_unit = market.get("threshold_unit")
if threshold is not None:
candidates.append(
{
"strike": _convert_temp(threshold, threshold_unit, temp_symbol),
"source": "market_threshold",
"market_id": m_id,
"question": m_question,
}
)
for outcome in market.get("outcomes", []) or []:
name = str(outcome.get("name") or "")
name_l = name.lower()
if name_l in ("yes", "no"):
continue
if not any(tok in name_l for tok in ("-", "to", "below", "under", "above", "over", "deg", "f", "c")):
continue
vals = [v for v in _extract_numbers(name) if -80 <= v <= 160]
for v in vals:
candidates.append(
{
"strike": _convert_temp(v, threshold_unit, temp_symbol),
"source": "outcome_number",
"market_id": m_id,
"question": m_question,
}
)
return candidates
def _find_market_by_id(markets: List[Dict[str, Any]], market_id: Any) -> Optional[Dict[str, Any]]:
for m in markets:
if str(m.get("id")) == str(market_id):
return m
return None
def _extract_market_prices(target_market: Optional[Dict[str, Any]]) -> Dict[str, Any]:
prices: Dict[str, Any] = {
"question": None,
"yes_buy": None,
"yes_sell": None,
"yes_last": None,
"no_buy": None,
"no_sell": None,
"no_last": None,
}
if not target_market:
return prices
prices["question"] = target_market.get("question")
for oc in target_market.get("outcomes", []) or []:
name = str(oc.get("name") or "").strip().lower()
if name not in ("yes", "no"):
continue
prefix = "yes" if name == "yes" else "no"
prices[f"{prefix}_buy"] = _sf(oc.get("buy_price"))
prices[f"{prefix}_sell"] = _sf(oc.get("sell_price"))
prices[f"{prefix}_last"] = _sf(oc.get("last_trade_price"))
if prices[f"{prefix}_last"] is None:
prices[f"{prefix}_last"] = _sf(oc.get("last_price"))
return prices
def _format_market_price(value: Optional[float]) -> str:
if value is None:
return "-"
return f"{value * 100:.0f}c"
def _format_temp_display(value: Optional[float], temp_symbol: str) -> str:
if value is None:
return f"-{temp_symbol}"
rounded = round(float(value), 1)
if abs(rounded - round(rounded)) < 1e-9:
return f"{int(round(rounded))}{temp_symbol}"
return f"{rounded:.1f}{temp_symbol}"
def _calc_kill_zone_alert(
city_weather: Dict[str, Any],
market_snapshot: Dict[str, Any],
temp_symbol: str,
) -> Dict[str, Any]:
current_temp = _sf((city_weather.get("current") or {}).get("temp"))
if current_temp is None:
return {
"type": "kill_zone",
"triggered": False,
"reason": "current temperature unavailable",
}
candidates = _extract_market_strikes(market_snapshot, temp_symbol)
if not candidates:
return {
"type": "kill_zone",
"triggered": False,
"reason": "no market strike candidates found",
}
nearest = min(candidates, key=lambda row: abs(current_temp - row["strike"]))
strike = _sf(nearest.get("strike"))
if strike is None:
return {
"type": "kill_zone",
"triggered": False,
"reason": "failed to parse strike temperature",
}
threshold = _to_unit_delta(0.3, temp_symbol)
distance = abs(current_temp - strike)
triggered = distance < threshold
markets = market_snapshot.get("markets", []) or []
target_market = _find_market_by_id(markets, nearest.get("market_id"))
market_prices = _extract_market_prices(target_market)
no_probability = None
if target_market:
yes_price = market_prices.get("yes_buy")
no_price = market_prices.get("no_buy")
if no_price is None and yes_price is not None:
no_price = max(0.0, min(1.0, 1.0 - yes_price))
no_probability = no_price
return {
"type": "kill_zone",
"triggered": triggered,
"current_temp": round(current_temp, 2),
"strike_price": round(strike, 2),
"market_label": f"{_format_temp_display(strike, temp_symbol)} 档位",
"distance": round(distance, 2),
"threshold": round(threshold, 2),
"market_id": nearest.get("market_id"),
"question": nearest.get("question"),
"strike_source": nearest.get("source"),
"no_probability": round(no_probability, 4) if no_probability is not None else None,
"market_prices": market_prices,
}
def _pick_leading_station(city: str, nearby: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: def _pick_leading_station(city: str, nearby: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not nearby: if not nearby:
return None return None
@@ -546,12 +373,52 @@ def _calc_advection_alert(city_weather: Dict[str, Any], temp_symbol: str) -> Dic
} }
def _calc_peak_passed_guard(city_weather: Dict[str, Any], temp_symbol: str) -> Dict[str, Any]:
current = city_weather.get("current") or {}
current_temp = _sf(current.get("temp"))
max_so_far = _sf(current.get("max_so_far"))
max_temp_time = current.get("max_temp_time")
local_time = city_weather.get("local_time")
if current_temp is None or max_so_far is None:
return {"suppressed": False, "reason": "missing current/max_so_far"}
local_min = _minute_of_day(local_time)
peak_min = _minute_of_day(max_temp_time)
if local_min is None or peak_min is None:
return {"suppressed": False, "reason": "missing local_time/max_temp_time"}
# Do not suppress in the morning; many cities still make their daily high later.
if local_min < (14 * 60 + 30):
return {"suppressed": False, "reason": "too early in local day"}
if peak_min >= local_min:
return {"suppressed": False, "reason": "peak has not passed yet"}
minutes_since_peak = local_min - peak_min
rollback = max_so_far - current_temp
rollback_threshold = _to_unit_delta(0.8, temp_symbol)
cooled_off = rollback >= rollback_threshold
suppressed = minutes_since_peak >= 45 and cooled_off
return {
"suppressed": suppressed,
"reason": "late-day peak already passed" if suppressed else "cool-off threshold not met",
"current_temp": round(current_temp, 2),
"max_so_far": round(max_so_far, 2),
"max_temp_time": max_temp_time,
"local_time": local_time,
"minutes_since_peak": minutes_since_peak,
"rollback": round(rollback, 2),
"rollback_threshold": round(rollback_threshold, 2),
}
def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str: def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
mapping = [ mapping = [
("ankara_center_deb_hit", "Center达到DEB"), ("ankara_center_deb_hit", "Center达到DEB"),
("momentum_spike", "动量突变"), ("momentum_spike", "动量突变"),
("forecast_breakthrough", "预测突破"), ("forecast_breakthrough", "预测突破"),
("kill_zone", "临界触发"),
("advection", "暖平流"), ("advection", "暖平流"),
] ]
parts = [name for key, name in mapping if rules.get(key, {}).get("triggered")] parts = [name for key, name in mapping if rules.get(key, {}).get("triggered")]
@@ -561,13 +428,24 @@ def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
def _build_advice_cn( def _build_advice_cn(
rules: Dict[str, Dict[str, Any]], rules: Dict[str, Dict[str, Any]],
temp_symbol: str, temp_symbol: str,
suppression: Optional[Dict[str, Any]] = None,
) -> str: ) -> str:
if (suppression or {}).get("suppressed"):
max_so_far = _sf((suppression or {}).get("max_so_far"))
max_temp_time = (suppression or {}).get("max_temp_time")
rollback = _sf((suppression or {}).get("rollback"))
if max_so_far is not None and max_temp_time and rollback is not None:
return (
f"当地高温大概率已在 {max_temp_time} 前后兑现,"
f"较日内高点 {max_so_far:.1f}{temp_symbol} 已回落 {rollback:.1f}{temp_symbol},暂停主动推送。"
)
return "当地高温大概率已经兑现,当前进入回落阶段,暂停主动推送。"
parts: List[str] = [] parts: List[str] = []
center_deb = rules.get("ankara_center_deb_hit", {}) center_deb = rules.get("ankara_center_deb_hit", {})
advection = rules.get("advection", {}) advection = rules.get("advection", {})
momentum = rules.get("momentum_spike", {}) momentum = rules.get("momentum_spike", {})
breakthrough = rules.get("forecast_breakthrough", {}) breakthrough = rules.get("forecast_breakthrough", {})
kill_zone = rules.get("kill_zone", {})
if center_deb.get("triggered"): if center_deb.get("triggered"):
deb_prediction = _sf(center_deb.get("deb_prediction")) deb_prediction = _sf(center_deb.get("deb_prediction"))
@@ -589,15 +467,8 @@ def _build_advice_cn(
if breakthrough.get("triggered"): if breakthrough.get("triggered"):
parts.append("实测已击穿主流模型上沿") parts.append("实测已击穿主流模型上沿")
no_prob = _sf(kill_zone.get("no_probability"))
strike = _sf(kill_zone.get("strike_price"))
if kill_zone.get("triggered") and no_prob is not None and strike is not None:
parts.append(f'{no_prob * 100:.0f}% 概率的 {strike:.1f}{temp_symbol} "No" 单需谨慎')
elif kill_zone.get("triggered") and strike is not None:
parts.append(f"接近 {strike:.1f}{temp_symbol} 结算阻力位,波动率可能激增")
if not parts: if not parts:
return "当前未触发高优先级异动,继续观察盘口与实测联动。" return "当前未触发高优先级天气异动,继续观察实测与模型联动。"
return "".join(parts) + "" return "".join(parts) + ""
@@ -605,26 +476,26 @@ def _build_telegram_messages(
city_weather: Dict[str, Any], city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]], rules: Dict[str, Dict[str, Any]],
map_url: Optional[str], map_url: Optional[str],
suppression: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]: ) -> Dict[str, str]:
temp_symbol = city_weather.get("temp_symbol", "°C") temp_symbol = city_weather.get("temp_symbol", "°C")
city_name = city_weather.get("display_name") or city_weather.get("name", "").title() city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
current_temp = _sf((city_weather.get("current") or {}).get("temp")) current_temp = _sf((city_weather.get("current") or {}).get("temp"))
center_deb = rules.get("ankara_center_deb_hit", {}) center_deb = rules.get("ankara_center_deb_hit", {})
momentum = rules.get("momentum_spike", {}) momentum = rules.get("momentum_spike", {})
kill_zone = rules.get("kill_zone", {})
advection = rules.get("advection", {}) advection = rules.get("advection", {})
if current_temp is None: if current_temp is None:
return {"zh": "", "en": ""} return {"zh": "", "en": ""}
suppressed = bool((suppression or {}).get("suppressed"))
has_active_trigger = any(rule.get("triggered") for rule in rules.values()) has_active_trigger = any(rule.get("triggered") for rule in rules.values())
types_cn = _join_trigger_types_cn(rules) or "盘口异动" if suppressed:
types_cn = "高温已过(暂停推送)"
else:
types_cn = _join_trigger_types_cn(rules) or "天气状态快照"
delta_temp = _sf(momentum.get("delta_temp")) delta_temp = _sf(momentum.get("delta_temp"))
delta_min = momentum.get("delta_minutes") delta_min = momentum.get("delta_minutes")
strike = _sf(kill_zone.get("strike_price"))
distance = _sf(kill_zone.get("distance"))
market_label = str(kill_zone.get("market_label") or "").strip()
market_prices = kill_zone.get("market_prices") or {}
center_station = center_deb.get("center_station") or {} center_station = center_deb.get("center_station") or {}
dyn = f"实测 {current_temp:.1f}{temp_symbol}" dyn = f"实测 {current_temp:.1f}{temp_symbol}"
@@ -632,13 +503,6 @@ def _build_telegram_messages(
icon = "🚀" if delta_temp > 0 else ("🧊" if delta_temp < 0 else "") icon = "🚀" if delta_temp > 0 else ("🧊" if delta_temp < 0 else "")
dyn += f" ({int(delta_min)}min 内 {delta_temp:+.1f}{temp_symbol}) {icon}" dyn += f" ({int(delta_min)}min 内 {delta_temp:+.1f}{temp_symbol}) {icon}"
strike_line = ""
if strike is not None and distance is not None:
if current_temp < strike:
strike_line = f"距离 {strike:.1f}{temp_symbol} 档位:还差 {distance:.1f}{temp_symbol}"
else:
strike_line = f"距离 {strike:.1f}{temp_symbol} 档位:高出 {distance:.1f}{temp_symbol}"
lead_line = "" lead_line = ""
if advection.get("triggered"): if advection.get("triggered"):
st_name = ((advection.get("lead_station") or {}).get("name")) or "nearby station" st_name = ((advection.get("lead_station") or {}).get("name")) or "nearby station"
@@ -662,20 +526,18 @@ def _build_telegram_messages(
if lead_gap is not None: if lead_gap is not None:
center_deb_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}" center_deb_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
price_line = "" peak_line = ""
if any( if suppressed:
market_prices.get(key) is not None max_so_far = _sf((suppression or {}).get("max_so_far"))
for key in ("yes_buy", "yes_sell", "no_buy", "no_sell") max_temp_time = (suppression or {}).get("max_temp_time")
): rollback = _sf((suppression or {}).get("rollback"))
price_prefix = f"盘口({market_label}):" if market_label else "盘口:" if max_so_far is not None and max_temp_time and rollback is not None:
price_line = ( peak_line = (
price_prefix f"高温状态:日内高点 {max_so_far:.1f}{temp_symbol} @ {max_temp_time}"
+ f"当前已回落 {rollback:.1f}{temp_symbol}"
f"Yes 买 {_format_market_price(market_prices.get('yes_buy'))} / 卖 {_format_market_price(market_prices.get('yes_sell'))} | " )
f"No 买 {_format_market_price(market_prices.get('no_buy'))} / 卖 {_format_market_price(market_prices.get('no_sell'))}"
)
advice = _build_advice_cn(rules, temp_symbol) advice = _build_advice_cn(rules, temp_symbol, suppression=suppression)
final_map = map_url or "https://polyweather-pro.vercel.app/" final_map = map_url or "https://polyweather-pro.vercel.app/"
title_zh = "🚨 PolyWeather 异动预警" if has_active_trigger else "📍 PolyWeather 状态快照" title_zh = "🚨 PolyWeather 异动预警" if has_active_trigger else "📍 PolyWeather 状态快照"
title_en = "🚨 PolyWeather Alert" if has_active_trigger else "📍 PolyWeather Status" title_en = "🚨 PolyWeather Alert" if has_active_trigger else "📍 PolyWeather Status"
@@ -686,27 +548,25 @@ def _build_telegram_messages(
f"类型:{types_cn}", f"类型:{types_cn}",
f"动态:{dyn}", f"动态:{dyn}",
] ]
if strike_line:
lines_zh.append(strike_line)
if center_deb_line: if center_deb_line:
lines_zh.append(center_deb_line) lines_zh.append(center_deb_line)
if price_line: if peak_line:
lines_zh.append(price_line) lines_zh.append(peak_line)
if lead_line: if lead_line:
lines_zh.append(lead_line) lines_zh.append(lead_line)
lines_zh.append(f"AI 建议:{advice}") lines_zh.append(f"AI 建议:{advice}")
lines_zh.append(f"点击查看实时地图:{final_map}") lines_zh.append(f"点击查看实时地图:{final_map}")
type_en = [] type_en = []
if rules.get("ankara_center_deb_hit", {}).get("triggered"):
type_en.append("Center Reached DEB")
if rules.get("momentum_spike", {}).get("triggered"): if rules.get("momentum_spike", {}).get("triggered"):
type_en.append("Momentum Spike") type_en.append("Momentum Spike")
if rules.get("forecast_breakthrough", {}).get("triggered"): if rules.get("forecast_breakthrough", {}).get("triggered"):
type_en.append("Forecast Breakthrough") type_en.append("Forecast Breakthrough")
if rules.get("kill_zone", {}).get("triggered"):
type_en.append("Kill Zone")
if rules.get("advection", {}).get("triggered"): if rules.get("advection", {}).get("triggered"):
type_en.append("Advection") type_en.append("Advection")
type_en_str = " + ".join(type_en) or "Market anomaly" type_en_str = "Peak Passed (suppressed)" if suppressed else (" + ".join(type_en) or "Weather snapshot")
lines_en = [ lines_en = [
f"{title_en} [{city_name}]", f"{title_en} [{city_name}]",
@@ -714,8 +574,6 @@ def _build_telegram_messages(
f"Type: {type_en_str}", f"Type: {type_en_str}",
f"Now: {current_temp:.1f}{temp_symbol}", f"Now: {current_temp:.1f}{temp_symbol}",
] ]
if strike is not None and distance is not None:
lines_en.append(f"Distance to {strike:.1f}{temp_symbol} strike: {distance:.1f}{temp_symbol}")
if center_deb_line: if center_deb_line:
center_temp = _sf(center_station.get("temp")) center_temp = _sf(center_station.get("temp"))
deb_prediction = _sf(center_deb.get("deb_prediction")) deb_prediction = _sf(center_deb.get("deb_prediction"))
@@ -723,14 +581,15 @@ def _build_telegram_messages(
lines_en.append( lines_en.append(
f"Center signal: {center_temp:.1f}{temp_symbol} has reached DEB {deb_prediction:.1f}{temp_symbol}" f"Center signal: {center_temp:.1f}{temp_symbol} has reached DEB {deb_prediction:.1f}{temp_symbol}"
) )
if price_line: if peak_line:
price_label_en = f"Quotes ({market_label}): " if market_label else "Quotes: " max_so_far = _sf((suppression or {}).get("max_so_far"))
lines_en.append( max_temp_time = (suppression or {}).get("max_temp_time")
price_label_en rollback = _sf((suppression or {}).get("rollback"))
+ if max_so_far is not None and max_temp_time and rollback is not None:
f"Yes buy {_format_market_price(market_prices.get('yes_buy'))} / sell {_format_market_price(market_prices.get('yes_sell'))} | " lines_en.append(
f"No buy {_format_market_price(market_prices.get('no_buy'))} / sell {_format_market_price(market_prices.get('no_sell'))}" f"Peak state: intraday high {max_so_far:.1f}{temp_symbol} at {max_temp_time}, "
) f"now off by {rollback:.1f}{temp_symbol}"
)
lines_en.append(f"Action: {advice}") lines_en.append(f"Action: {advice}")
lines_en.append(f"Map: {final_map}") lines_en.append(f"Map: {final_map}")
@@ -739,11 +598,10 @@ def _build_telegram_messages(
def build_trading_alerts( def build_trading_alerts(
city_weather: Dict[str, Any], city_weather: Dict[str, Any],
market_snapshot: Dict[str, Any],
map_url: Optional[str] = None, map_url: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Build weather+market trading alerts for paid Telegram delivery and web usage. Build weather-driven trading alerts for paid Telegram delivery and web usage.
""" """
temp_symbol = city_weather.get("temp_symbol", "°C") temp_symbol = city_weather.get("temp_symbol", "°C")
city = city_weather.get("name", "") city = city_weather.get("name", "")
@@ -753,7 +611,6 @@ def build_trading_alerts(
"ankara_center_deb_hit": _calc_ankara_center_deb_alert(city_weather, temp_symbol), "ankara_center_deb_hit": _calc_ankara_center_deb_alert(city_weather, temp_symbol),
"momentum_spike": _calc_momentum_alert(city_weather, temp_symbol), "momentum_spike": _calc_momentum_alert(city_weather, temp_symbol),
"forecast_breakthrough": _calc_forecast_breakthrough_alert(city_weather, temp_symbol), "forecast_breakthrough": _calc_forecast_breakthrough_alert(city_weather, temp_symbol),
"kill_zone": _calc_kill_zone_alert(city_weather, market_snapshot, temp_symbol),
"advection": _calc_advection_alert(city_weather, temp_symbol), "advection": _calc_advection_alert(city_weather, temp_symbol),
} }
@@ -765,15 +622,31 @@ def build_trading_alerts(
for key, value in rules.items() for key, value in rules.items()
if value.get("triggered") if value.get("triggered")
] ]
force_push = any(alert.get("force_push") for alert in triggered) suppression = _calc_peak_passed_guard(city_weather, temp_symbol)
severity = "high" if len(triggered) >= 2 else ("medium" if len(triggered) == 1 else "none") if suppression.get("suppressed") and triggered:
if force_push and severity == "none": suppression["raw_trigger_types"] = [alert.get("type") for alert in triggered if alert.get("type")]
severity = "medium" for alert in triggered:
rule = rules.get(alert.get("type") or "")
if not rule:
continue
rule["raw_triggered"] = True
rule["triggered"] = False
rule["suppressed"] = True
rule["suppression_reason"] = suppression.get("reason")
triggered = []
force_push = False
severity = "none"
else:
force_push = any(alert.get("force_push") for alert in triggered)
severity = "high" if len(triggered) >= 2 else ("medium" if len(triggered) == 1 else "none")
if force_push and severity == "none":
severity = "medium"
telegram = _build_telegram_messages( telegram = _build_telegram_messages(
city_weather=city_weather, city_weather=city_weather,
rules=rules, rules=rules,
map_url=map_url, map_url=map_url,
suppression=suppression,
) )
return { return {
@@ -783,6 +656,7 @@ def build_trading_alerts(
"severity": severity, "severity": severity,
"trigger_count": len(triggered), "trigger_count": len(triggered),
"rules": rules, "rules": rules,
"suppression": suppression,
"triggered_alerts": triggered, "triggered_alerts": triggered,
"telegram": telegram, "telegram": telegram,
} }
-673
View File
@@ -1,673 +0,0 @@
"""
Polymarket Weather Market Client
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Market discovery + orderbook snapshot + anomaly detection for weather markets.
"""
import json
import logging
import re
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
import requests
logger = logging.getLogger(__name__)
GAMMA_API = "https://gamma-api.polymarket.com"
CLOB_API = "https://clob.polymarket.com"
CITY_KEYWORDS = {
"ankara": ["ankara"],
"london": ["london"],
"paris": ["paris"],
"seoul": ["seoul"],
"toronto": ["toronto"],
"buenos aires": ["buenos aires"],
"wellington": ["wellington"],
"new york": ["new york", "nyc", "new york city"],
"chicago": ["chicago"],
"dallas": ["dallas"],
"miami": ["miami"],
"atlanta": ["atlanta"],
"seattle": ["seattle"],
}
CACHE_TTL_MARKETS = 300
CACHE_TTL_BOOKS = 20
SNAPSHOT_RETENTION_SEC = 48 * 3600
_market_cache: Dict[str, Any] = {}
_market_cache_ts: float = 0.0
_book_cache: Dict[str, Dict[str, Any]] = {}
_book_cache_ts: Dict[str, float] = {}
_prev_snapshots: Dict[str, Dict[str, Any]] = {}
def _build_session(proxy: Optional[str] = None) -> requests.Session:
"""Build a requests session with optional explicit proxy."""
session = requests.Session()
# Disable implicit system/environment proxies for deterministic behavior.
session.trust_env = False
if proxy:
if not proxy.startswith("http"):
proxy = f"http://{proxy}"
session.proxies = {"http": proxy, "https": proxy}
return session
def _safe_float(v: Any) -> Optional[float]:
if v is None:
return None
try:
return float(v)
except Exception:
return None
def _parse_json_list(v: Any) -> List[Any]:
"""Parse value into list. Gamma often returns JSON-encoded strings."""
if isinstance(v, list):
return v
if isinstance(v, str):
s = v.strip()
if not s:
return []
try:
parsed = json.loads(s)
return parsed if isinstance(parsed, list) else []
except Exception:
return []
return []
def _parse_threshold_from_question(question: str) -> Optional[Dict[str, Any]]:
"""Extract simple threshold contracts like: exceed 45°F/7°C."""
m = re.search(r"exceed\s+([\d.]+)\s*[°掳]?\s*([FC])", question, re.IGNORECASE)
if m:
return {
"threshold": float(m.group(1)),
"unit": m.group(2).upper(),
"type": "exceed",
}
if re.search(r"highest\s+temperature", question, re.IGNORECASE):
return {"type": "range"}
return None
def _match_city(text: str) -> Optional[str]:
text_l = (text or "").lower()
for city, aliases in CITY_KEYWORDS.items():
if any(alias in text_l for alias in aliases):
return city
return None
def _parse_date_from_question(text: str) -> Optional[str]:
"""Extract date from market question, return YYYY-MM-DD."""
m = re.search(r"on\s+(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", text, re.IGNORECASE)
if not m:
return None
month_map = {
"january": 1,
"february": 2,
"march": 3,
"april": 4,
"may": 5,
"june": 6,
"july": 7,
"august": 8,
"september": 9,
"october": 10,
"november": 11,
"december": 12,
}
month = month_map.get(m.group(1).lower())
if month is None:
return None
day = int(m.group(2))
year = int(m.group(3)) if m.group(3) else datetime.utcnow().year
return f"{year:04d}-{month:02d}-{day:02d}"
def _parse_iso_date(dt: Optional[str]) -> Optional[str]:
if not dt:
return None
try:
return dt[:10]
except Exception:
return None
def _sort_by_volume(markets: List[Dict[str, Any]]) -> None:
markets.sort(key=lambda x: _safe_float(x.get("volume")) or 0.0, reverse=True)
def _cleanup_old_snapshots(now_ts: float) -> None:
stale = [
token for token, rec in _prev_snapshots.items()
if now_ts - (_safe_float(rec.get("ts")) or 0.0) > SNAPSHOT_RETENTION_SEC
]
for token in stale:
_prev_snapshots.pop(token, None)
def fetch_weather_markets(
proxy: Optional[str] = None,
timeout: int = 15,
force_refresh: bool = False,
) -> List[Dict[str, Any]]:
"""Fetch active weather markets and normalize outcome/token metadata."""
global _market_cache, _market_cache_ts
now_ts = time.time()
if (
not force_refresh
and _market_cache
and now_ts - _market_cache_ts < CACHE_TTL_MARKETS
):
return _market_cache.get("_all", [])
session = _build_session(proxy)
try:
resp = session.get(
f"{GAMMA_API}/events",
params={
"tag": "weather",
"active": "true",
"closed": "false",
"limit": 200,
},
timeout=timeout,
headers={"Accept": "application/json"},
)
resp.raise_for_status()
events = resp.json()
except Exception as exc:
logger.warning(f"Polymarket fetch_weather_markets failed: {exc}")
return _market_cache.get("_all", [])
all_markets: List[Dict[str, Any]] = []
for event in events:
event_title = event.get("title", "")
event_slug = event.get("slug", "")
event_end_date = _parse_iso_date(event.get("endDate"))
for mkt in event.get("markets", []) or []:
question = mkt.get("question") or event_title
city = _match_city(question) or _match_city(event_title)
if not city:
continue
target_date = (
_parse_date_from_question(question)
or _parse_date_from_question(event_title)
or _parse_iso_date(mkt.get("endDate"))
or event_end_date
)
parsed = _parse_threshold_from_question(question)
outcomes = [str(x) for x in _parse_json_list(mkt.get("outcomes"))]
outcome_prices = [
_safe_float(x) for x in _parse_json_list(mkt.get("outcomePrices"))
]
token_ids = [str(x) for x in _parse_json_list(mkt.get("clobTokenIds"))]
outcome_rows: List[Dict[str, Any]] = []
for idx, name in enumerate(outcomes):
outcome_rows.append(
{
"name": name,
"token_id": token_ids[idx] if idx < len(token_ids) else None,
"last_price": (
outcome_prices[idx] if idx < len(outcome_prices) else None
),
}
)
yes_price = None
no_price = None
for row in outcome_rows:
name_l = row["name"].strip().lower()
if name_l == "yes":
yes_price = row.get("last_price")
elif name_l == "no":
no_price = row.get("last_price")
all_markets.append(
{
"id": mkt.get("id"),
"question": question,
"city": city,
"date": target_date,
"threshold": parsed.get("threshold") if parsed else None,
"threshold_unit": parsed.get("unit") if parsed else None,
"contract_type": (
parsed.get("type", "unknown") if parsed else "unknown"
),
"yes_price": yes_price,
"no_price": no_price,
"volume": _safe_float(mkt.get("volume")),
"liquidity": _safe_float(mkt.get("liquidityNum") or mkt.get("liquidity")),
"slug": mkt.get("slug", ""),
"event_slug": event_slug,
"url": f"https://polymarket.com/event/{event_slug}" if event_slug else None,
"outcomes": outcome_rows,
"enable_order_book": bool(mkt.get("enableOrderBook", True)),
}
)
by_city: Dict[str, List[Dict[str, Any]]] = {}
for m in all_markets:
by_city.setdefault(m["city"], []).append(m)
for city in by_city:
_sort_by_volume(by_city[city])
_sort_by_volume(all_markets)
_market_cache = {"_all": all_markets, **by_city}
_market_cache_ts = now_ts
logger.info(f"Polymarket fetched {len(all_markets)} weather markets")
return all_markets
def get_city_markets(
city: str,
target_date: Optional[str] = None,
proxy: Optional[str] = None,
timeout: int = 15,
force_refresh: bool = False,
) -> List[Dict[str, Any]]:
"""Get city markets, optionally filtered by YYYY-MM-DD target date."""
if not _market_cache or force_refresh or (time.time() - _market_cache_ts >= CACHE_TTL_MARKETS):
fetch_weather_markets(proxy=proxy, timeout=timeout, force_refresh=force_refresh)
rows = list(_market_cache.get(city, []))
if target_date:
rows = [m for m in rows if m.get("date") == target_date]
_sort_by_volume(rows)
return rows
def _extract_best_prices(orderbook: Dict[str, Any]) -> Dict[str, Optional[float]]:
bids = orderbook.get("bids") or []
asks = orderbook.get("asks") or []
best_bid_price = None
best_bid_size = None
best_ask_price = None
best_ask_size = None
for level in bids:
p = _safe_float(level.get("price"))
if p is None:
continue
s = _safe_float(level.get("size"))
if best_bid_price is None or p > best_bid_price:
best_bid_price = p
best_bid_size = s
for level in asks:
p = _safe_float(level.get("price"))
if p is None:
continue
s = _safe_float(level.get("size"))
if best_ask_price is None or p < best_ask_price:
best_ask_price = p
best_ask_size = s
spread = None
if best_bid_price is not None and best_ask_price is not None:
spread = best_ask_price - best_bid_price
return {
"best_bid": best_bid_price,
"best_bid_size": best_bid_size,
"best_ask": best_ask_price,
"best_ask_size": best_ask_size,
"spread": spread,
"last_trade_price": _safe_float(orderbook.get("last_trade_price")),
}
def fetch_order_books(
token_ids: List[str],
proxy: Optional[str] = None,
timeout: int = 12,
force_refresh: bool = False,
) -> Dict[str, Dict[str, Any]]:
"""Fetch order books for token IDs (prefer POST /books, fallback GET /book)."""
now_ts = time.time()
session = _build_session(proxy)
# Deduplicate while keeping order
seen = set()
normalized: List[str] = []
for token_id in token_ids:
tid = str(token_id or "").strip()
if not tid or tid in seen:
continue
seen.add(tid)
normalized.append(tid)
books: Dict[str, Dict[str, Any]] = {}
to_fetch: List[str] = []
for tid in normalized:
cached_ok = (
(not force_refresh)
and (tid in _book_cache)
and (now_ts - _book_cache_ts.get(tid, 0) < CACHE_TTL_BOOKS)
)
if cached_ok:
books[tid] = _book_cache[tid]
else:
to_fetch.append(tid)
if to_fetch:
try:
payload = [{"token_id": tid} for tid in to_fetch]
resp = session.post(
f"{CLOB_API}/books",
json=payload,
timeout=timeout,
headers={"Accept": "application/json"},
)
resp.raise_for_status()
rows = resp.json() or []
for row in rows:
tid = str(row.get("asset_id") or row.get("token_id") or "").strip()
if not tid:
continue
books[tid] = row
_book_cache[tid] = row
_book_cache_ts[tid] = now_ts
except Exception as exc:
logger.warning(f"Polymarket POST /books failed, fallback to /book: {exc}")
# Fallback for missing tokens
for tid in to_fetch:
if tid in books:
continue
try:
resp = session.get(
f"{CLOB_API}/book",
params={"token_id": tid},
timeout=timeout,
headers={"Accept": "application/json"},
)
resp.raise_for_status()
row = resp.json()
books[tid] = row
_book_cache[tid] = row
_book_cache_ts[tid] = now_ts
except Exception as exc:
logger.debug(f"Polymarket GET /book failed token={tid}: {exc}")
return books
def _detect_anomaly_flags(
token_id: str,
best_bid: Optional[float],
best_ask: Optional[float],
spread: Optional[float],
last_trade_price: Optional[float],
best_bid_size: Optional[float],
best_ask_size: Optional[float],
now_ts: float,
) -> List[str]:
flags: List[str] = []
if best_bid is None or best_ask is None:
flags.append("one_sided_orderbook")
if spread is not None and spread >= 0.08:
flags.append("wide_spread")
if (best_bid_size is not None and best_bid_size < 25) or (
best_ask_size is not None and best_ask_size < 25
):
flags.append("thin_liquidity")
prev = _prev_snapshots.get(token_id)
if prev:
prev_bid = _safe_float(prev.get("best_bid"))
prev_ask = _safe_float(prev.get("best_ask"))
prev_trade = _safe_float(prev.get("last_trade_price"))
prev_spread = _safe_float(prev.get("spread"))
if (
best_bid is not None
and prev_bid is not None
and abs(best_bid - prev_bid) >= 0.06
):
flags.append("bid_price_jump")
if (
best_ask is not None
and prev_ask is not None
and abs(best_ask - prev_ask) >= 0.06
):
flags.append("ask_price_jump")
if (
last_trade_price is not None
and prev_trade is not None
and abs(last_trade_price - prev_trade) >= 0.06
):
flags.append("last_trade_jump")
if (
spread is not None
and prev_spread is not None
and spread - prev_spread >= 0.05
):
flags.append("spread_widening")
_prev_snapshots[token_id] = {
"ts": now_ts,
"best_bid": best_bid,
"best_ask": best_ask,
"spread": spread,
"last_trade_price": last_trade_price,
}
return flags
def build_city_market_snapshot(
city: str,
target_date: Optional[str] = None,
proxy: Optional[str] = None,
timeout: int = 15,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""
Build city/date market snapshot with buy/sell prices and anomaly flags.
buy_price = best ask (what you pay to buy)
sell_price = best bid (what you receive when selling)
"""
now_ts = time.time()
_cleanup_old_snapshots(now_ts)
markets = get_city_markets(
city=city,
target_date=target_date,
proxy=proxy,
timeout=timeout,
force_refresh=force_refresh,
)
token_ids: List[str] = []
for market in markets:
for outcome in market.get("outcomes", []):
tid = outcome.get("token_id")
if tid:
token_ids.append(str(tid))
books_by_token = fetch_order_books(
token_ids,
proxy=proxy,
timeout=timeout,
force_refresh=force_refresh,
)
snapshot_markets: List[Dict[str, Any]] = []
alerts: List[Dict[str, Any]] = []
for market in markets:
market_outcomes: List[Dict[str, Any]] = []
market_alerts: List[Dict[str, Any]] = []
for outcome in market.get("outcomes", []):
token_id = outcome.get("token_id")
orderbook = books_by_token.get(str(token_id), {}) if token_id else {}
top = _extract_best_prices(orderbook)
buy_price = top["best_ask"]
sell_price = top["best_bid"]
spread = top["spread"]
last_trade_price = top["last_trade_price"]
flags = _detect_anomaly_flags(
token_id=str(token_id or ""),
best_bid=top["best_bid"],
best_ask=top["best_ask"],
spread=spread,
last_trade_price=last_trade_price,
best_bid_size=top["best_bid_size"],
best_ask_size=top["best_ask_size"],
now_ts=now_ts,
) if token_id else []
row = {
"name": outcome.get("name"),
"token_id": token_id,
"last_price": outcome.get("last_price"),
"buy_price": buy_price,
"sell_price": sell_price,
"buy_size": top["best_ask_size"],
"sell_size": top["best_bid_size"],
"spread": spread,
"last_trade_price": last_trade_price,
"book_timestamp": orderbook.get("timestamp"),
"anomaly_flags": flags,
}
market_outcomes.append(row)
if flags:
market_alert = {
"market_id": market.get("id"),
"question": market.get("question"),
"outcome": outcome.get("name"),
"token_id": token_id,
"flags": flags,
"buy_price": buy_price,
"sell_price": sell_price,
"spread": spread,
"last_trade_price": last_trade_price,
}
market_alerts.append(market_alert)
alerts.append(market_alert)
snapshot_markets.append(
{
"id": market.get("id"),
"question": market.get("question"),
"city": market.get("city"),
"date": market.get("date"),
"threshold": market.get("threshold"),
"threshold_unit": market.get("threshold_unit"),
"contract_type": market.get("contract_type"),
"slug": market.get("slug"),
"url": market.get("url"),
"volume": market.get("volume"),
"liquidity": market.get("liquidity"),
"outcomes": market_outcomes,
"market_alerts": market_alerts,
}
)
return {
"city": city,
"target_date": target_date,
"updated_at": datetime.utcnow().isoformat() + "Z",
"summary": {
"market_count": len(snapshot_markets),
"outcome_count": sum(len(m.get("outcomes", [])) for m in snapshot_markets),
"alert_count": len(alerts),
},
"markets": snapshot_markets,
"alerts": alerts,
}
def compute_divergence(
city_markets: List[Dict[str, Any]],
prob_distribution: List[Dict[str, Any]],
temp_symbol: str = "°C",
use_fahrenheit: bool = False,
) -> List[Dict[str, Any]]:
"""Compare probability-engine output with Polymarket yes/no pricing."""
signals: List[Dict[str, Any]] = []
for mkt in city_markets:
if mkt.get("contract_type") != "exceed" or mkt.get("yes_price") is None:
continue
threshold = _safe_float(mkt.get("threshold"))
market_prob = _safe_float(mkt.get("yes_price"))
mkt_unit = mkt.get("threshold_unit", "F")
if threshold is None or market_prob is None:
continue
# Convert threshold to our unit scale
if mkt_unit == "F" and not use_fahrenheit:
threshold_v = (threshold - 32) * 5 / 9
else:
threshold_v = threshold
threshold_wu = round(threshold_v)
our_exceed_prob = 0.0
for p in prob_distribution:
if (p.get("value") or 0) >= threshold_wu:
our_exceed_prob += _safe_float(p.get("probability")) or 0.0
divergence = our_exceed_prob - market_prob
signal = "neutral"
if abs(divergence) > 0.10:
signal = "underpriced" if divergence > 0 else "overpriced"
elif abs(divergence) > 0.05:
signal = "slight_under" if divergence > 0 else "slight_over"
signals.append(
{
"question": mkt.get("question"),
"threshold": threshold,
"threshold_unit": mkt_unit,
"our_prob": round(our_exceed_prob, 3),
"market_prob": round(market_prob, 3),
"divergence": round(divergence, 3),
"signal": signal,
"volume": mkt.get("volume"),
"url": mkt.get("url"),
}
)
return signals
-7
View File
@@ -15,13 +15,6 @@ def load_config():
return val return val
config = { config = {
"polymarket": {
"api_key": get_env_or_none("POLYMARKET_API_KEY"),
"secret_key": get_env_or_none("POLYMARKET_SECRET_KEY"),
"passphrase": get_env_or_none("POLYMARKET_PASSPHRASE"),
"wallet_address": get_env_or_none("POLYMARKET_WALLET_ADDRESS"),
"proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
},
"weather": { "weather": {
"openweather_api_key": get_env_or_none("OPENWEATHER_API_KEY"), "openweather_api_key": get_env_or_none("OPENWEATHER_API_KEY"),
"wunderground_api_key": get_env_or_none("WUNDERGROUND_API_KEY"), "wunderground_api_key": get_env_or_none("WUNDERGROUND_API_KEY"),
+5 -15
View File
@@ -116,8 +116,8 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
center_deb = rules.get("ankara_center_deb_hit") or {} center_deb = rules.get("ankara_center_deb_hit") or {}
momentum = rules.get("momentum_spike") or {} momentum = rules.get("momentum_spike") or {}
breakthrough = rules.get("forecast_breakthrough") or {} breakthrough = rules.get("forecast_breakthrough") or {}
kill_zone = rules.get("kill_zone") or {}
advection = rules.get("advection") or {} advection = rules.get("advection") or {}
suppression = alert_payload.get("suppression") or {}
signature_payload = { signature_payload = {
"city": alert_payload.get("city"), "city": alert_payload.get("city"),
@@ -134,10 +134,12 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
"momentum_direction": momentum.get("direction"), "momentum_direction": momentum.get("direction"),
"momentum_slope_30m": round(float(momentum.get("slope_30m") or 0.0), 1), "momentum_slope_30m": round(float(momentum.get("slope_30m") or 0.0), 1),
"breakthrough_margin": round(float(breakthrough.get("margin") or 0.0), 1), "breakthrough_margin": round(float(breakthrough.get("margin") or 0.0), 1),
"kill_zone_strike": round(float(kill_zone.get("strike_price") or 0.0), 1),
"kill_zone_distance": round(float(kill_zone.get("distance") or 0.0), 1),
"lead_station": (advection.get("lead_station") or {}).get("name"), "lead_station": (advection.get("lead_station") or {}).get("name"),
"lead_delta": round(float(advection.get("lead_delta") or 0.0), 1), "lead_delta": round(float(advection.get("lead_delta") or 0.0), 1),
"suppressed": bool(suppression.get("suppressed")),
"suppression_reason": suppression.get("reason"),
"suppression_peak_time": suppression.get("max_temp_time"),
"suppression_rollback": round(float(suppression.get("rollback") or 0.0), 1),
} }
raw = json.dumps(signature_payload, sort_keys=True, ensure_ascii=True) raw = json.dumps(signature_payload, sort_keys=True, ensure_ascii=True)
return hashlib.sha1(raw.encode("utf-8")).hexdigest() return hashlib.sha1(raw.encode("utf-8")).hexdigest()
@@ -151,27 +153,15 @@ def build_trade_alert_for_city(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from web.app import _analyze from web.app import _analyze
from src.analysis.market_alert_engine import build_trading_alerts from src.analysis.market_alert_engine import build_trading_alerts
from src.data_collection.polymarket_client import build_city_market_snapshot
city_weather = _analyze(city, force_refresh=force_refresh) city_weather = _analyze(city, force_refresh=force_refresh)
resolved_target_date = target_date or city_weather.get("local_date") resolved_target_date = target_date or city_weather.get("local_date")
if resolved_target_date: if resolved_target_date:
datetime.strptime(resolved_target_date, "%Y-%m-%d") datetime.strptime(resolved_target_date, "%Y-%m-%d")
proxy = (
(config.get("polymarket", {}) or {}).get("proxy")
or (config.get("app", {}) or {}).get("proxy")
)
market_snapshot = build_city_market_snapshot(
city=city,
target_date=resolved_target_date,
proxy=proxy,
force_refresh=force_refresh,
)
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/" map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
alert_payload = build_trading_alerts( alert_payload = build_trading_alerts(
city_weather=city_weather, city_weather=city_weather,
market_snapshot=market_snapshot,
map_url=map_url, map_url=map_url,
) )
alert_payload["target_date"] = resolved_target_date alert_payload["target_date"] = resolved_target_date
+44 -28
View File
@@ -51,46 +51,20 @@ def _sample_weather_payload():
} }
def _sample_market_snapshot():
return {
"city": "ankara",
"target_date": "2026-03-07",
"markets": [
{
"id": "m1",
"question": "Will temperature in Ankara exceed 11.5°C on March 7?",
"threshold": 11.5,
"threshold_unit": "C",
"contract_type": "exceed",
"outcomes": [
{"name": "Yes", "buy_price": 0.73, "last_price": 0.72},
{"name": "No", "buy_price": 0.27, "last_price": 0.28},
],
}
],
}
def test_trading_alerts_all_core_rules_trigger(): def test_trading_alerts_all_core_rules_trigger():
out = build_trading_alerts( out = build_trading_alerts(
city_weather=_sample_weather_payload(), city_weather=_sample_weather_payload(),
market_snapshot=_sample_market_snapshot(),
map_url="https://example.com/map", map_url="https://example.com/map",
) )
assert out["trigger_count"] >= 3 assert out["trigger_count"] >= 3
assert out["rules"]["momentum_spike"]["triggered"] is True assert out["rules"]["momentum_spike"]["triggered"] is True
assert out["rules"]["forecast_breakthrough"]["triggered"] is True assert out["rules"]["forecast_breakthrough"]["triggered"] is True
assert out["rules"]["kill_zone"]["triggered"] is True
assert out["rules"]["advection"]["triggered"] is True assert out["rules"]["advection"]["triggered"] is True
msg = out["telegram"]["zh"] msg = out["telegram"]["zh"]
assert "PolyWeather 异动预警" in msg assert "PolyWeather 异动预警" in msg
assert "动量突变" in msg assert "动量突变" in msg
assert "盘口:" in msg
assert "Yes 买 73c / 卖 -" in msg
assert "No 买 27c / 卖 -" in msg
assert "No\" 单需谨慎" in msg
assert "https://example.com/map" in msg assert "https://example.com/map" in msg
@@ -100,7 +74,6 @@ def test_forecast_breakthrough_not_triggered_when_current_not_above_margin():
out = build_trading_alerts( out = build_trading_alerts(
city_weather=city_weather, city_weather=city_weather,
market_snapshot=_sample_market_snapshot(),
) )
assert out["rules"]["forecast_breakthrough"]["triggered"] is False assert out["rules"]["forecast_breakthrough"]["triggered"] is False
@@ -118,7 +91,6 @@ def test_ankara_center_hits_deb_triggers_force_push():
out = build_trading_alerts( out = build_trading_alerts(
city_weather=city_weather, city_weather=city_weather,
market_snapshot={"city": "ankara", "target_date": "2026-03-07", "markets": []},
) )
center_rule = out["rules"]["ankara_center_deb_hit"] center_rule = out["rules"]["ankara_center_deb_hit"]
@@ -126,3 +98,47 @@ def test_ankara_center_hits_deb_triggers_force_push():
assert center_rule["force_push"] is True assert center_rule["force_push"] is True
assert out["severity"] in ("medium", "high") assert out["severity"] in ("medium", "high")
assert "Center信号" in out["telegram"]["zh"] assert "Center信号" in out["telegram"]["zh"]
def test_peak_passed_guard_suppresses_late_day_cooldown_alerts():
city_weather = {
"name": "wellington",
"display_name": "Wellington",
"temp_symbol": "°C",
"local_time": "16:40",
"current": {
"temp": 19.0,
"max_so_far": 20.2,
"max_temp_time": "15:20",
"wind_dir": 220.0,
"wind_speed_kt": 8.0,
},
"trend": {
"recent": [
{"time": "16:40", "temp": 19.0},
{"time": "16:10", "temp": 20.0},
{"time": "15:40", "temp": 20.5},
]
},
"multi_model": {
"MGM": 18.2,
"GFS": 18.4,
"ECMWF": 18.5,
},
"deb": {"prediction": 18.7},
"metar_recent_obs": [
{"time": "16:40", "wdir": 220},
{"time": "16:10", "wdir": 210},
],
"mgm_nearby": [],
}
out = build_trading_alerts(city_weather=city_weather)
assert out["suppression"]["suppressed"] is True
assert out["severity"] == "none"
assert out["trigger_count"] == 0
assert out["rules"]["momentum_spike"]["raw_triggered"] is True
assert out["rules"]["forecast_breakthrough"]["raw_triggered"] is True
assert "高温已过(暂停推送)" in out["telegram"]["zh"]
assert "暂停主动推送" in out["telegram"]["zh"]
-90
View File
@@ -1,90 +0,0 @@
from src.data_collection import polymarket_client as pm
def test_extract_best_prices():
book = {
"bids": [{"price": "0.41", "size": "100"}, {"price": "0.39", "size": "80"}],
"asks": [{"price": "0.45", "size": "90"}, {"price": "0.47", "size": "70"}],
"last_trade_price": "0.44",
}
out = pm._extract_best_prices(book)
assert out["best_bid"] == 0.41
assert out["best_ask"] == 0.45
assert out["spread"] == 0.04
assert out["last_trade_price"] == 0.44
def test_build_city_market_snapshot_buy_sell_and_alerts(monkeypatch):
pm._prev_snapshots.clear()
markets = [
{
"id": "m1",
"question": "Highest temperature in Ankara on March 7?",
"city": "ankara",
"date": "2026-03-07",
"slug": "m1",
"url": "https://polymarket.com/event/m1",
"volume": 1000.0,
"liquidity": 500.0,
"outcomes": [
{"name": "6-7°C", "token_id": "t1", "last_price": 0.32},
{"name": "8-9°C", "token_id": "t2", "last_price": 0.40},
],
}
]
books = {
"t1": {
"bids": [{"price": "0.30", "size": "50"}],
"asks": [{"price": "0.36", "size": "55"}],
"last_trade_price": "0.34",
"timestamp": "2026-03-06T10:00:00Z",
},
# one-sided book + thin liquidity to trigger anomaly
"t2": {
"bids": [],
"asks": [{"price": "0.52", "size": "10"}],
"last_trade_price": "0.51",
"timestamp": "2026-03-06T10:00:00Z",
},
}
def fake_get_city_markets(**kwargs):
return markets
def fake_fetch_order_books(*args, **kwargs):
return books
monkeypatch.setattr(pm, "get_city_markets", fake_get_city_markets)
monkeypatch.setattr(pm, "fetch_order_books", fake_fetch_order_books)
# Seed previous snapshot for token t1, so we can detect a price jump alert
pm._prev_snapshots["t1"] = {
"ts": 1.0,
"best_bid": 0.20,
"best_ask": 0.24,
"spread": 0.04,
"last_trade_price": 0.22,
}
snap = pm.build_city_market_snapshot(city="ankara", target_date="2026-03-07")
assert snap["city"] == "ankara"
assert snap["target_date"] == "2026-03-07"
assert snap["summary"]["market_count"] == 1
assert snap["summary"]["outcome_count"] == 2
first_market = snap["markets"][0]
row_t1 = next(x for x in first_market["outcomes"] if x["token_id"] == "t1")
row_t2 = next(x for x in first_market["outcomes"] if x["token_id"] == "t2")
# Buy uses ask, sell uses bid
assert row_t1["buy_price"] == 0.36
assert row_t1["sell_price"] == 0.30
assert round(row_t1["spread"], 2) == 0.06
# one-sided orderbook has no sell price
assert row_t2["buy_price"] == 0.52
assert row_t2["sell_price"] is None
assert "one_sided_orderbook" in row_t2["anomaly_flags"]
-121
View File
@@ -588,127 +588,6 @@ def _normalize_city_or_404(name: str) -> str:
return city return city
def _resolve_target_date(city: str, target_date: Optional[str]) -> str:
"""
Resolve requested market date. If absent, default to current local date of city.
"""
if target_date:
try:
datetime.strptime(target_date, "%Y-%m-%d")
except Exception:
raise HTTPException(400, detail="target_date must be YYYY-MM-DD")
return target_date
tz_seconds = CITIES.get(city, {}).get("tz", 0)
return (datetime.now(timezone.utc) + timedelta(seconds=tz_seconds)).strftime(
"%Y-%m-%d"
)
@app.get("/api/polymarket/{name}")
async def city_polymarket_snapshot(
name: str,
target_date: Optional[str] = None,
force_refresh: bool = False,
):
"""
Return Polymarket city/date market snapshot with buy/sell prices and spreads.
"""
city = _normalize_city_or_404(name)
resolved_date = _resolve_target_date(city, target_date)
from src.data_collection.polymarket_client import build_city_market_snapshot
proxy = (
(_config.get("polymarket", {}) or {}).get("proxy")
or (_config.get("app", {}) or {}).get("proxy")
)
snapshot = build_city_market_snapshot(
city=city,
target_date=resolved_date,
proxy=proxy,
force_refresh=force_refresh,
)
return snapshot
@app.get("/api/polymarket/{name}/alerts")
async def city_polymarket_alerts(
name: str,
target_date: Optional[str] = None,
force_refresh: bool = False,
):
"""
Return orderbook anomalies plus strategy-focused trading alerts.
"""
city = _normalize_city_or_404(name)
resolved_date = _resolve_target_date(city, target_date)
from src.data_collection.polymarket_client import build_city_market_snapshot
from src.analysis.market_alert_engine import build_trading_alerts
proxy = (
(_config.get("polymarket", {}) or {}).get("proxy")
or (_config.get("app", {}) or {}).get("proxy")
)
snapshot = build_city_market_snapshot(
city=city,
target_date=resolved_date,
proxy=proxy,
force_refresh=force_refresh,
)
city_weather = _analyze(city, force_refresh=force_refresh)
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
trade_alerts = build_trading_alerts(
city_weather=city_weather,
market_snapshot=snapshot,
map_url=map_url,
)
return {
"city": snapshot.get("city"),
"target_date": snapshot.get("target_date"),
"updated_at": snapshot.get("updated_at"),
"summary": snapshot.get("summary"),
"alerts": snapshot.get("alerts", []),
"trade_alerts": trade_alerts,
}
@app.get("/api/polymarket/{name}/trade-alerts")
async def city_trade_alerts(
name: str,
target_date: Optional[str] = None,
force_refresh: bool = False,
):
"""
Return trading alerts and Telegram-ready notification payload.
"""
city = _normalize_city_or_404(name)
resolved_date = _resolve_target_date(city, target_date)
from src.data_collection.polymarket_client import build_city_market_snapshot
from src.analysis.market_alert_engine import build_trading_alerts
proxy = (
(_config.get("polymarket", {}) or {}).get("proxy")
or (_config.get("app", {}) or {}).get("proxy")
)
snapshot = build_city_market_snapshot(
city=city,
target_date=resolved_date,
proxy=proxy,
force_refresh=force_refresh,
)
city_weather = _analyze(city, force_refresh=force_refresh)
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
return build_trading_alerts(
city_weather=city_weather,
market_snapshot=snapshot,
map_url=map_url,
)
@app.get("/api/history/{name}") @app.get("/api/history/{name}")
async def city_history(name: str): async def city_history(name: str):
"""Return historical accuracy data (DEB, mu, actuals) for a city.""" """Return historical accuracy data (DEB, mu, actuals) for a city."""
-231
View File
@@ -255,85 +255,6 @@ async function fetchCityDetail(cityName, force = false) {
return await res.json(); return await res.json();
} }
async function fetchCityMarket(cityName, targetDate, force = false) {
const urlName = cityName.replace(/\s/g, "-");
const params = new URLSearchParams({
force_refresh: String(force),
});
if (targetDate) {
params.set("target_date", targetDate);
}
const res = await fetch(`/api/polymarket/${encodeURIComponent(urlName)}?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
}
function hasMarketSnapshot(data, targetDate) {
return Boolean(
data?.polymarket &&
data.polymarket.target_date === targetDate &&
!data.polymarket.loading &&
!data.polymarket.fetch_error &&
Array.isArray(data.polymarket.markets),
);
}
async function hydrateCityMarketData(data, force = false) {
if (!data?.name) return null;
const targetDate = data.local_date || null;
if (!force && data.polymarket?.loading && data.polymarket.target_date === targetDate) {
return data.polymarket;
}
if (!force && hasMarketSnapshot(data, targetDate)) {
return data.polymarket;
}
const existingMarkets = Array.isArray(data.polymarket?.markets)
? data.polymarket.markets
: [];
data.polymarket = {
...(data.polymarket || {}),
target_date: targetDate,
loading: true,
fetch_error: null,
markets: existingMarkets,
};
if (selectedCity === data.name) {
renderMarketPrices(data);
}
try {
const snapshot = await fetchCityMarket(data.name, targetDate, force);
data.polymarket = {
...snapshot,
loading: false,
fetch_error: null,
};
} catch (e) {
console.error(`Failed to load market for ${data.name}:`, e);
data.polymarket = {
...(data.polymarket || {}),
target_date: targetDate,
loading: false,
fetch_error: e.message || "Unknown error",
markets: existingMarkets,
};
}
cityDataCache[data.name] = data;
saveCache();
if (selectedCity === data.name) {
renderMarketPrices(data);
}
return data.polymarket;
}
// ────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────
// Nearby Map Stations Rendering // Nearby Map Stations Rendering
// ────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────
@@ -426,7 +347,6 @@ async function loadCityDetail(cityName, force = false) {
const cachedData = cityDataCache[cityName]; const cachedData = cityDataCache[cityName];
renderPanel(cachedData); renderPanel(cachedData);
renderNearbyStations(cachedData); renderNearbyStations(cachedData);
hydrateCityMarketData(cachedData, false);
return; return;
} }
@@ -437,7 +357,6 @@ async function loadCityDetail(cityName, force = false) {
cityDataCache[cityName] = data; cityDataCache[cityName] = data;
saveCache(); saveCache();
renderPanel(data); renderPanel(data);
hydrateCityMarketData(data, force);
// Render nearby stations and zoom camera (cinematic or bounds) // Render nearby stations and zoom camera (cinematic or bounds)
renderNearbyStations(data); renderNearbyStations(data);
@@ -494,8 +413,6 @@ function renderPanel(data) {
renderChart(data); renderChart(data);
// Probabilities // Probabilities
renderProbabilities(data); renderProbabilities(data);
// Market prices
renderMarketPrices(data);
// Multi-model & Forecast synchronization // Multi-model & Forecast synchronization
if (!selectedForecastDate) { if (!selectedForecastDate) {
selectedForecastDate = data.local_date; selectedForecastDate = data.local_date;
@@ -1008,154 +925,6 @@ function formatCents(price) {
return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`; return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
} }
function formatCompactUsd(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
if (n >= 1000) {
const compact = n >= 10000 ? (n / 1000).toFixed(0) : (n / 1000).toFixed(1);
return `$${compact}k`;
}
return `$${Math.round(n)}`;
}
function formatMarketThreshold(market, fallbackUnit) {
const threshold = Number(market?.threshold);
if (!Number.isFinite(threshold)) {
return market?.question || "Market";
}
const isInteger = Math.abs(threshold - Math.round(threshold)) < 0.001;
const value = isInteger ? threshold.toFixed(0) : threshold.toFixed(1);
const unitRaw = String(market?.threshold_unit || fallbackUnit || "C").toUpperCase();
const unit = unitRaw === "F" ? "°F" : "°C";
if (market?.contract_type === "exceed") {
return `${value}${unit}+`;
}
return `${value}${unit}`;
}
function findOutcome(market, outcomeName) {
const target = String(outcomeName || "").toLowerCase();
return (market?.outcomes || []).find(
(outcome) => String(outcome?.name || "").toLowerCase() === target,
);
}
function renderMarketPrices(data) {
const summary = document.getElementById("marketSummary");
const container = document.getElementById("marketBook");
const snapshot = data.polymarket || {};
const markets = Array.isArray(snapshot.markets) ? [...snapshot.markets] : [];
const targetDate = snapshot.target_date || data.local_date || "--";
if (snapshot.loading && markets.length === 0) {
summary.innerHTML =
'<span class="market-muted">Loading current Polymarket markets...</span>';
container.innerHTML = "";
return;
}
if (snapshot.fetch_error && markets.length === 0) {
summary.innerHTML = `<span class="market-error">Market load failed: ${escapeHtml(snapshot.fetch_error)}</span>`;
container.innerHTML = "";
return;
}
if (markets.length === 0) {
summary.innerHTML =
'<span class="market-muted">No Polymarket markets for this date</span>';
container.innerHTML = "";
return;
}
markets.sort((a, b) => {
const left = Number(a?.threshold);
const right = Number(b?.threshold);
const safeLeft = Number.isFinite(left) ? left : Number.MAX_SAFE_INTEGER;
const safeRight = Number.isFinite(right) ? right : Number.MAX_SAFE_INTEGER;
return safeLeft - safeRight;
});
const primaryUrl = markets.find((market) => market?.url)?.url;
const updatedAtTs = snapshot.updated_at ? Date.parse(snapshot.updated_at) : NaN;
const updatedAt = Number.isFinite(updatedAtTs)
? new Date(updatedAtTs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: null;
summary.innerHTML = `
<div class="market-summary-main">
<span>${escapeHtml(targetDate)} - ${markets.length} markets</span>
<span>Buy = best ask</span>
<span>Sell = best bid</span>
${updatedAt ? `<span>Updated ${escapeHtml(updatedAt)}</span>` : ""}
</div>
${primaryUrl ? `<a href="${escapeHtml(primaryUrl)}" target="_blank" rel="noreferrer">Open Polymarket</a>` : ""}
`;
container.innerHTML = markets
.map((market) => {
const yes = findOutcome(market, "yes") || {};
const no = findOutcome(market, "no") || {};
const label = formatMarketThreshold(
market,
String(data.temp_symbol || "").includes("F") ? "F" : "C",
);
const spread = Number(yes.spread);
const meta = [
`Volume ${formatCompactUsd(market.volume)}`,
`Liquidity ${formatCompactUsd(market.liquidity)}`,
Number.isFinite(spread) ? `Yes spread ${formatCents(spread)}` : null,
].filter(Boolean);
return `
<div class="market-row">
<div class="market-contract">
<div class="market-threshold">${escapeHtml(label)}</div>
<div class="market-contract-meta">${meta.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}</div>
<div class="market-question">${escapeHtml(market.question || "")}</div>
</div>
<div class="market-side yes">
<div class="market-side-header">
<span class="market-side-label yes">YES</span>
<span class="market-last">Last ${formatCents(yes.last_trade_price)}</span>
</div>
<div class="market-side-prices">
<div class="market-price-chip">
<div class="market-price-label">Buy</div>
<div class="market-price-value">${formatCents(yes.buy_price)}</div>
</div>
<div class="market-price-chip">
<div class="market-price-label">Sell</div>
<div class="market-price-value">${formatCents(yes.sell_price)}</div>
</div>
</div>
</div>
<div class="market-side no">
<div class="market-side-header">
<span class="market-side-label no">NO</span>
<span class="market-last">Last ${formatCents(no.last_trade_price)}</span>
</div>
<div class="market-side-prices">
<div class="market-price-chip">
<div class="market-price-label">Buy</div>
<div class="market-price-value">${formatCents(no.buy_price)}</div>
</div>
<div class="market-price-chip">
<div class="market-price-label">Sell</div>
<div class="market-price-value">${formatCents(no.sell_price)}</div>
</div>
</div>
</div>
</div>
`;
})
.join("");
}
function renderModels(data) { function renderModels(data) {
const container = document.getElementById("modelBars"); const container = document.getElementById("modelBars");
const targetDate = selectedForecastDate || data.local_date; const targetDate = selectedForecastDate || data.local_date;
-8
View File
@@ -108,14 +108,6 @@
</div> </div>
</section> </section>
<section class="market-section">
<h3>Polymarket Prices</h3>
<div id="marketSummary" class="market-summary"></div>
<div id="marketBook" class="market-book">
<!-- Dynamically populated -->
</div>
</section>
<!-- ── Multi-Model Comparison ── --> <!-- ── Multi-Model Comparison ── -->
<section class="models-section"> <section class="models-section">
<h3>🔬 多模型预报</h3> <h3>🔬 多模型预报</h3>
-152
View File
@@ -668,155 +668,6 @@ body {
background: rgba(99, 102, 241, 0.15); background: rgba(99, 102, 241, 0.15);
} }
/* ── Market Section ── */
.market-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
font-size: 11px;
color: var(--text-muted);
}
.market-summary-main {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.market-summary a {
color: var(--accent-cyan);
text-decoration: none;
font-weight: 600;
}
.market-summary a:hover {
text-decoration: underline;
}
.market-book {
display: flex;
flex-direction: column;
gap: 10px;
}
.market-row {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.025);
}
.market-contract {
min-width: 0;
}
.market-threshold {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.market-contract-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 11px;
color: var(--text-muted);
}
.market-question {
margin-top: 8px;
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
word-break: break-word;
}
.market-side {
border-radius: 10px;
padding: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
}
.market-side.yes {
border-color: rgba(34, 197, 94, 0.18);
background: rgba(34, 197, 94, 0.05);
}
.market-side.no {
border-color: rgba(248, 113, 113, 0.18);
background: rgba(248, 113, 113, 0.05);
}
.market-side-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.market-side-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
}
.market-side-label.yes {
color: #4ade80;
}
.market-side-label.no {
color: #fda4af;
}
.market-last {
font-size: 10px;
color: var(--text-muted);
}
.market-side-prices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.market-price-chip {
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: rgba(15, 23, 42, 0.45);
padding: 8px;
}
.market-price-label {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 2px;
}
.market-price-value {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.market-muted {
color: var(--text-muted);
}
.market-error {
color: var(--accent-red);
}
/* ── Model Bars ── */ /* ── Model Bars ── */
.model-bars { .model-bars {
display: flex; display: flex;
@@ -1237,9 +1088,6 @@ body {
:root { :root {
--panel-width: 100%; --panel-width: 100%;
} }
.market-row {
grid-template-columns: 1fr;
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {