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_TOKEN=your_bot_token_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">
<iframe
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"
/>
</main>
+1 -9
View File
@@ -108,14 +108,6 @@
</div>
</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 ── -->
<section class="models-section">
<h3>🔬 多模型预报</h3>
@@ -222,4 +214,4 @@
<script src="/static/app.js"></script>
</body>
</html>
</html>
-231
View File
@@ -255,85 +255,6 @@ async function fetchCityDetail(cityName, force = false) {
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
// ──────────────────────────────────────────────────────────
@@ -426,7 +347,6 @@ async function loadCityDetail(cityName, force = false) {
const cachedData = cityDataCache[cityName];
renderPanel(cachedData);
renderNearbyStations(cachedData);
hydrateCityMarketData(cachedData, false);
return;
}
@@ -437,7 +357,6 @@ async function loadCityDetail(cityName, force = false) {
cityDataCache[cityName] = data;
saveCache();
renderPanel(data);
hydrateCityMarketData(data, force);
// Render nearby stations and zoom camera (cinematic or bounds)
renderNearbyStations(data);
@@ -494,8 +413,6 @@ function renderPanel(data) {
renderChart(data);
// Probabilities
renderProbabilities(data);
// Market prices
renderMarketPrices(data);
// Multi-model & Forecast synchronization
if (!selectedForecastDate) {
selectedForecastDate = data.local_date;
@@ -1008,154 +925,6 @@ function formatCents(price) {
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) {
const container = document.getElementById("modelBars");
const targetDate = selectedForecastDate || data.local_date;
-152
View File
@@ -668,155 +668,6 @@ body {
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 {
display: flex;
@@ -1237,9 +1088,6 @@ body {
:root {
--panel-width: 100%;
}
.market-row {
grid-template-columns: 1fr;
}
}
@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
import math
import re
from datetime import datetime, timezone
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]]:
if not nearby:
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:
mapping = [
("ankara_center_deb_hit", "Center达到DEB"),
("momentum_spike", "动量突变"),
("forecast_breakthrough", "预测突破"),
("kill_zone", "临界触发"),
("advection", "暖平流"),
]
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(
rules: Dict[str, Dict[str, Any]],
temp_symbol: str,
suppression: Optional[Dict[str, Any]] = None,
) -> 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] = []
center_deb = rules.get("ankara_center_deb_hit", {})
advection = rules.get("advection", {})
momentum = rules.get("momentum_spike", {})
breakthrough = rules.get("forecast_breakthrough", {})
kill_zone = rules.get("kill_zone", {})
if center_deb.get("triggered"):
deb_prediction = _sf(center_deb.get("deb_prediction"))
@@ -589,15 +467,8 @@ def _build_advice_cn(
if breakthrough.get("triggered"):
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:
return "当前未触发高优先级异动,继续观察盘口与实测联动。"
return "当前未触发高优先级天气异动,继续观察实测与模型联动。"
return "".join(parts) + ""
@@ -605,26 +476,26 @@ def _build_telegram_messages(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
map_url: Optional[str],
suppression: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
temp_symbol = city_weather.get("temp_symbol", "°C")
city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
current_temp = _sf((city_weather.get("current") or {}).get("temp"))
center_deb = rules.get("ankara_center_deb_hit", {})
momentum = rules.get("momentum_spike", {})
kill_zone = rules.get("kill_zone", {})
advection = rules.get("advection", {})
if current_temp is None:
return {"zh": "", "en": ""}
suppressed = bool((suppression or {}).get("suppressed"))
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_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 {}
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 "")
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 = ""
if advection.get("triggered"):
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:
center_deb_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
price_line = ""
if any(
market_prices.get(key) is not None
for key in ("yes_buy", "yes_sell", "no_buy", "no_sell")
):
price_prefix = f"盘口({market_label}):" if market_label else "盘口:"
price_line = (
price_prefix
+
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'))}"
)
peak_line = ""
if 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:
peak_line = (
f"高温状态:日内高点 {max_so_far:.1f}{temp_symbol} @ {max_temp_time}"
f"当前已回落 {rollback:.1f}{temp_symbol}"
)
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/"
title_zh = "🚨 PolyWeather 异动预警" if has_active_trigger else "📍 PolyWeather 状态快照"
title_en = "🚨 PolyWeather Alert" if has_active_trigger else "📍 PolyWeather Status"
@@ -686,27 +548,25 @@ def _build_telegram_messages(
f"类型:{types_cn}",
f"动态:{dyn}",
]
if strike_line:
lines_zh.append(strike_line)
if center_deb_line:
lines_zh.append(center_deb_line)
if price_line:
lines_zh.append(price_line)
if peak_line:
lines_zh.append(peak_line)
if lead_line:
lines_zh.append(lead_line)
lines_zh.append(f"AI 建议:{advice}")
lines_zh.append(f"点击查看实时地图:{final_map}")
type_en = []
if rules.get("ankara_center_deb_hit", {}).get("triggered"):
type_en.append("Center Reached DEB")
if rules.get("momentum_spike", {}).get("triggered"):
type_en.append("Momentum Spike")
if rules.get("forecast_breakthrough", {}).get("triggered"):
type_en.append("Forecast Breakthrough")
if rules.get("kill_zone", {}).get("triggered"):
type_en.append("Kill Zone")
if rules.get("advection", {}).get("triggered"):
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 = [
f"{title_en} [{city_name}]",
@@ -714,8 +574,6 @@ def _build_telegram_messages(
f"Type: {type_en_str}",
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:
center_temp = _sf(center_station.get("temp"))
deb_prediction = _sf(center_deb.get("deb_prediction"))
@@ -723,14 +581,15 @@ def _build_telegram_messages(
lines_en.append(
f"Center signal: {center_temp:.1f}{temp_symbol} has reached DEB {deb_prediction:.1f}{temp_symbol}"
)
if price_line:
price_label_en = f"Quotes ({market_label}): " if market_label else "Quotes: "
lines_en.append(
price_label_en
+
f"Yes buy {_format_market_price(market_prices.get('yes_buy'))} / sell {_format_market_price(market_prices.get('yes_sell'))} | "
f"No buy {_format_market_price(market_prices.get('no_buy'))} / sell {_format_market_price(market_prices.get('no_sell'))}"
)
if peak_line:
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:
lines_en.append(
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"Map: {final_map}")
@@ -739,11 +598,10 @@ def _build_telegram_messages(
def build_trading_alerts(
city_weather: Dict[str, Any],
market_snapshot: Dict[str, Any],
map_url: Optional[str] = None,
) -> 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")
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),
"momentum_spike": _calc_momentum_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),
}
@@ -765,15 +622,31 @@ def build_trading_alerts(
for key, value in rules.items()
if value.get("triggered")
]
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"
suppression = _calc_peak_passed_guard(city_weather, temp_symbol)
if suppression.get("suppressed") and triggered:
suppression["raw_trigger_types"] = [alert.get("type") for alert in triggered if alert.get("type")]
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(
city_weather=city_weather,
rules=rules,
map_url=map_url,
suppression=suppression,
)
return {
@@ -783,6 +656,7 @@ def build_trading_alerts(
"severity": severity,
"trigger_count": len(triggered),
"rules": rules,
"suppression": suppression,
"triggered_alerts": triggered,
"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
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": {
"openweather_api_key": get_env_or_none("OPENWEATHER_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 {}
momentum = rules.get("momentum_spike") or {}
breakthrough = rules.get("forecast_breakthrough") or {}
kill_zone = rules.get("kill_zone") or {}
advection = rules.get("advection") or {}
suppression = alert_payload.get("suppression") or {}
signature_payload = {
"city": alert_payload.get("city"),
@@ -134,10 +134,12 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
"momentum_direction": momentum.get("direction"),
"momentum_slope_30m": round(float(momentum.get("slope_30m") 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_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)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
@@ -151,27 +153,15 @@ def build_trade_alert_for_city(
) -> Dict[str, Any]:
from web.app import _analyze
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)
resolved_target_date = target_date or city_weather.get("local_date")
if resolved_target_date:
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/"
alert_payload = build_trading_alerts(
city_weather=city_weather,
market_snapshot=market_snapshot,
map_url=map_url,
)
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():
out = build_trading_alerts(
city_weather=_sample_weather_payload(),
market_snapshot=_sample_market_snapshot(),
map_url="https://example.com/map",
)
assert out["trigger_count"] >= 3
assert out["rules"]["momentum_spike"]["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
msg = out["telegram"]["zh"]
assert "PolyWeather 异动预警" 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
@@ -100,7 +74,6 @@ def test_forecast_breakthrough_not_triggered_when_current_not_above_margin():
out = build_trading_alerts(
city_weather=city_weather,
market_snapshot=_sample_market_snapshot(),
)
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(
city_weather=city_weather,
market_snapshot={"city": "ankara", "target_date": "2026-03-07", "markets": []},
)
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 out["severity"] in ("medium", "high")
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
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}")
async def city_history(name: str):
"""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();
}
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
// ──────────────────────────────────────────────────────────
@@ -426,7 +347,6 @@ async function loadCityDetail(cityName, force = false) {
const cachedData = cityDataCache[cityName];
renderPanel(cachedData);
renderNearbyStations(cachedData);
hydrateCityMarketData(cachedData, false);
return;
}
@@ -437,7 +357,6 @@ async function loadCityDetail(cityName, force = false) {
cityDataCache[cityName] = data;
saveCache();
renderPanel(data);
hydrateCityMarketData(data, force);
// Render nearby stations and zoom camera (cinematic or bounds)
renderNearbyStations(data);
@@ -494,8 +413,6 @@ function renderPanel(data) {
renderChart(data);
// Probabilities
renderProbabilities(data);
// Market prices
renderMarketPrices(data);
// Multi-model & Forecast synchronization
if (!selectedForecastDate) {
selectedForecastDate = data.local_date;
@@ -1008,154 +925,6 @@ function formatCents(price) {
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) {
const container = document.getElementById("modelBars");
const targetDate = selectedForecastDate || data.local_date;
-8
View File
@@ -108,14 +108,6 @@
</div>
</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 ── -->
<section class="models-section">
<h3>🔬 多模型预报</h3>
-152
View File
@@ -668,155 +668,6 @@ body {
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 {
display: flex;
@@ -1237,9 +1088,6 @@ body {
:root {
--panel-width: 100%;
}
.market-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {