Files
DinQuant/backend_api_python/app/data_sources/polymarket.py
T

1225 lines
64 KiB
Python
Raw Normal View History

2026-03-01 03:42:10 +08:00
"""
Polymarket预测市场数据源
从Polymarket获取预测市场数据
"""
import time
import requests
import json
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
logger = get_logger(__name__)
class PolymarketDataSource:
"""Polymarket预测市场数据源"""
def __init__(self):
# Polymarket官方API端点(根据官方文档)
# Gamma API: 市场、事件、标签、搜索等(完全公开,无需认证)
self.gamma_api = "https://gamma-api.polymarket.com"
# Data API: 用户持仓、交易、活动等(完全公开,无需认证)
self.data_api = "https://data-api.polymarket.com"
# CLOB API: 订单簿、价格、交易操作(公开端点无需认证)
self.clob_api = "https://clob.polymarket.com"
self.cache_ttl = 300 # 5分钟缓存
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json'
})
def get_trending_markets(self, category: str = None, limit: int = 50) -> List[Dict]:
"""
获取热门预测市场
Args:
category: 类别筛选 (crypto, politics, economics, sports, all)
limit: 返回数量限制
Returns:
预测市场列表
"""
try:
# 先从数据库缓存读取
cached = self._get_cached_markets(category, limit)
if cached:
return cached
2026-04-07 22:47:07 +08:00
# 从真实API获取
2026-03-01 03:42:10 +08:00
all_markets = []
if category and category != "all":
2026-04-07 22:47:07 +08:00
# 获取所有事件,然后按类别过滤
2026-03-01 03:42:10 +08:00
markets = self._fetch_markets_from_api(category, limit * 2)
all_markets.extend(markets)
else:
2026-04-07 22:47:07 +08:00
# 获取所有事件(不指定类别,避免重复请求)
markets = self._fetch_from_gamma_api(category=None, limit=100)
all_markets.extend(markets)
2026-03-01 03:42:10 +08:00
# 去重(按market_id
seen = set()
unique_markets = []
for market in all_markets:
market_id = market.get("market_id")
if market_id and market_id not in seen:
seen.add(market_id)
unique_markets.append(market)
# 按交易量排序
unique_markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True)
# 保存到数据库缓存
if unique_markets:
self._save_markets_to_db(unique_markets)
return unique_markets[:limit]
# 如果API失败,返回空列表(不再使用示例数据)
logger.warning("Polymarket API unavailable, returning empty list")
return []
except Exception as e:
logger.error(f"Failed to get trending markets: {e}", exc_info=True)
return []
def get_market_details(self, market_id: str) -> Optional[Dict]:
"""获取单个市场详情"""
try:
# 确保market_id是字符串
market_id = str(market_id).strip()
if not market_id:
logger.warning("Empty market_id provided")
return None
# 先从数据库读取
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("""
SELECT market_id, question, category, current_probability,
volume_24h, liquidity, end_date_iso, status, outcome_tokens
FROM qd_polymarket_markets
WHERE market_id = %s
""", (market_id,))
row = cur.fetchone()
cur.close()
if row:
# RealDictCursor返回字典,使用键访问
db_market_id = str(row.get('market_id') or market_id)
# 解析outcome_tokens(可能是JSON字符串)
outcome_tokens = {}
outcome_tokens_raw = row.get('outcome_tokens')
if outcome_tokens_raw:
try:
if isinstance(outcome_tokens_raw, str):
outcome_tokens = json.loads(outcome_tokens_raw)
else:
outcome_tokens = outcome_tokens_raw if isinstance(outcome_tokens_raw, dict) else {}
except:
outcome_tokens = {}
return {
"market_id": db_market_id,
"question": row.get('question') or '',
"category": row.get('category') or 'other',
"current_probability": float(row.get('current_probability') or 0),
"volume_24h": float(row.get('volume_24h') or 0),
"liquidity": float(row.get('liquidity') or 0),
"end_date_iso": row.get('end_date_iso'),
"status": row.get('status') or 'active',
"outcome_tokens": outcome_tokens,
"polymarket_url": self._build_polymarket_url(row.get('slug'), db_market_id),
"slug": row.get('slug') if row.get('slug') and not str(row.get('slug', '')).isdigit() else None
}
except Exception as db_error:
logger.warning(f"Database query failed for market {market_id}: {db_error}")
# 继续尝试从API获取
# 如果数据库没有,从API获取
logger.info(f"Market {market_id} not in database, fetching from API")
market = self._fetch_market_from_api(market_id)
if market:
try:
self._save_markets_to_db([market])
except Exception as save_error:
logger.warning(f"Failed to save market to DB: {save_error}")
return market
logger.warning(f"Market {market_id} not found in API")
return None
except Exception as e:
logger.error(f"Failed to get market details for {market_id}: {e}", exc_info=True)
return None
def get_market_history(self, market_id: str, days: int = 30) -> List[Dict]:
"""获取市场历史价格数据"""
# 这里需要实现历史数据获取逻辑
# 暂时返回空列表
return []
def search_markets(self, keyword: str, limit: int = 20, use_cache: bool = True) -> List[Dict]:
"""
搜索相关预测市场
优先从API获取实时数据,数据库仅作为可选缓存
Args:
keyword: 搜索关键词
limit: 返回结果数量限制
use_cache: 是否使用数据库缓存(AI分析时应设为False以获取最新数据)
"""
try:
logger.info(f"Searching Polymarket markets for keyword: '{keyword}' (limit={limit}, use_cache={use_cache})")
# 如果允许使用缓存,先尝试从数据库搜索
if use_cache:
with get_db_connection() as db:
cur = db.cursor()
2026-03-01 17:20:37 +08:00
# 改进搜索:同时搜索question和slug字段,也支持market_id精确匹配
keyword_lower = keyword.lower()
is_numeric = keyword_lower.isdigit()
has_hyphens = '-' in keyword_lower
if is_numeric:
# 如果是纯数字,可能是market_id,精确匹配
cur.execute("""
SELECT market_id, question, category, current_probability,
volume_24h, liquidity, end_date_iso, status, slug
FROM qd_polymarket_markets
WHERE market_id = %s AND status = 'active'
ORDER BY volume_24h DESC
LIMIT %s
""", (keyword, limit))
elif has_hyphens:
# 如果包含连字符,可能是slug,优先匹配slug
cur.execute("""
SELECT market_id, question, category, current_probability,
volume_24h, liquidity, end_date_iso, status, slug
FROM qd_polymarket_markets
WHERE (slug ILIKE %s OR question ILIKE %s) AND status = 'active'
ORDER BY
CASE WHEN slug ILIKE %s THEN 1 ELSE 2 END,
volume_24h DESC
LIMIT %s
""", (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", limit))
else:
# 普通文本搜索
cur.execute("""
SELECT market_id, question, category, current_probability,
volume_24h, liquidity, end_date_iso, status, slug
FROM qd_polymarket_markets
WHERE (question ILIKE %s OR slug ILIKE %s) AND status = 'active'
ORDER BY volume_24h DESC
LIMIT %s
""", (f"%{keyword}%", f"%{keyword}%", limit))
2026-03-01 03:42:10 +08:00
rows = cur.fetchall()
cur.close()
if rows:
logger.info(f"Found {len(rows)} markets in database for keyword '{keyword}'")
return [{
"market_id": str(row.get('market_id') or ''),
"question": row.get('question') or '',
"category": row.get('category') or 'other',
"current_probability": float(row.get('current_probability') or 0),
"volume_24h": float(row.get('volume_24h') or 0),
"liquidity": float(row.get('liquidity') or 0),
"end_date_iso": row.get('end_date_iso'),
"status": row.get('status') or 'active',
"polymarket_url": self._build_polymarket_url(row.get('slug'), row.get('market_id') or ''),
"slug": row.get('slug') if row.get('slug') and not str(row.get('slug', '')).isdigit() else None
} for row in rows]
# 直接从Gamma API获取并过滤(AI分析时使用)
logger.info(f"Fetching from API for keyword '{keyword}' (use_cache={use_cache})...")
2026-03-01 17:20:37 +08:00
# 优化:如果关键词看起来像slug,先尝试直接查询(避免全量获取)
import re
keyword_lower = keyword.lower().strip()
is_slug_like = '-' in keyword_lower and not keyword_lower.isdigit()
if is_slug_like:
# 尝试直接通过slug查询(最高效,根据Polymarket API文档)
direct_market = self._fetch_market_by_slug(keyword_lower)
if direct_market:
logger.info(f"Found market directly by slug (no need to fetch all markets): {keyword_lower}")
return [direct_market]
# 如果直接查询失败,获取更多数据以便有足够的选择空间
# 进行多次请求以获取更多市场(每次最多100个事件,但每个事件可能包含多个市场)
all_markets = []
max_requests = 3 # 最多请求3次,获取300个事件(约4500个市场)
for page in range(max_requests):
page_markets = self._fetch_from_gamma_api(category=None, limit=100)
if not page_markets:
break
all_markets.extend(page_markets)
# 如果已经获取了足够多的市场,可以提前停止
if len(all_markets) >= 3000: # 最多获取3000个市场
break
logger.info(f"Fetched page {page + 1}/{max_requests}, total markets: {len(all_markets)}")
# 短暂延迟,避免API限流
if page < max_requests - 1:
time.sleep(0.5)
2026-03-01 03:42:10 +08:00
logger.info(f"Fetched {len(all_markets)} markets from API, filtering for keyword '{keyword}'...")
# 按关键词过滤(支持多个关键词匹配)
2026-03-01 17:20:37 +08:00
# 如果关键词看起来像slug(包含连字符),也尝试匹配slug
keyword_is_slug = '-' in keyword_lower
# 提取关键词(去除常见停用词和标点)
# 提取关键词:去除标点,保留字母数字和连字符
keyword_words = re.findall(r'\b\w+\b', keyword_lower)
# 过滤掉太短的词(少于3个字符)和常见停用词
stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'will', 'would', 'should', 'could', 'may', 'might', 'can', 'must'}
keyword_words = [w for w in keyword_words if len(w) >= 3 and w not in stop_words]
# 如果没有提取到关键词,使用原始关键词
if not keyword_words:
keyword_words = [keyword_lower]
logger.info(f"Extracted keywords: {keyword_words} from '{keyword}'")
2026-03-01 03:42:10 +08:00
filtered = []
2026-03-01 17:20:37 +08:00
scored_markets = [] # 用于存储带评分的结果
top_candidates = [] # 用于存储接近匹配的候选(用于调试)
2026-03-01 03:42:10 +08:00
for market in all_markets:
question = market.get("question", "").lower()
2026-03-01 17:20:37 +08:00
slug = (market.get("slug") or "").lower()
market_id = str(market.get("market_id") or "")
score = 0
match_reason = ""
# 1. 完全匹配(最高优先级,分数100)
2026-03-01 03:42:10 +08:00
if keyword_lower in question:
2026-03-01 17:20:37 +08:00
score = 100
match_reason = "exact_match_question"
elif keyword_lower == slug:
score = 100
match_reason = "exact_match_slug"
# 2. 如果关键词看起来像slug,检查slug字段
if score < 100 and keyword_is_slug:
if keyword_lower == slug:
score = 100
match_reason = "exact_slug_match"
elif keyword_lower in slug or slug in keyword_lower:
score = 90
match_reason = "partial_slug_match"
# 3. 如果关键词是纯数字,检查market_id
if score < 90 and keyword_lower.isdigit():
if keyword_lower == market_id:
score = 100
match_reason = "market_id_match"
# 4. 关键词匹配:检查所有关键词是否都在问题中
if score < 90 and keyword_words:
# 计算匹配的关键词数量
matched_words = sum(1 for word in keyword_words if word in question or word in slug)
if matched_words > 0:
# 匹配率
match_ratio = matched_words / len(keyword_words)
# 降低阈值:从60%降到40%,提高匹配率
if match_ratio >= 0.4:
score = int(60 + match_ratio * 30) # 60-90分
match_reason = f"keyword_match_{matched_words}/{len(keyword_words)}"
else:
# 记录接近匹配的候选(用于调试)
if matched_words >= 1 and len(top_candidates) < 5:
top_candidates.append((match_ratio, market.get('question', '')[:80], matched_words, len(keyword_words)))
# 5. 部分匹配:检查关键词的主要部分是否在问题中
if score < 60 and keyword_words:
# 如果关键词包含多个词,尝试匹配主要部分
if len(keyword_words) > 1:
# 取前3个最重要的词(通常是名词)
important_words = keyword_words[:3]
matched_important = sum(1 for word in important_words if word in question or word in slug)
# 降低要求:至少匹配1个重要词即可
if matched_important >= 1:
score = 50
match_reason = f"important_words_match_{matched_important}/{len(important_words)}"
if score >= 50: # 降低最低分数要求从60到50
scored_markets.append((score, market, match_reason))
logger.debug(f"Matched (score={score}, reason={match_reason}): {market.get('question', '')[:60]}")
2026-03-01 03:42:10 +08:00
2026-03-01 17:20:37 +08:00
# 按分数排序,取前limit个
scored_markets.sort(key=lambda x: x[0], reverse=True)
filtered = [market for score, market, reason in scored_markets[:limit]]
# 输出调试信息
if len(scored_markets) == 0 and top_candidates:
logger.warning(f"No exact matches found. Top candidates (partial matches):")
for ratio, question, matched, total in top_candidates:
logger.warning(f" - {question} (matched {matched}/{total} keywords, ratio={ratio:.2f})")
logger.info(f"Filtered {len(filtered)} markets matching keyword '{keyword}' from API (from {len(all_markets)} total markets, {len(scored_markets)} scored matches)")
if len(scored_markets) > 0:
logger.info(f"Top match: {filtered[0].get('question', '')[:80]} (score={scored_markets[0][0]})")
2026-03-01 03:42:10 +08:00
return filtered
except Exception as e:
logger.error(f"Failed to search markets: {e}", exc_info=True)
return []
def _get_cached_markets(self, category: str = None, limit: int = 50) -> Optional[List[Dict]]:
"""从数据库缓存读取市场数据"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 检查缓存是否新鲜(5分钟内)
cutoff_time = datetime.now() - timedelta(seconds=self.cache_ttl)
query = """
SELECT market_id, question, category, current_probability,
volume_24h, liquidity, end_date_iso, status, outcome_tokens
FROM qd_polymarket_markets
WHERE status = 'active' AND updated_at > %s
"""
params = [cutoff_time]
if category:
query += " AND category = %s"
params.append(category)
query += " ORDER BY volume_24h DESC LIMIT %s"
params.append(limit)
cur.execute(query, params)
rows = cur.fetchall()
cur.close()
if rows:
2026-03-01 03:59:55 +08:00
result = []
for row in rows:
market_id = str(row.get('market_id') or '')
slug = row.get('slug')
# 确保使用正确的URL构建方法
polymarket_url = self._build_polymarket_url(slug, market_id)
result.append({
"market_id": market_id,
"question": row.get('question') or '',
"category": row.get('category') or 'other',
"current_probability": float(row.get('current_probability') or 0),
"volume_24h": float(row.get('volume_24h') or 0),
"liquidity": float(row.get('liquidity') or 0),
"end_date_iso": row.get('end_date_iso'),
"status": row.get('status') or 'active',
"outcome_tokens": row.get('outcome_tokens') if row.get('outcome_tokens') else {},
"polymarket_url": polymarket_url,
"slug": slug if slug and not str(slug).isdigit() else None
})
return result
2026-03-01 03:42:10 +08:00
return None
except Exception as e:
logger.debug(f"Failed to get cached markets: {e}")
return None
def _fetch_markets_from_api(self, category: str = None, limit: int = 50) -> List[Dict]:
"""
从Polymarket Gamma API获取市场数据
使用官方推荐的 /events 端点
"""
try:
# 使用Gamma API的/events端点(官方推荐方式)
markets = self._fetch_from_gamma_api(category, limit)
if markets:
# 按volume_24h降序排序(因为API不支持order参数,需要本地排序)
markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True)
return markets[:limit] # 返回排序后的前limit个
2026-03-01 17:20:37 +08:00
# 如果API返回空列表,记录警告(可能是API暂时不可用、网络问题或限流)
logger.warning(f"Gamma API failed to fetch markets for category '{category}' (可能原因: API暂时不可用、网络问题、限流或返回空数据)")
2026-03-01 03:42:10 +08:00
return []
except Exception as e:
logger.error(f"Failed to fetch markets from API: {e}", exc_info=True)
return []
def _fetch_from_gamma_api(self, category: str = None, limit: int = 50) -> List[Dict]:
"""
使用Gamma API的/events端点获取市场数据(官方推荐方式)
参考: https://docs.polymarket.com/market-data/fetching-markets
"""
try:
# 使用/events端点获取活跃市场(推荐方式)
# 根据官方文档:https://docs.polymarket.com/market-data/fetching-markets
# order参数支持的值:volume_24hr, volume, liquidity, competitive, start_date, end_date
# 但某些端点可能不支持,先尝试不带order参数
url = f"{self.gamma_api}/events"
params = {
"active": "true",
"closed": "false",
"limit": min(limit * 2, 100) # 获取更多数据以便排序和筛选
}
# 尝试添加排序参数(如果API支持)
# 根据文档,可能的排序字段:volume_24hr, volume, liquidity等
# 如果API不支持,会在422错误后移除
# 如果指定了类别,需要通过tag_id筛选
# 注意:需要先获取tag_id,这里先用关键词推断
if category:
# 可以尝试通过搜索或标签来筛选
# 暂时先获取所有,然后在解析时过滤
pass
logger.info(f"Fetching from Gamma API: {url} with params: {params}")
response = self.session.get(url, params=params, timeout=15)
logger.info(f"Gamma API response status: {response.status_code}")
if response.status_code == 200:
try:
data = response.json()
logger.debug(f"Gamma API returned data type: {type(data)}, keys: {list(data.keys()) if isinstance(data, dict) else 'list'}")
# Gamma API返回的可能是列表或包含data字段的对象
if isinstance(data, list):
logger.info(f"Gamma API returned list with {len(data)} items")
markets = self._parse_gamma_events(data, category)
logger.info(f"Parsed {len(markets)} markets from Gamma API")
return markets
elif isinstance(data, dict):
# 可能是 {"data": [...]} 格式
if "data" in data:
events_list = data["data"]
logger.info(f"Gamma API returned dict with 'data' field containing {len(events_list) if isinstance(events_list, list) else 'non-list'} items")
markets = self._parse_gamma_events(events_list, category)
logger.info(f"Parsed {len(markets)} markets from Gamma API")
return markets
# 或者直接是事件对象
elif "id" in data or "slug" in data:
logger.info("Gamma API returned single event object")
markets = self._parse_gamma_events([data], category)
logger.info(f"Parsed {len(markets)} markets from Gamma API")
return markets
else:
logger.warning(f"Gamma API returned dict with unexpected keys: {list(data.keys())}")
logger.debug(f"Full response: {str(data)[:500]}")
logger.warning(f"Gamma API returned unexpected format: {type(data)}")
return []
except json.JSONDecodeError as je:
logger.error(f"Gamma API returned invalid JSON: {je}")
logger.error(f"Response text (first 500 chars): {response.text[:500]}")
return []
# 非200状态码
2026-03-01 17:20:37 +08:00
status_code = response.status_code
if status_code == 429:
logger.warning(f"Gamma API rate limited (429). 建议: 稍后重试或减少请求频率")
elif status_code == 503:
logger.warning(f"Gamma API service unavailable (503). Polymarket API可能正在维护")
elif status_code >= 500:
logger.warning(f"Gamma API server error ({status_code}). Polymarket服务器可能暂时不可用")
else:
logger.warning(f"Gamma API returned status {status_code}")
logger.debug(f"Response headers: {dict(response.headers)}")
logger.debug(f"Response text (first 500 chars): {response.text[:500]}")
2026-03-01 03:42:10 +08:00
return []
except requests.exceptions.Timeout:
2026-03-01 17:20:37 +08:00
logger.warning("Gamma API request timeout after 15 seconds (可能原因: 网络延迟或API响应慢)")
2026-03-01 03:42:10 +08:00
return []
except requests.exceptions.ConnectionError as ce:
2026-03-01 17:20:37 +08:00
logger.warning(f"Gamma API connection error: {ce} (可能原因: 网络连接问题或Polymarket API不可达)")
2026-03-01 03:42:10 +08:00
return []
except Exception as e:
2026-03-01 17:20:37 +08:00
logger.warning(f"Gamma API failed: {e} (可能原因: API格式变更、网络问题或服务异常)")
2026-03-01 03:42:10 +08:00
return []
def _parse_gamma_events(self, events_data: List[Dict], category_filter: str = None) -> List[Dict]:
"""
解析Gamma API返回的事件数据
Gamma API的/events端点返回事件对象,每个事件包含关联的市场数据
根据官方文档,事件对象结构:
- event对象包含markets数组
- 每个market包含clobTokenIds、outcomePrices等字段
"""
parsed = []
if not events_data:
logger.warning("_parse_gamma_events received empty events_data")
return parsed
logger.info(f"Parsing {len(events_data)} events from Gamma API")
# 记录第一个事件的键,用于调试
if events_data:
first_event_keys = list(events_data[0].keys())[:10]
logger.info(f"First event keys: {first_event_keys}")
logger.debug(f"First event sample: {str(events_data[0])[:500]}")
for idx, event in enumerate(events_data):
try:
# Gamma API的事件对象结构
# 事件可能有多个市场(markets字段),或者直接包含市场信息
markets = event.get("markets", [])
# 如果事件没有markets字段,可能事件本身就是市场数据
if not markets:
# 检查是否直接是市场对象(有question或title字段)
if "question" in event or "title" in event or "slug" in event:
markets = [event]
else:
if idx < 3: # 只记录前3个的详细信息
logger.debug(f"Event {idx} has no markets and doesn't look like a market. Keys: {list(event.keys())[:10]}")
continue
if idx < 3: # 只记录前3个的详细信息
logger.debug(f"Processing event {idx} with {len(markets)} markets")
for market_idx, market in enumerate(markets):
# 提取市场基本信息
market_id = market.get("id") or market.get("slug") or event.get("id") or event.get("slug", "")
question = market.get("question") or event.get("question") or market.get("title") or event.get("title", "")
if idx < 3 and market_idx < 2: # 记录前几个市场的详细信息
logger.info(f"Event {idx}, Market {market_idx}: id={market_id}, question={question[:50] if question else 'None'}, event_slug={event.get('slug')}, market_slug={market.get('slug')}, keys={list(market.keys())[:10]}")
if not question:
if idx < 3:
logger.warning(f"Event {idx}, Market {market_idx}: No question found, skipping. Market keys: {list(market.keys())[:10]}")
continue
# 推断类别
inferred_category = self._infer_category(question)
# 如果指定了类别筛选,进行过滤
if category_filter and inferred_category != category_filter:
continue
# 获取概率和outcome数据
current_probability = 50.0
outcome_tokens = {}
# 方法1: 从CLOB API获取实时价格(最准确)
try:
condition_id = market.get("conditionId") or event.get("conditionId")
if condition_id:
prices = self._get_market_prices_from_clob(condition_id)
if prices:
yes_price = prices.get("YES", 0)
no_price = prices.get("NO", 0)
if yes_price > 0:
current_probability = yes_price * 100 if yes_price <= 1 else yes_price
outcome_tokens["YES"] = {"price": yes_price if yes_price <= 1 else yes_price / 100, "volume": 0}
if no_price > 0:
outcome_tokens["NO"] = {"price": no_price if no_price <= 1 else no_price / 100, "volume": 0}
except Exception as e:
logger.debug(f"Failed to get prices from CLOB API: {e}")
# 方法2: 处理outcomePrices字段(可能是JSON字符串)
if current_probability == 50.0:
outcome_prices_str = market.get("outcomePrices") or event.get("outcomePrices")
if outcome_prices_str:
try:
if isinstance(outcome_prices_str, str):
outcome_prices = json.loads(outcome_prices_str)
else:
outcome_prices = outcome_prices_str
# outcomePrices通常是["0.65", "0.35"]格式,对应YES和NO
if isinstance(outcome_prices, list) and len(outcome_prices) >= 2:
yes_price = float(outcome_prices[0]) if outcome_prices[0] else 0
no_price = float(outcome_prices[1]) if outcome_prices[1] else 0
current_probability = yes_price * 100 if yes_price <= 1 else yes_price
outcome_tokens["YES"] = {"price": yes_price if yes_price <= 1 else yes_price / 100, "volume": 0}
outcome_tokens["NO"] = {"price": no_price if no_price <= 1 else no_price / 100, "volume": 0}
except Exception as e:
logger.debug(f"Failed to parse outcomePrices: {e}")
# 从market或event中获取outcomes
# outcomes可能是对象数组、字符串数组,或者需要从其他字段解析
outcomes = market.get("outcomes") or market.get("tokens") or event.get("outcomes") or []
# 处理outcomes数组(可能是对象或字符串)
for outcome in outcomes:
try:
# 如果outcome是字符串,跳过或尝试解析
if isinstance(outcome, str):
# 可能是简单的字符串标识,如"YES"或"NO"
outcome_upper = outcome.upper()
if "YES" in outcome_upper:
if "YES" not in outcome_tokens:
outcome_tokens["YES"] = {"price": 0.5, "volume": 0}
elif "NO" in outcome_upper:
if "NO" not in outcome_tokens:
outcome_tokens["NO"] = {"price": 0.5, "volume": 0}
continue
# outcome是对象
if not isinstance(outcome, dict):
continue
title = str(outcome.get("title") or outcome.get("name", "")).upper()
# 获取价格(可能是price、probability或currentPrice
price = float(outcome.get("price") or outcome.get("probability") or outcome.get("currentPrice") or 0)
if "YES" in title or title == "YES" or outcome.get("outcome") == "Yes":
current_probability = price * 100 if price <= 1 else price
outcome_tokens["YES"] = {
"price": price if price <= 1 else price / 100,
"volume": float(outcome.get("volume", outcome.get("volume24hr", 0)) or 0)
}
elif "NO" in title or title == "NO" or outcome.get("outcome") == "No":
outcome_tokens["NO"] = {
"price": price if price <= 1 else price / 100,
"volume": float(outcome.get("volume", outcome.get("volume24hr", 0)) or 0)
}
except Exception as e:
logger.debug(f"Failed to parse outcome: {e}")
continue
# 如果没有找到outcomes,尝试从其他字段获取概率
if current_probability == 50.0:
# 尝试从market的probability字段获取
prob = market.get("probability") or market.get("yesProbability") or event.get("probability")
if prob:
current_probability = float(prob) * 100 if float(prob) <= 1 else float(prob)
# 获取交易量和流动性
volume_24h = float(
market.get("volume_24hr") or
market.get("volume24hr") or
market.get("volume_24h") or
event.get("volume_24hr") or
event.get("volume24hr") or
0
)
liquidity = float(
market.get("liquidity") or
market.get("totalLiquidity") or
event.get("liquidity") or
0
)
# 解析结束日期
end_date_iso = None
end_date = market.get("endDate") or market.get("end_date") or event.get("endDate") or event.get("end_date")
if end_date:
try:
if isinstance(end_date, (int, float)):
end_date_iso = datetime.fromtimestamp(end_date).isoformat() + "Z"
elif isinstance(end_date, str):
# 尝试解析ISO格式字符串
end_date_iso = end_date
except:
pass
# 获取slug用于构建URL
# 根据Polymarket API文档:slug应该直接从API返回的数据中获取
# URL格式: https://polymarket.com/event/{slug}
# slug是字符串标识符,不是数字ID
slug = None
# 优先从event获取slug(因为event包含markets
if event.get("slug"):
slug_str = str(event.get("slug", "")).strip()
# 如果slug不是纯数字,且包含字母或连字符,则是有效slug
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
slug = slug_str
# 如果event没有有效slug,尝试从market获取
if not slug and market.get("slug"):
slug_str = str(market.get("slug", "")).strip()
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
slug = slug_str
# 如果仍然没有有效slug,尝试通过API查询获取
if not slug and market_id:
try:
# 使用markets端点通过ID查询,获取完整的slug信息
detail_market = self._fetch_market_detail_by_id(market_id)
if detail_market and detail_market.get("slug"):
slug_str = str(detail_market.get("slug", "")).strip()
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
slug = slug_str
except Exception as e:
logger.debug(f"Failed to fetch slug for market {market_id}: {e}")
# 构建URL(使用统一的辅助方法)
polymarket_url = self._build_polymarket_url(slug, market_id)
if not slug:
logger.warning(f"Market {market_id} has no valid slug, using markets endpoint as fallback")
market_data = {
"market_id": market_id,
"question": question,
"category": inferred_category,
"current_probability": round(current_probability, 2),
"volume_24h": volume_24h,
"liquidity": liquidity,
"end_date_iso": end_date_iso,
"status": "active" if market.get("active", event.get("active", True)) else "closed",
"outcome_tokens": outcome_tokens,
"polymarket_url": polymarket_url,
"slug": slug if slug else None # 保存slug(如果不是数字)
}
parsed.append(market_data)
if idx < 3 and market_idx < 2: # 记录成功解析的市场
logger.info(f"Successfully parsed market: {question[:50]}, prob={current_probability:.1f}%, volume={volume_24h}")
except Exception as e:
logger.warning(f"Failed to parse event {idx} (id={event.get('id', event.get('slug', 'unknown'))}): {e}", exc_info=True)
continue
logger.info(f"Successfully parsed {len(parsed)} markets from {len(events_data)} events")
return parsed
def _parse_rest_markets(self, markets_data: List[Dict]) -> List[Dict]:
"""解析REST API返回的市场数据"""
parsed = []
for market in markets_data:
try:
# 提取基本信息
market_id = market.get("id") or market.get("slug") or market.get("market_id", "")
question = market.get("question") or market.get("title", "")
# 计算概率
current_probability = 50.0
outcome_tokens = {}
if "outcomes" in market:
for outcome in market["outcomes"]:
title = str(outcome.get("title", "")).upper()
price = float(outcome.get("price", outcome.get("probability", 0)) or 0)
if "YES" in title or title == "YES":
current_probability = price * 100
outcome_tokens["YES"] = {
"price": price,
"volume": float(outcome.get("volume", 0) or 0)
}
elif "NO" in title or title == "NO":
outcome_tokens["NO"] = {
"price": price,
"volume": float(outcome.get("volume", 0) or 0)
}
volume_24h = float(market.get("volume_24h", market.get("volume", 0)) or 0)
liquidity = float(market.get("liquidity", 0) or 0)
# 推断类别
category = self._infer_category(question)
# 解析结束日期
end_date_iso = market.get("end_date") or market.get("endDate")
if isinstance(end_date_iso, (int, float)):
try:
end_date_iso = datetime.fromtimestamp(end_date_iso).isoformat() + "Z"
except:
end_date_iso = None
# 获取slug用于构建URL
slug = None
slug_str = str(market.get('slug', '')).strip() if market.get('slug') else ''
# 检查slug是否有效(不是数字,且包含字母或连字符)
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
slug = slug_str
else:
# 如果slug无效,尝试通过API查询获取
try:
detail_market = self._fetch_market_detail_by_id(market_id)
if detail_market and detail_market.get("slug"):
slug_str = str(detail_market.get("slug", "")).strip()
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
slug = slug_str
except Exception as e:
logger.debug(f"Failed to fetch slug for market {market_id}: {e}")
# 构建URL(使用统一的辅助方法)
polymarket_url = self._build_polymarket_url(slug, market_id)
if not slug:
logger.warning(f"Market {market_id} has no valid slug, using markets endpoint as fallback")
parsed.append({
"market_id": market_id,
"question": question,
"category": category,
"current_probability": round(current_probability, 2),
"volume_24h": volume_24h,
"liquidity": liquidity,
"end_date_iso": end_date_iso,
"status": "active" if market.get("active", True) else "closed",
"outcome_tokens": outcome_tokens,
"polymarket_url": polymarket_url,
"slug": slug if slug else None
})
except Exception as e:
logger.debug(f"Failed to parse market {market.get('id')}: {e}")
continue
return parsed
def _infer_category(self, question: str) -> str:
"""从问题中推断类别"""
question_lower = question.lower()
# 加密货币关键词
crypto_keywords = ['btc', 'bitcoin', 'eth', 'ethereum', 'sol', 'solana', 'crypto', 'token', 'coin', 'defi', 'nft']
if any(kw in question_lower for kw in crypto_keywords):
return "crypto"
# 政治关键词
politics_keywords = ['election', 'president', 'trump', 'biden', 'senate', 'congress', 'vote', 'political', 'democrat', 'republican']
if any(kw in question_lower for kw in politics_keywords):
return "politics"
# 经济关键词
economics_keywords = ['gdp', 'inflation', 'unemployment', 'fed', 'federal reserve', 'interest rate', 'economic', 'economy', 'recession', 'gdp growth', 'cpi', 'ppi']
if any(kw in question_lower for kw in economics_keywords):
return "economics"
# 体育关键词
sports_keywords = ['nfl', 'nba', 'mlb', 'soccer', 'football', 'basketball', 'baseball', 'championship', 'world cup', 'olympics', 'super bowl', 'stanley cup', 'world series']
if any(kw in question_lower for kw in sports_keywords):
return "sports"
# 科技关键词
tech_keywords = ['ai', 'artificial intelligence', 'chatgpt', 'openai', 'tech', 'technology', 'apple', 'google', 'microsoft', 'meta', 'tesla', 'ipo', 'startup']
if any(kw in question_lower for kw in tech_keywords):
return "tech"
# 金融关键词
finance_keywords = ['stock', 's&p', 'dow', 'nasdaq', 'market cap', 'earnings', 'revenue', 'profit', 'bank', 'banking', 'financial', 'trading']
if any(kw in question_lower for kw in finance_keywords):
return "finance"
# 地缘政治关键词
geopolitics_keywords = ['war', 'conflict', 'russia', 'ukraine', 'china', 'taiwan', 'north korea', 'iran', 'israel', 'palestine', 'middle east', 'nato', 'sanctions']
if any(kw in question_lower for kw in geopolitics_keywords):
return "geopolitics"
# 文化关键词
culture_keywords = ['movie', 'film', 'oscar', 'grammy', 'award', 'celebrity', 'music', 'album', 'tv show', 'series', 'netflix', 'disney']
if any(kw in question_lower for kw in culture_keywords):
return "culture"
# 气候关键词
climate_keywords = ['climate', 'global warming', 'temperature', 'carbon', 'emission', 'renewable', 'solar', 'wind energy', 'paris agreement', 'cop']
if any(kw in question_lower for kw in climate_keywords):
return "climate"
# 娱乐关键词
entertainment_keywords = ['game', 'gaming', 'esports', 'tournament', 'streaming', 'youtube', 'twitch', 'podcast', 'comic', 'anime', 'manga']
if any(kw in question_lower for kw in entertainment_keywords):
return "entertainment"
return "other"
def _build_polymarket_url(self, slug: Optional[str], market_id: str) -> str:
"""
根据slug构建Polymarket URL
参考: https://docs.polymarket.com/market-data/fetching-markets
Args:
slug: 从API或数据库获取的slug(可能是None或数字字符串)
market_id: 市场ID(作为备选)
Returns:
Polymarket URL字符串
"""
if slug:
slug_str = str(slug).strip()
# 检查slug是否有效(不是数字,且包含字母或连字符)
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
import re
slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str)
slug_clean = slug_clean.strip('-')
if slug_clean:
return f"https://polymarket.com/event/{slug_clean}"
2026-03-01 17:20:37 +08:00
# 如果没有有效slug,尝试通过API获取slug
if market_id:
try:
detail_market = self._fetch_market_detail_by_id(market_id)
if detail_market:
# 尝试从detail中获取slug
event_slug = detail_market.get('slug')
if event_slug:
slug_str = str(event_slug).strip()
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
import re
slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str)
slug_clean = slug_clean.strip('-')
if slug_clean:
return f"https://polymarket.com/event/{slug_clean}"
# 如果event没有slug,尝试从markets中获取
markets = detail_market.get('markets', [])
if markets:
for m in markets:
market_slug = m.get('slug')
if market_slug:
slug_str = str(market_slug).strip()
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
import re
slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str)
slug_clean = slug_clean.strip('-')
if slug_clean:
return f"https://polymarket.com/event/{slug_clean}"
except Exception as e:
logger.debug(f"Failed to fetch slug for market {market_id}: {e}")
# 如果所有方法都失败,返回搜索页面(更可靠)
# 注意:Polymarket的URL格式可能已经改变,使用搜索作为fallback
return f"https://polymarket.com/search?q={market_id}"
2026-03-01 03:42:10 +08:00
def _fetch_market_detail_by_id(self, market_id: str) -> Optional[Dict]:
"""
通过market ID从API获取市场详情(用于获取slug)
参考: https://docs.polymarket.com/market-data/fetching-markets
"""
try:
# 方法1: 尝试通过events端点查询(推荐,因为events包含markets
url = f"{self.gamma_api}/events"
params = {"active": "true", "closed": "false", "limit": 100}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
events = response.json()
if isinstance(events, list):
for event in events:
markets = event.get("markets", [])
if not markets and ("question" in event or "slug" in event):
markets = [event]
for market in markets:
m_id = market.get("id") or market.get("slug") or ""
e_id = event.get("id") or event.get("slug") or ""
# 匹配market_id或event_id
if str(m_id) == str(market_id) or str(e_id) == str(market_id):
# 返回event(因为event包含slug
return event
elif isinstance(events, dict):
if "data" in events:
events_list = events["data"]
for event in events_list:
markets = event.get("markets", [])
if not markets and ("question" in event or "slug" in event):
markets = [event]
for market in markets:
m_id = market.get("id") or market.get("slug") or ""
e_id = event.get("id") or event.get("slug") or ""
if str(m_id) == str(market_id) or str(e_id) == str(market_id):
return event
# 方法2: 尝试通过markets端点查询
url = f"{self.gamma_api}/markets"
params = {"id": market_id, "limit": 1}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
if isinstance(data, list) and len(data) > 0:
return data[0]
elif isinstance(data, dict) and "id" in data:
return data
return None
except Exception as e:
logger.debug(f"Failed to fetch market detail by ID {market_id}: {e}")
return None
2026-03-01 17:20:37 +08:00
def _fetch_market_by_slug(self, slug: str) -> Optional[Dict]:
"""
直接通过slug查询市场(最高效的方式)
根据Polymarket API文档:https://docs.polymarket.com/market-data/fetching-markets
可以使用 /markets?slug=xxx 直接查询
"""
try:
# 方法1: 尝试通过markets端点直接查询slug
url = f"{self.gamma_api}/markets"
params = {"slug": slug, "limit": 10}
logger.info(f"Fetching market by slug from Gamma API: {url} with params: {params}")
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
if isinstance(data, list) and len(data) > 0:
# 解析返回的市场数据
markets = self._parse_gamma_events(data)
# 精确匹配slug
for market in markets:
market_slug = market.get("slug", "").lower()
if market_slug == slug.lower() or slug.lower() in market_slug:
logger.info(f"Found market by slug: {slug}")
return market
# 如果没有精确匹配,返回第一个
if markets:
logger.info(f"Found market by slug (fuzzy match): {slug}")
return markets[0]
elif isinstance(data, dict):
# 单个市场对象
markets = self._parse_gamma_events([data])
if markets:
logger.info(f"Found market by slug: {slug}")
return markets[0]
# 方法2: 尝试通过events端点查询(events可能包含slug信息)
url = f"{self.gamma_api}/events"
params = {"active": "true", "closed": "false", "limit": 100}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
events = data if isinstance(data, list) else (data.get("data", []) if isinstance(data, dict) else [])
# 在返回的事件中查找匹配的slug
for event in events:
event_slug = (event.get("slug") or "").lower()
if event_slug == slug.lower() or slug.lower() in event_slug:
parsed = self._parse_gamma_events([event])
if parsed:
logger.info(f"Found market by slug via events: {slug}")
return parsed[0]
logger.warning(f"Market with slug '{slug}' not found via direct query")
return None
except Exception as e:
logger.error(f"Failed to fetch market by slug {slug}: {e}", exc_info=True)
return None
2026-03-01 03:42:10 +08:00
def _fetch_market_from_api(self, market_id: str) -> Optional[Dict]:
"""
从Gamma API获取单个市场数据
支持通过slug或id查询
"""
try:
2026-03-01 17:20:37 +08:00
# 判断是slug还是market_id
is_slug = not market_id.isdigit() and ('-' in market_id or any(c.isalpha() for c in market_id))
# 如果是slug,优先使用直接查询方法
if is_slug:
market = self._fetch_market_by_slug(market_id)
if market:
return market
# 方法1: 通过markets端点查询(支持id和slug
url = f"{self.gamma_api}/markets"
params = {"id": market_id, "limit": 10} if not is_slug else {"slug": market_id, "limit": 10}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
if isinstance(data, list) and len(data) > 0:
markets = self._parse_gamma_events(data)
if markets:
return markets[0]
elif isinstance(data, dict):
markets = self._parse_gamma_events([data])
if markets:
return markets[0]
2026-03-01 03:42:10 +08:00
2026-03-01 17:20:37 +08:00
# 方法2: 通过events端点搜索(作为备选)
2026-03-01 03:42:10 +08:00
url = f"{self.gamma_api}/events"
params = {
"active": "true",
"closed": "false",
"limit": 100
}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
events = data if isinstance(data, list) else (data.get("data", []) if isinstance(data, dict) else [])
# 在返回的事件中查找匹配的市场
for event in events:
markets = event.get("markets", [])
if not markets:
markets = [event]
for market in markets:
m_id = market.get("id") or market.get("slug") or event.get("id") or event.get("slug", "")
2026-03-01 17:20:37 +08:00
if str(m_id) == str(market_id) or market.get("slug") == market_id:
2026-03-01 03:42:10 +08:00
parsed = self._parse_gamma_events([event])
if parsed:
return parsed[0]
return None
except Exception as e:
logger.error(f"Failed to fetch market {market_id}: {e}", exc_info=True)
return None
def _save_markets_to_db(self, markets: List[Dict]):
"""保存市场数据到数据库"""
try:
with get_db_connection() as db:
cur = db.cursor()
for market in markets:
# 获取slug,但如果是数字则不要使用(数字不是有效的slug)
slug = market.get('slug') or None
# 如果slug是数字,说明不是有效的slug,设置为None
if slug and str(slug).isdigit():
slug = None
# 清理slug,只保留字母数字和连字符
import re
if slug:
slug = re.sub(r'[^a-zA-Z0-9\-]', '-', str(slug))
slug = slug.strip('-')
# 如果清理后为空或仍然是数字,设置为None
if not slug or slug.isdigit():
slug = None
cur.execute("""
INSERT INTO qd_polymarket_markets
(market_id, question, category, current_probability, volume_24h,
liquidity, end_date_iso, status, outcome_tokens, slug, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (market_id) DO UPDATE SET
question = EXCLUDED.question,
category = EXCLUDED.category,
current_probability = EXCLUDED.current_probability,
volume_24h = EXCLUDED.volume_24h,
liquidity = EXCLUDED.liquidity,
end_date_iso = EXCLUDED.end_date_iso,
status = EXCLUDED.status,
outcome_tokens = EXCLUDED.outcome_tokens,
slug = EXCLUDED.slug,
updated_at = NOW()
""", (
market.get('market_id'),
market.get('question'),
market.get('category', 'other'),
market.get('current_probability', 50.0),
market.get('volume_24h', 0),
market.get('liquidity', 0),
market.get('end_date_iso'),
market.get('status', 'active'),
json.dumps(market.get('outcome_tokens', {})),
slug
))
db.commit()
cur.close()
except Exception as e:
logger.error(f"Failed to save markets to DB: {type(e).__name__}: {e}", exc_info=True)
def _get_sample_markets(self, category: str = None, limit: int = 50) -> List[Dict]:
"""
获取示例市场数据(已弃用)
现在应该使用真实的API数据
"""
# 不再返回示例数据,返回空列表
logger.warning("Sample data method called, but real API should be used instead")
return []