feat: Implement PolyWeather web frontend with dark theme, map, and city list UI.
This commit is contained in:
+1
-102
@@ -10,9 +10,8 @@ if project_root not in sys.path:
|
|||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
from src.utils.config_loader import load_config # type: ignore # noqa: E402
|
from src.utils.config_loader import load_config # type: ignore # noqa: E402
|
||||||
from src.utils.telegram_push import start_trade_alert_push_loop, build_trade_alert_for_city # type: ignore # noqa: E402
|
from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
|
||||||
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
|
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
|
||||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY # type: ignore # noqa: E402
|
|
||||||
from src.data_collection.city_risk_profiles import get_city_risk_profile # type: ignore # noqa: E402
|
from src.data_collection.city_risk_profiles import get_city_risk_profile # type: ignore # noqa: E402
|
||||||
from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402
|
from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402
|
||||||
|
|
||||||
@@ -42,7 +41,6 @@ def start_bot():
|
|||||||
"可用指令:\n"
|
"可用指令:\n"
|
||||||
"/city [城市名] - 查询城市天气预测与实测\n"
|
"/city [城市名] - 查询城市天气预测与实测\n"
|
||||||
"/deb [城市名] - 查看 DEB 融合预测准确率\n"
|
"/deb [城市名] - 查看 DEB 融合预测准确率\n"
|
||||||
"/tradealert [城市名] - 预览当前异动预警推送\n"
|
|
||||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||||
"示例: <code>/city 伦敦</code>"
|
"示例: <code>/city 伦敦</code>"
|
||||||
)
|
)
|
||||||
@@ -238,105 +236,6 @@ def start_bot():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||||
|
|
||||||
@bot.message_handler(commands=["tradealert"])
|
|
||||||
def tradealert_preview(message):
|
|
||||||
"""Preview the current trade-alert push payload for one city."""
|
|
||||||
try:
|
|
||||||
parts = message.text.split()
|
|
||||||
if len(parts) < 2:
|
|
||||||
bot.reply_to(
|
|
||||||
message,
|
|
||||||
"用法: <code>/tradealert ankara</code> 或 <code>/tradealert ankara 2026-03-06</code>",
|
|
||||||
parse_mode="HTML",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
target_date = None
|
|
||||||
city_tokens = parts[1:]
|
|
||||||
if city_tokens:
|
|
||||||
last_token = city_tokens[-1].strip()
|
|
||||||
try:
|
|
||||||
from datetime import datetime as _dt
|
|
||||||
_dt.strptime(last_token, "%Y-%m-%d")
|
|
||||||
target_date = last_token
|
|
||||||
city_tokens = city_tokens[:-1]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
city_input = " ".join(city_tokens).strip().lower()
|
|
||||||
if not city_input:
|
|
||||||
bot.reply_to(
|
|
||||||
message,
|
|
||||||
"用法: <code>/tradealert ankara</code> 或 <code>/tradealert ankara 2026-03-06</code>",
|
|
||||||
parse_mode="HTML",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
city_name = ALIASES.get(city_input)
|
|
||||||
|
|
||||||
if not city_name and city_input in CITY_REGISTRY:
|
|
||||||
city_name = city_input
|
|
||||||
|
|
||||||
if not city_name and len(city_input) >= 2:
|
|
||||||
for alias, resolved in ALIASES.items():
|
|
||||||
if alias.startswith(city_input):
|
|
||||||
city_name = resolved
|
|
||||||
break
|
|
||||||
if not city_name:
|
|
||||||
for full_name in CITY_REGISTRY:
|
|
||||||
if full_name.startswith(city_input):
|
|
||||||
city_name = full_name
|
|
||||||
break
|
|
||||||
|
|
||||||
if not city_name:
|
|
||||||
bot.reply_to(
|
|
||||||
message,
|
|
||||||
f"❌ 未找到城市: <b>{city_input}</b>",
|
|
||||||
parse_mode="HTML",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
bot.send_message(
|
|
||||||
message.chat.id,
|
|
||||||
f"📡 正在生成 {city_name.title()} 的异动预警预览"
|
|
||||||
f"{f' ({target_date})' if target_date else ''}...",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = build_trade_alert_for_city(
|
|
||||||
city_name,
|
|
||||||
config,
|
|
||||||
force_refresh=True,
|
|
||||||
target_date=target_date,
|
|
||||||
)
|
|
||||||
|
|
||||||
preview = ((payload.get("telegram") or {}).get("zh") or "").strip()
|
|
||||||
trigger_count = int(payload.get("trigger_count") or 0)
|
|
||||||
severity = str(payload.get("severity") or "none")
|
|
||||||
resolved_date = payload.get("target_date")
|
|
||||||
header = (
|
|
||||||
f"[TEST PREVIEW] city={city_name} "
|
|
||||||
f"date={resolved_date} "
|
|
||||||
f"severity={severity} triggers={trigger_count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not preview:
|
|
||||||
bot.reply_to(message, f"{header}\n\nNo Telegram preview available.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if trigger_count <= 0:
|
|
||||||
bot.send_message(
|
|
||||||
message.chat.id,
|
|
||||||
f"{header}\n\n当前没有 active trade alert。\n\n{preview}",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
bot.send_message(message.chat.id, f"{header}\n\n{preview}")
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
logger.error(f"trade alert preview failed: {e}\n{traceback.format_exc()}")
|
|
||||||
bot.reply_to(message, f"❌ tradealert failed: {e}")
|
|
||||||
|
|
||||||
@bot.message_handler(commands=["city"])
|
@bot.message_handler(commands=["city"])
|
||||||
def get_city_info(message):
|
def get_city_info(message):
|
||||||
"""查询指定城市的天气详情"""
|
"""查询指定城市的天气详情"""
|
||||||
|
|||||||
@@ -255,6 +255,85 @@ 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
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
@@ -344,8 +423,10 @@ async function loadCityDetail(cityName, force = false) {
|
|||||||
setSelectedMarker(cityName);
|
setSelectedMarker(cityName);
|
||||||
|
|
||||||
if (!force && cityDataCache[cityName]) {
|
if (!force && cityDataCache[cityName]) {
|
||||||
renderPanel(cityDataCache[cityName]);
|
const cachedData = cityDataCache[cityName];
|
||||||
renderNearbyStations(cityDataCache[cityName]);
|
renderPanel(cachedData);
|
||||||
|
renderNearbyStations(cachedData);
|
||||||
|
hydrateCityMarketData(cachedData, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +437,7 @@ 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);
|
||||||
@@ -412,6 +494,8 @@ 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;
|
||||||
@@ -903,6 +987,175 @@ function renderProbabilities(data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "").replace(/[&<>"']/g, (ch) => {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}[ch] || ch
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCents(price) {
|
||||||
|
const n = Number(price);
|
||||||
|
if (!Number.isFinite(n)) return "--";
|
||||||
|
const cents = Math.round(n * 1000) / 10;
|
||||||
|
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">姝e湪鍔犺浇褰撳ぉ Polymarket 鐩樺彛...</span>';
|
||||||
|
container.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.fetch_error && markets.length === 0) {
|
||||||
|
summary.innerHTML = `<span class="market-error">鐩樺彛鍔犺浇澶辫触: ${escapeHtml(snapshot.fetch_error)}</span>`;
|
||||||
|
container.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markets.length === 0) {
|
||||||
|
summary.innerHTML =
|
||||||
|
'<span class="market-muted">褰撳ぉ鏆傛棤鍙敤鐨?Polymarket 甯傚満浠锋牸</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} 涓悎绾?/span>
|
||||||
|
<span>涔板叆 = best ask</span>
|
||||||
|
<span>鍗栧嚭 = best bid</span>
|
||||||
|
${updatedAt ? `<span>鏇存柊 ${escapeHtml(updatedAt)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${primaryUrl ? `<a href="${escapeHtml(primaryUrl)}" target="_blank" rel="noreferrer">鎵撳紑 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">涔板叆</div>
|
||||||
|
<div class="market-price-value">${formatCents(yes.buy_price)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="market-price-chip">
|
||||||
|
<div class="market-price-label">鍗栧嚭</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">涔板叆</div>
|
||||||
|
<div class="market-price-value">${formatCents(no.buy_price)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="market-price-chip">
|
||||||
|
<div class="market-price-label">鍗栧嚭</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;
|
||||||
|
|||||||
@@ -668,6 +668,155 @@ 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;
|
||||||
@@ -1088,6 +1237,9 @@ body {
|
|||||||
:root {
|
:root {
|
||||||
--panel-width: 100%;
|
--panel-width: 100%;
|
||||||
}
|
}
|
||||||
|
.market-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|||||||
+255
-2
@@ -255,6 +255,85 @@ 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
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
@@ -344,8 +423,10 @@ async function loadCityDetail(cityName, force = false) {
|
|||||||
setSelectedMarker(cityName);
|
setSelectedMarker(cityName);
|
||||||
|
|
||||||
if (!force && cityDataCache[cityName]) {
|
if (!force && cityDataCache[cityName]) {
|
||||||
renderPanel(cityDataCache[cityName]);
|
const cachedData = cityDataCache[cityName];
|
||||||
renderNearbyStations(cityDataCache[cityName]);
|
renderPanel(cachedData);
|
||||||
|
renderNearbyStations(cachedData);
|
||||||
|
hydrateCityMarketData(cachedData, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +437,7 @@ 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);
|
||||||
@@ -412,6 +494,8 @@ 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;
|
||||||
@@ -903,6 +987,175 @@ function renderProbabilities(data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "").replace(/[&<>"']/g, (ch) => {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}[ch] || ch
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCents(price) {
|
||||||
|
const n = Number(price);
|
||||||
|
if (!Number.isFinite(n)) return "--";
|
||||||
|
const cents = Math.round(n * 1000) / 10;
|
||||||
|
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">姝e湪鍔犺浇褰撳ぉ Polymarket 鐩樺彛...</span>';
|
||||||
|
container.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.fetch_error && markets.length === 0) {
|
||||||
|
summary.innerHTML = `<span class="market-error">鐩樺彛鍔犺浇澶辫触: ${escapeHtml(snapshot.fetch_error)}</span>`;
|
||||||
|
container.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markets.length === 0) {
|
||||||
|
summary.innerHTML =
|
||||||
|
'<span class="market-muted">褰撳ぉ鏆傛棤鍙敤鐨?Polymarket 甯傚満浠锋牸</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} 涓悎绾?/span>
|
||||||
|
<span>涔板叆 = best ask</span>
|
||||||
|
<span>鍗栧嚭 = best bid</span>
|
||||||
|
${updatedAt ? `<span>鏇存柊 ${escapeHtml(updatedAt)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${primaryUrl ? `<a href="${escapeHtml(primaryUrl)}" target="_blank" rel="noreferrer">鎵撳紑 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">涔板叆</div>
|
||||||
|
<div class="market-price-value">${formatCents(yes.buy_price)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="market-price-chip">
|
||||||
|
<div class="market-price-label">鍗栧嚭</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">涔板叆</div>
|
||||||
|
<div class="market-price-value">${formatCents(no.buy_price)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="market-price-chip">
|
||||||
|
<div class="market-price-label">鍗栧嚭</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;
|
||||||
|
|||||||
@@ -108,6 +108,14 @@
|
|||||||
</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>
|
||||||
@@ -214,4 +222,4 @@
|
|||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -668,6 +668,155 @@ 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;
|
||||||
@@ -1088,6 +1237,9 @@ body {
|
|||||||
:root {
|
:root {
|
||||||
--panel-width: 100%;
|
--panel-width: 100%;
|
||||||
}
|
}
|
||||||
|
.market-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user