diff --git a/bot_listener.py b/bot_listener.py index 8057bb05..d6854b73 100644 --- a/bot_listener.py +++ b/bot_listener.py @@ -10,9 +10,8 @@ if project_root not in sys.path: sys.path.insert(0, project_root) 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.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.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402 @@ -42,7 +41,6 @@ def start_bot(): "可用指令:\n" "/city [城市名] - 查询城市天气预测与实测\n" "/deb [城市名] - 查看 DEB 融合预测准确率\n" - "/tradealert [城市名] - 预览当前异动预警推送\n" "/id - 获取当前聊天的 Chat ID\n\n" "示例: /city 伦敦" ) @@ -238,105 +236,6 @@ def start_bot(): except Exception as 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, - "用法: /tradealert ankara/tradealert ankara 2026-03-06", - 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, - "用法: /tradealert ankara/tradealert ankara 2026-03-06", - 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"❌ 未找到城市: {city_input}", - 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"]) def get_city_info(message): """查询指定城市的天气详情""" diff --git a/frontend/public/static/app.js b/frontend/public/static/app.js index 5905496b..66e711cd 100644 --- a/frontend/public/static/app.js +++ b/frontend/public/static/app.js @@ -255,6 +255,85 @@ 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 // ────────────────────────────────────────────────────────── @@ -344,8 +423,10 @@ async function loadCityDetail(cityName, force = false) { setSelectedMarker(cityName); if (!force && cityDataCache[cityName]) { - renderPanel(cityDataCache[cityName]); - renderNearbyStations(cityDataCache[cityName]); + const cachedData = cityDataCache[cityName]; + renderPanel(cachedData); + renderNearbyStations(cachedData); + hydrateCityMarketData(cachedData, false); return; } @@ -356,6 +437,7 @@ 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); @@ -412,6 +494,8 @@ function renderPanel(data) { renderChart(data); // Probabilities renderProbabilities(data); + // Market prices + renderMarketPrices(data); // Multi-model & Forecast synchronization if (!selectedForecastDate) { 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 = + '姝e湪鍔犺浇褰撳ぉ Polymarket 鐩樺彛...'; + container.innerHTML = ""; + return; + } + + if (snapshot.fetch_error && markets.length === 0) { + summary.innerHTML = `鐩樺彛鍔犺浇澶辫触: ${escapeHtml(snapshot.fetch_error)}`; + container.innerHTML = ""; + return; + } + + if (markets.length === 0) { + summary.innerHTML = + '褰撳ぉ鏆傛棤鍙敤鐨?Polymarket 甯傚満浠锋牸'; + 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 = ` +
+ ${escapeHtml(targetDate)} · ${markets.length} 涓悎绾?/span> + 涔板叆 = best ask + 鍗栧嚭 = best bid + ${updatedAt ? `鏇存柊 ${escapeHtml(updatedAt)}` : ""} +
+ ${primaryUrl ? `鎵撳紑 Polymarket` : ""} + `; + + 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 ` +
+
+
${escapeHtml(label)}
+
${meta.map((item) => `${escapeHtml(item)}`).join("")}
+
${escapeHtml(market.question || "")}
+
+
+
+ YES + Last ${formatCents(yes.last_trade_price)} +
+
+
+
涔板叆
+
${formatCents(yes.buy_price)}
+
+
+
鍗栧嚭
+
${formatCents(yes.sell_price)}
+
+
+
+
+
+ NO + Last ${formatCents(no.last_trade_price)} +
+
+
+
涔板叆
+
${formatCents(no.buy_price)}
+
+
+
鍗栧嚭
+
${formatCents(no.sell_price)}
+
+
+
+
+ `; + }) + .join(""); +} + function renderModels(data) { const container = document.getElementById("modelBars"); const targetDate = selectedForecastDate || data.local_date; diff --git a/frontend/public/static/style.css b/frontend/public/static/style.css index 3cc9db25..7b98a4e0 100644 --- a/frontend/public/static/style.css +++ b/frontend/public/static/style.css @@ -668,6 +668,155 @@ 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; @@ -1088,6 +1237,9 @@ body { :root { --panel-width: 100%; } + .market-row { + grid-template-columns: 1fr; + } } @media (max-width: 600px) { diff --git a/web/static/app.js b/web/static/app.js index 5905496b..66e711cd 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -255,6 +255,85 @@ 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 // ────────────────────────────────────────────────────────── @@ -344,8 +423,10 @@ async function loadCityDetail(cityName, force = false) { setSelectedMarker(cityName); if (!force && cityDataCache[cityName]) { - renderPanel(cityDataCache[cityName]); - renderNearbyStations(cityDataCache[cityName]); + const cachedData = cityDataCache[cityName]; + renderPanel(cachedData); + renderNearbyStations(cachedData); + hydrateCityMarketData(cachedData, false); return; } @@ -356,6 +437,7 @@ 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); @@ -412,6 +494,8 @@ function renderPanel(data) { renderChart(data); // Probabilities renderProbabilities(data); + // Market prices + renderMarketPrices(data); // Multi-model & Forecast synchronization if (!selectedForecastDate) { 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 = + '姝e湪鍔犺浇褰撳ぉ Polymarket 鐩樺彛...'; + container.innerHTML = ""; + return; + } + + if (snapshot.fetch_error && markets.length === 0) { + summary.innerHTML = `鐩樺彛鍔犺浇澶辫触: ${escapeHtml(snapshot.fetch_error)}`; + container.innerHTML = ""; + return; + } + + if (markets.length === 0) { + summary.innerHTML = + '褰撳ぉ鏆傛棤鍙敤鐨?Polymarket 甯傚満浠锋牸'; + 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 = ` +
+ ${escapeHtml(targetDate)} · ${markets.length} 涓悎绾?/span> + 涔板叆 = best ask + 鍗栧嚭 = best bid + ${updatedAt ? `鏇存柊 ${escapeHtml(updatedAt)}` : ""} +
+ ${primaryUrl ? `鎵撳紑 Polymarket` : ""} + `; + + 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 ` +
+
+
${escapeHtml(label)}
+
${meta.map((item) => `${escapeHtml(item)}`).join("")}
+
${escapeHtml(market.question || "")}
+
+
+
+ YES + Last ${formatCents(yes.last_trade_price)} +
+
+
+
涔板叆
+
${formatCents(yes.buy_price)}
+
+
+
鍗栧嚭
+
${formatCents(yes.sell_price)}
+
+
+
+
+
+ NO + Last ${formatCents(no.last_trade_price)} +
+
+
+
涔板叆
+
${formatCents(no.buy_price)}
+
+
+
鍗栧嚭
+
${formatCents(no.sell_price)}
+
+
+
+
+ `; + }) + .join(""); +} + function renderModels(data) { const container = document.getElementById("modelBars"); const targetDate = selectedForecastDate || data.local_date; diff --git a/web/static/index.html b/web/static/index.html index 08b17c50..dccd11d9 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -108,6 +108,14 @@ +
+

Polymarket Prices

+
+
+ +
+
+

🔬 多模型预报

@@ -214,4 +222,4 @@ - \ No newline at end of file + diff --git a/web/static/style.css b/web/static/style.css index 3cc9db25..7b98a4e0 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -668,6 +668,155 @@ 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; @@ -1088,6 +1237,9 @@ body { :root { --panel-width: 100%; } + .market-row { + grid-template-columns: 1fr; + } } @media (max-width: 600px) {