feat: Implement initial PolyWeather web map API with comprehensive weather data collection, analysis, and Polymarket integration.
This commit is contained in:
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
Polymarket Weather Market Client
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
Fetches real-time odds from Polymarket's Gamma API for weather contracts.
|
||||||
|
Used by the web dashboard only (not the Telegram bot).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GAMMA_API = "https://gamma-api.polymarket.com"
|
||||||
|
|
||||||
|
# Map our city names → Polymarket contract keywords
|
||||||
|
CITY_KEYWORDS = {
|
||||||
|
"ankara": ["ankara", "Ankara"],
|
||||||
|
"london": ["london", "London"],
|
||||||
|
"paris": ["paris", "Paris"],
|
||||||
|
"seoul": ["seoul", "Seoul"],
|
||||||
|
"toronto": ["toronto", "Toronto"],
|
||||||
|
"buenos aires": ["buenos aires", "Buenos Aires"],
|
||||||
|
"wellington": ["wellington", "Wellington"],
|
||||||
|
"new york": ["new york", "New York", "NYC"],
|
||||||
|
"chicago": ["chicago", "Chicago"],
|
||||||
|
"dallas": ["dallas", "Dallas"],
|
||||||
|
"miami": ["miami", "Miami"],
|
||||||
|
"atlanta": ["atlanta", "Atlanta"],
|
||||||
|
"seattle": ["seattle", "Seattle"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# In-memory cache: {city: {date: data, ...}}
|
||||||
|
_market_cache: Dict[str, Any] = {}
|
||||||
|
_cache_ts: float = 0
|
||||||
|
CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_threshold_from_question(question: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Parse a Polymarket weather question to extract city, threshold, and date.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"Will the high temperature in Ankara exceed 8°C on March 5?"
|
||||||
|
"Highest temperature in London on March 4?"
|
||||||
|
"Will the high in New York City exceed 45°F on March 5, 2026?"
|
||||||
|
"""
|
||||||
|
# Pattern 1: "exceed X°F/°C"
|
||||||
|
m = re.search(
|
||||||
|
r"exceed\s+([\d.]+)\s*°\s*([FC])", question, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
value = float(m.group(1))
|
||||||
|
unit = m.group(2).upper()
|
||||||
|
return {"threshold": value, "unit": unit, "type": "exceed"}
|
||||||
|
|
||||||
|
# Pattern 2: "Highest temperature in City on Date?" (multi-outcome)
|
||||||
|
m = re.search(r"[Hh]ighest\s+temperature", question)
|
||||||
|
if m:
|
||||||
|
return {"type": "range"}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _match_city(question: str) -> Optional[str]:
|
||||||
|
"""Match a Polymarket question to one of our tracked cities."""
|
||||||
|
q_lower = question.lower()
|
||||||
|
for city, keywords in CITY_KEYWORDS.items():
|
||||||
|
for kw in keywords:
|
||||||
|
if kw.lower() in q_lower:
|
||||||
|
return city
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_from_question(question: str) -> Optional[str]:
|
||||||
|
"""Extract date from question, return as YYYY-MM-DD."""
|
||||||
|
# "on March 5, 2026" or "on March 5"
|
||||||
|
m = re.search(
|
||||||
|
r"on\s+(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", question, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
month_str, day_str, year_str = m.group(1), m.group(2), m.group(3)
|
||||||
|
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(month_str.lower())
|
||||||
|
if month:
|
||||||
|
year = int(year_str) if year_str else datetime.now().year
|
||||||
|
return f"{year}-{month:02d}-{int(day_str):02d}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_weather_markets(
|
||||||
|
proxy: Optional[str] = None, timeout: int = 15
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Fetch all active weather markets from Polymarket.
|
||||||
|
|
||||||
|
Returns a list of dicts, each representing a market with:
|
||||||
|
- question, city, date, odds, volume, etc.
|
||||||
|
"""
|
||||||
|
global _market_cache, _cache_ts
|
||||||
|
|
||||||
|
if time.time() - _cache_ts < CACHE_TTL and _market_cache:
|
||||||
|
return _market_cache.get("_all", [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = requests.Session()
|
||||||
|
if proxy:
|
||||||
|
if not proxy.startswith("http"):
|
||||||
|
proxy = f"http://{proxy}"
|
||||||
|
session.proxies = {"http": proxy, "https": proxy}
|
||||||
|
|
||||||
|
# Fetch weather-tagged events
|
||||||
|
resp = session.get(
|
||||||
|
f"{GAMMA_API}/events",
|
||||||
|
params={
|
||||||
|
"tag": "weather",
|
||||||
|
"active": "true",
|
||||||
|
"closed": "false",
|
||||||
|
"limit": 50,
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
events = resp.json()
|
||||||
|
|
||||||
|
all_markets = []
|
||||||
|
for event in events:
|
||||||
|
markets = event.get("markets", [])
|
||||||
|
event_title = event.get("title", "")
|
||||||
|
|
||||||
|
for mkt in markets:
|
||||||
|
question = mkt.get("question", event_title)
|
||||||
|
city = _match_city(question)
|
||||||
|
if not city:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_str = _parse_date_from_question(question)
|
||||||
|
parsed = _parse_threshold_from_question(question)
|
||||||
|
|
||||||
|
# Extract outcome prices
|
||||||
|
outcome_prices = mkt.get("outcomePrices", "")
|
||||||
|
outcomes = mkt.get("outcomes", "")
|
||||||
|
yes_price = None
|
||||||
|
no_price = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(outcome_prices, str) and outcome_prices:
|
||||||
|
import json
|
||||||
|
prices = json.loads(outcome_prices)
|
||||||
|
if len(prices) >= 2:
|
||||||
|
yes_price = float(prices[0])
|
||||||
|
no_price = float(prices[1])
|
||||||
|
elif isinstance(outcome_prices, list) and len(outcome_prices) >= 2:
|
||||||
|
yes_price = float(outcome_prices[0])
|
||||||
|
no_price = float(outcome_prices[1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
market_info = {
|
||||||
|
"id": mkt.get("id"),
|
||||||
|
"question": question,
|
||||||
|
"city": city,
|
||||||
|
"date": date_str,
|
||||||
|
"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, # 0.00-1.00 = market probability
|
||||||
|
"no_price": no_price,
|
||||||
|
"volume": mkt.get("volume"),
|
||||||
|
"liquidity": mkt.get("liquidityNum"),
|
||||||
|
"slug": mkt.get("slug", ""),
|
||||||
|
"url": f"https://polymarket.com/event/{event.get('slug', '')}",
|
||||||
|
}
|
||||||
|
all_markets.append(market_info)
|
||||||
|
|
||||||
|
# Organize by city
|
||||||
|
_market_cache = {"_all": all_markets}
|
||||||
|
for m in all_markets:
|
||||||
|
c = m["city"]
|
||||||
|
if c not in _market_cache:
|
||||||
|
_market_cache[c] = []
|
||||||
|
_market_cache[c].append(m)
|
||||||
|
|
||||||
|
_cache_ts = time.time()
|
||||||
|
logger.info(f"📊 Polymarket: 获取 {len(all_markets)} 个天气合约")
|
||||||
|
return all_markets
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Polymarket API 请求失败: {e}")
|
||||||
|
return _market_cache.get("_all", [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_city_markets(city: str, target_date: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get Polymarket contracts for a specific city.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
city: City name (lowercase)
|
||||||
|
target_date: Optional date filter (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of market dicts for this city, sorted by volume desc.
|
||||||
|
"""
|
||||||
|
# Ensure markets are fetched
|
||||||
|
if not _market_cache or time.time() - _cache_ts >= CACHE_TTL:
|
||||||
|
fetch_weather_markets()
|
||||||
|
|
||||||
|
markets = _market_cache.get(city, [])
|
||||||
|
if target_date:
|
||||||
|
markets = [m for m in markets if m.get("date") == target_date]
|
||||||
|
|
||||||
|
# Sort by volume (descending)
|
||||||
|
markets.sort(key=lambda m: float(m.get("volume") or 0), reverse=True)
|
||||||
|
return markets
|
||||||
|
|
||||||
|
|
||||||
|
def compute_divergence(
|
||||||
|
city_markets: List[Dict],
|
||||||
|
prob_distribution: List[Dict],
|
||||||
|
temp_symbol: str = "°C",
|
||||||
|
use_fahrenheit: bool = False,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Compare our probability engine output with Polymarket odds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
city_markets: Markets from get_city_markets()
|
||||||
|
prob_distribution: Our engine's [{value, probability}, ...]
|
||||||
|
temp_symbol: "°C" or "°F"
|
||||||
|
use_fahrenheit: Whether our data is in Fahrenheit
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of divergence signals:
|
||||||
|
[{threshold, our_prob, market_prob, divergence, signal}, ...]
|
||||||
|
"""
|
||||||
|
signals = []
|
||||||
|
|
||||||
|
for mkt in city_markets:
|
||||||
|
if mkt.get("contract_type") != "exceed" or mkt.get("yes_price") is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
threshold = mkt.get("threshold")
|
||||||
|
mkt_unit = mkt.get("threshold_unit", "F")
|
||||||
|
if threshold is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert threshold to match our unit
|
||||||
|
if mkt_unit == "F" and not use_fahrenheit:
|
||||||
|
threshold_c = (threshold - 32) * 5 / 9
|
||||||
|
elif mkt_unit == "C" and use_fahrenheit:
|
||||||
|
threshold_c = threshold # keep as-is, our data is F
|
||||||
|
else:
|
||||||
|
threshold_c = threshold
|
||||||
|
|
||||||
|
# Calculate our probability of exceeding this threshold
|
||||||
|
# Sum probabilities for all values >= threshold (rounded)
|
||||||
|
threshold_wu = round(threshold_c)
|
||||||
|
our_exceed_prob = 0.0
|
||||||
|
for p in prob_distribution:
|
||||||
|
if p.get("value", 0) >= threshold_wu:
|
||||||
|
our_exceed_prob += p.get("probability", 0)
|
||||||
|
|
||||||
|
market_prob = mkt["yes_price"]
|
||||||
|
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["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
|
||||||
+30
@@ -26,6 +26,11 @@ from src.utils.config_loader import load_config
|
|||||||
from src.data_collection.weather_sources import WeatherDataCollector
|
from src.data_collection.weather_sources import WeatherDataCollector
|
||||||
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
|
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
|
||||||
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
|
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
|
||||||
|
from src.data_collection.polymarket_client import (
|
||||||
|
fetch_weather_markets,
|
||||||
|
get_city_markets,
|
||||||
|
compute_divergence,
|
||||||
|
)
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────
|
||||||
# Setup
|
# Setup
|
||||||
@@ -453,6 +458,31 @@ def _analyze(city: str) -> Dict[str, Any]:
|
|||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── 16. Polymarket odds (web-only, non-blocking) ──
|
||||||
|
try:
|
||||||
|
proxy = _config.get("proxy")
|
||||||
|
fetch_weather_markets(proxy=proxy, timeout=10)
|
||||||
|
city_mkts = get_city_markets(city, target_date=local_date_str)
|
||||||
|
if city_mkts:
|
||||||
|
result["polymarket"] = {
|
||||||
|
"markets": [
|
||||||
|
{
|
||||||
|
"question": m["question"],
|
||||||
|
"yes_price": m["yes_price"],
|
||||||
|
"volume": m.get("volume"),
|
||||||
|
"threshold": m.get("threshold"),
|
||||||
|
"threshold_unit": m.get("threshold_unit"),
|
||||||
|
"url": m.get("url", ""),
|
||||||
|
}
|
||||||
|
for m in city_mkts[:5]
|
||||||
|
],
|
||||||
|
"divergence": compute_divergence(
|
||||||
|
city_mkts, probabilities, sym, use_fahrenheit=info.get("f", False)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Polymarket data skipped for {city}: {e}")
|
||||||
|
|
||||||
_cache[city] = {"t": _time.time(), "d": result}
|
_cache[city] = {"t": _time.time(), "d": result}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,8 @@ function renderPanel(data) {
|
|||||||
renderForecast(data);
|
renderForecast(data);
|
||||||
// AI
|
// AI
|
||||||
renderAI(data);
|
renderAI(data);
|
||||||
|
// Market Odds
|
||||||
|
renderMarket(data);
|
||||||
// Risk
|
// Risk
|
||||||
renderRisk(data);
|
renderRisk(data);
|
||||||
}
|
}
|
||||||
@@ -824,6 +826,75 @@ function renderAI(data) {
|
|||||||
container.innerHTML = text;
|
container.innerHTML = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMarket(data) {
|
||||||
|
const container = document.getElementById("marketOdds");
|
||||||
|
if (
|
||||||
|
!data.polymarket ||
|
||||||
|
!data.polymarket.markets ||
|
||||||
|
data.polymarket.markets.length === 0
|
||||||
|
) {
|
||||||
|
container.innerHTML = '<div class="market-no-data">暂无相关天气合约</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { markets, divergence } = data.polymarket;
|
||||||
|
let html = "";
|
||||||
|
|
||||||
|
markets.forEach((mkt) => {
|
||||||
|
// Find matching divergence signal
|
||||||
|
const divSignal = divergence.find((d) => d.question === mkt.question);
|
||||||
|
|
||||||
|
let probText =
|
||||||
|
mkt.yes_price != null ? `${Math.round(mkt.yes_price * 100)}¢` : "N/A";
|
||||||
|
let noText =
|
||||||
|
mkt.yes_price != null
|
||||||
|
? `${Math.round((1 - mkt.yes_price) * 100)}¢`
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="market-card">
|
||||||
|
<div class="market-question">
|
||||||
|
<a href="${mkt.url}" target="_blank" style="color:var(--text-primary);text-decoration:none;">${mkt.question} 🔗</a>
|
||||||
|
</div>
|
||||||
|
<div class="market-prices">
|
||||||
|
<span class="market-price yes">${probText}</span><span class="market-price-label">Yes</span>
|
||||||
|
<span style="margin: 0 4px; color:var(--text-muted)">|</span>
|
||||||
|
<span class="market-price no">${noText}</span><span class="market-price-label">No</span>
|
||||||
|
</div>
|
||||||
|
<div class="market-volume">
|
||||||
|
交易量: $${mkt.volume ? Math.round(mkt.volume).toLocaleString() : 0}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (divSignal) {
|
||||||
|
let divClass = divSignal.signal;
|
||||||
|
let divIcon = "⚪";
|
||||||
|
let divText = "市场定价合理";
|
||||||
|
let diffPct = (Math.abs(divSignal.divergence) * 100).toFixed(1);
|
||||||
|
|
||||||
|
if (divClass === "underpriced" || divClass === "slight_under") {
|
||||||
|
divIcon = "🟢";
|
||||||
|
divText = `低估 (偏离 +${diffPct}%)`;
|
||||||
|
} else if (divClass === "overpriced" || divClass === "slight_over") {
|
||||||
|
divIcon = "🔴";
|
||||||
|
divText = `高估 (偏离 -${diffPct}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="market-divergence ${divClass.replace("slight_", "")}">
|
||||||
|
<div><b>${divIcon} 你的模型:</b> ${Math.round(divSignal.our_prob * 100)}%</div>
|
||||||
|
<div style="margin-top:2px;"><b>🆚 市场定价:</b> ${Math.round(divSignal.market_prob * 100)}%</div>
|
||||||
|
<div style="margin-top:6px;font-weight:600;">💡 信号: ${divText}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function renderRisk(data) {
|
function renderRisk(data) {
|
||||||
const container = document.getElementById("riskInfo");
|
const container = document.getElementById("riskInfo");
|
||||||
const risk = data.risk || {};
|
const risk = data.risk || {};
|
||||||
|
|||||||
@@ -131,6 +131,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Polymarket Odds ── -->
|
||||||
|
<section class="market-section">
|
||||||
|
<h3>📈 市场赔率 <span style="font-size:11px;color:var(--text-muted);font-weight:400;">Polymarket</span></h3>
|
||||||
|
<div id="marketOdds" class="market-odds">
|
||||||
|
<div style="color:var(--text-muted);font-size:13px;">点击城市后加载...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ── Risk Profile ── -->
|
<!-- ── Risk Profile ── -->
|
||||||
<section class="risk-section">
|
<section class="risk-section">
|
||||||
<h3>⚠️ 数据偏差风险</h3>
|
<h3>⚠️ 数据偏差风险</h3>
|
||||||
|
|||||||
@@ -804,6 +804,95 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Market Odds Section ── */
|
||||||
|
.market-odds {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card:hover {
|
||||||
|
border-color: var(--border-glass);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-question {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-prices {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-price {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-price.yes {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
.market-price.no {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.market-price-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-volume {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-divergence {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-divergence.underpriced {
|
||||||
|
background: rgba(52, 211, 153, 0.08);
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-divergence.overpriced {
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-divergence.neutral {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-no-data {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Risk Section ── */
|
/* ── Risk Section ── */
|
||||||
.risk-info {
|
.risk-info {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user