@@ -33,6 +33,15 @@ def get_pending_order_worker():
|
||||
return _pending_order_worker
|
||||
|
||||
|
||||
def start_polymarket_worker():
|
||||
"""启动Polymarket后台任务"""
|
||||
try:
|
||||
from app.services.polymarket_worker import get_polymarket_worker
|
||||
get_polymarket_worker().start()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Polymarket worker: {e}")
|
||||
|
||||
|
||||
def start_portfolio_monitor():
|
||||
"""Start the portfolio monitor service if enabled.
|
||||
|
||||
@@ -257,6 +266,7 @@ def create_app(config_name='default'):
|
||||
start_pending_order_worker()
|
||||
start_portfolio_monitor()
|
||||
start_usdt_order_worker()
|
||||
start_polymarket_worker()
|
||||
restore_running_strategies()
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# 从真实API获取 - 获取多个分类的数据以确保多样性
|
||||
all_markets = []
|
||||
|
||||
if category and category != "all":
|
||||
# 如果指定了类别,只获取该类别的数据
|
||||
markets = self._fetch_markets_from_api(category, limit * 2)
|
||||
all_markets.extend(markets)
|
||||
else:
|
||||
# 如果没有指定类别或指定了"all",获取多个分类的数据
|
||||
categories_to_fetch = ["crypto", "politics", "economics", "sports"]
|
||||
for cat in categories_to_fetch:
|
||||
markets = self._fetch_markets_from_api(cat, limit // len(categories_to_fetch) + 10)
|
||||
all_markets.extend(markets)
|
||||
|
||||
# 去重(按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()
|
||||
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 AND status = 'active'
|
||||
ORDER BY volume_24h DESC
|
||||
LIMIT %s
|
||||
""", (f"%{keyword}%", limit))
|
||||
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})...")
|
||||
# 获取更多数据以便有足够的选择空间
|
||||
all_markets = self._fetch_from_gamma_api(category=None, limit=limit * 5)
|
||||
logger.info(f"Fetched {len(all_markets)} markets from API, filtering for keyword '{keyword}'...")
|
||||
|
||||
# 按关键词过滤(支持多个关键词匹配)
|
||||
keyword_lower = keyword.lower()
|
||||
filtered = []
|
||||
for market in all_markets:
|
||||
question = market.get("question", "").lower()
|
||||
# 检查关键词是否在问题中
|
||||
if keyword_lower in question:
|
||||
filtered.append(market)
|
||||
logger.debug(f"Matched market: {market.get('question', '')[:60]}")
|
||||
if len(filtered) >= limit:
|
||||
break
|
||||
|
||||
logger.info(f"Filtered {len(filtered)} markets matching keyword '{keyword}' from API")
|
||||
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:
|
||||
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',
|
||||
"outcome_tokens": row.get('outcome_tokens') if row.get('outcome_tokens') else {},
|
||||
"polymarket_url": f"https://polymarket.com/event/{row.get('market_id') or ''}"
|
||||
} for row in rows]
|
||||
|
||||
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个
|
||||
|
||||
logger.warning("Gamma API failed to fetch markets")
|
||||
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状态码
|
||||
logger.warning(f"Gamma API returned status {response.status_code}")
|
||||
logger.warning(f"Response headers: {dict(response.headers)}")
|
||||
logger.warning(f"Response text (first 500 chars): {response.text[:500]}")
|
||||
return []
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error("Gamma API request timeout after 15 seconds")
|
||||
return []
|
||||
except requests.exceptions.ConnectionError as ce:
|
||||
logger.error(f"Gamma API connection error: {ce}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Gamma API failed: {e}", exc_info=True)
|
||||
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}"
|
||||
|
||||
# 如果没有有效slug,使用markets端点作为备选
|
||||
return f"https://polymarket.com/markets/{market_id}"
|
||||
|
||||
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
|
||||
|
||||
def _fetch_market_from_api(self, market_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
从Gamma API获取单个市场数据
|
||||
支持通过slug或id查询
|
||||
"""
|
||||
try:
|
||||
# 方法1: 通过slug查询(推荐)
|
||||
# 如果market_id看起来像slug(包含连字符),使用markets端点
|
||||
if "-" in market_id or "/" not in market_id:
|
||||
url = f"{self.gamma_api}/markets"
|
||||
params = {"slug": market_id}
|
||||
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]
|
||||
|
||||
# 方法2: 通过events端点搜索
|
||||
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", "")
|
||||
if m_id == market_id or market.get("slug") == market_id:
|
||||
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 []
|
||||
@@ -26,6 +26,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.fast_analysis import fast_analysis_bp
|
||||
from app.routes.billing import billing_bp
|
||||
from app.routes.quick_trade import quick_trade_bp
|
||||
from app.routes.polymarket import polymarket_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
||||
@@ -46,4 +47,5 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(community_bp, url_prefix='/api/community')
|
||||
app.register_blueprint(fast_analysis_bp, url_prefix='/api/fast-analysis')
|
||||
app.register_blueprint(billing_bp, url_prefix='/api/billing')
|
||||
app.register_blueprint(quick_trade_bp, url_prefix='/api/quick-trade')
|
||||
app.register_blueprint(quick_trade_bp, url_prefix='/api/quick-trade')
|
||||
app.register_blueprint(polymarket_bp, url_prefix='/api/polymarket')
|
||||
@@ -1837,11 +1837,61 @@ def _analyze_opportunities_forex(opportunities: list):
|
||||
})
|
||||
|
||||
|
||||
def _analyze_opportunities_polymarket(opportunities: list):
|
||||
"""扫描预测市场机会"""
|
||||
try:
|
||||
from app.data_sources.polymarket import PolymarketDataSource
|
||||
from app.services.polymarket_analyzer import PolymarketAnalyzer
|
||||
|
||||
polymarket_source = PolymarketDataSource()
|
||||
analyzer = PolymarketAnalyzer()
|
||||
|
||||
# 获取热门市场
|
||||
markets = polymarket_source.get_trending_markets(limit=20)
|
||||
|
||||
for market in markets:
|
||||
try:
|
||||
# AI分析
|
||||
analysis = analyzer.analyze_market(market['market_id'])
|
||||
|
||||
if analysis.get('error'):
|
||||
continue
|
||||
|
||||
# 只添加高分机会
|
||||
if analysis.get('opportunity_score', 0) > 75:
|
||||
opportunities.append({
|
||||
"symbol": market['question'][:50], # 简化显示
|
||||
"name": market['question'],
|
||||
"price": market['current_probability'],
|
||||
"change_24h": 0, # 预测市场没有24h涨跌幅概念
|
||||
"signal": "prediction_opportunity",
|
||||
"strength": "strong" if analysis.get('opportunity_score', 0) > 85 else "medium",
|
||||
"reason": f"AI预测概率{analysis.get('ai_predicted_probability', 0):.1f}%,市场概率{market['current_probability']:.1f}%,差异{analysis.get('divergence', 0):.1f}%",
|
||||
"impact": "bullish" if analysis.get('recommendation') == "YES" else "bearish",
|
||||
"market": "PredictionMarket",
|
||||
"market_id": market['market_id'],
|
||||
"ai_analysis": {
|
||||
"predicted_probability": analysis.get('ai_predicted_probability', 0),
|
||||
"recommendation": analysis.get('recommendation', 'HOLD'),
|
||||
"confidence_score": analysis.get('confidence_score', 0),
|
||||
"opportunity_score": analysis.get('opportunity_score', 0)
|
||||
},
|
||||
"timestamp": int(time.time())
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to analyze polymarket {market.get('market_id')}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"_analyze_opportunities_polymarket failed: {e}")
|
||||
|
||||
|
||||
@global_market_bp.route("/opportunities", methods=["GET"])
|
||||
@login_required
|
||||
def trading_opportunities():
|
||||
"""
|
||||
Scan for trading opportunities across Crypto, US Stocks, and Forex.
|
||||
Note: Prediction Markets are excluded as they have their own dedicated page.
|
||||
Cached for 1 hour. Pass ?force=true to skip cache.
|
||||
"""
|
||||
try:
|
||||
@@ -1878,6 +1928,9 @@ def trading_opportunities():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze forex opportunities: {e}", exc_info=True)
|
||||
|
||||
# Note: Prediction Markets are excluded from trading opportunities radar
|
||||
# as they have their own dedicated page at /polymarket
|
||||
|
||||
# Sort by absolute change descending
|
||||
opportunities.sort(key=lambda x: abs(x.get("change_24h", 0)), reverse=True)
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ def save_indicator():
|
||||
description = (data.get("description") or "").strip()
|
||||
publish_to_community = 1 if data.get("publishToCommunity") or data.get("publish_to_community") else 0
|
||||
pricing_type = (data.get("pricingType") or data.get("pricing_type") or "free").strip() or "free"
|
||||
vip_free = 1 if (data.get("vipFree") or data.get("vip_free")) else 0
|
||||
vip_free = bool(data.get("vipFree") or data.get("vip_free"))
|
||||
try:
|
||||
price = float(data.get("price") or 0)
|
||||
except Exception:
|
||||
@@ -265,7 +265,7 @@ def save_indicator():
|
||||
UPDATE qd_indicator_codes
|
||||
SET name = ?, code = ?, description = ?,
|
||||
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
|
||||
vip_free = 0,
|
||||
vip_free = FALSE,
|
||||
review_status = NULL, review_note = '', reviewed_at = NULL, reviewed_by = NULL,
|
||||
updatetime = ?, updated_at = NOW()
|
||||
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
Polymarket预测市场API路由
|
||||
提供预测市场数据和分析接口(只读,不涉及交易)
|
||||
"""
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from app.utils.auth import login_required
|
||||
from app.utils.logger import get_logger
|
||||
from app.data_sources.polymarket import PolymarketDataSource
|
||||
from app.utils.db import get_db_connection
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
polymarket_bp = Blueprint('polymarket', __name__)
|
||||
|
||||
# 初始化服务
|
||||
polymarket_source = PolymarketDataSource()
|
||||
|
||||
|
||||
@polymarket_bp.route("/markets", methods=["GET"])
|
||||
@login_required
|
||||
def get_markets():
|
||||
"""
|
||||
获取预测市场列表
|
||||
|
||||
Query params:
|
||||
category: crypto/politics/economics/sports (optional)
|
||||
sort_by: volume_24h/ai_score/probability_change (default: volume_24h)
|
||||
limit: 数量 (default: 20)
|
||||
"""
|
||||
try:
|
||||
category = request.args.get("category")
|
||||
sort_by = request.args.get("sort_by", "volume_24h")
|
||||
limit = int(request.args.get("limit", 20))
|
||||
|
||||
# 获取市场列表
|
||||
markets = polymarket_source.get_trending_markets(category, limit * 2)
|
||||
|
||||
logger.info(f"Fetched {len(markets)} markets from PolymarketDataSource (category={category}, limit={limit})")
|
||||
|
||||
if not markets:
|
||||
logger.warning(f"No markets returned from PolymarketDataSource. This may indicate API issues or empty cache.")
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": [],
|
||||
"warning": "No markets available. API may be unavailable or cache is empty."
|
||||
})
|
||||
|
||||
# 从数据库读取缓存的AI分析结果(由后台任务批量分析生成)
|
||||
# 只读取30分钟内的分析结果
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
market_ids = [m.get('market_id') for m in markets if m.get('market_id')]
|
||||
|
||||
if market_ids:
|
||||
# 查询30分钟内的分析结果
|
||||
from datetime import datetime, timedelta
|
||||
cache_cutoff = datetime.now() - timedelta(minutes=30)
|
||||
placeholders = ','.join(['%s'] * len(market_ids))
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT market_id, ai_predicted_probability, market_probability, divergence,
|
||||
recommendation, confidence_score, opportunity_score, reasoning, key_factors
|
||||
FROM qd_polymarket_ai_analysis
|
||||
WHERE market_id IN ({placeholders})
|
||||
AND user_id IS NULL
|
||||
AND created_at > %s
|
||||
ORDER BY opportunity_score DESC
|
||||
""", market_ids + [cache_cutoff])
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
# 构建分析结果映射
|
||||
analysis_map = {}
|
||||
for row in rows:
|
||||
market_id = row.get('market_id')
|
||||
if market_id:
|
||||
key_factors_raw = row.get('key_factors')
|
||||
key_factors = []
|
||||
if key_factors_raw:
|
||||
try:
|
||||
if isinstance(key_factors_raw, str):
|
||||
key_factors = json.loads(key_factors_raw)
|
||||
else:
|
||||
key_factors = key_factors_raw if isinstance(key_factors_raw, list) else []
|
||||
except:
|
||||
key_factors = []
|
||||
|
||||
analysis_map[market_id] = {
|
||||
'predicted_probability': float(row.get('ai_predicted_probability') or 0),
|
||||
'recommendation': row.get('recommendation') or 'HOLD',
|
||||
'confidence_score': float(row.get('confidence_score') or 0),
|
||||
'opportunity_score': float(row.get('opportunity_score') or 0),
|
||||
'divergence': float(row.get('divergence') or 0),
|
||||
'reasoning': row.get('reasoning') or '',
|
||||
'key_factors': key_factors
|
||||
}
|
||||
|
||||
# 为每个市场添加分析结果
|
||||
for market in markets:
|
||||
market_id = market.get('market_id')
|
||||
if market_id and market_id in analysis_map:
|
||||
market['ai_analysis'] = analysis_map[market_id]
|
||||
else:
|
||||
market['ai_analysis'] = None
|
||||
else:
|
||||
# 如果没有市场ID,设置分析为None
|
||||
for market in markets:
|
||||
market['ai_analysis'] = None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cached analysis: {e}")
|
||||
# 出错时,所有市场都没有分析结果
|
||||
for market in markets:
|
||||
market['ai_analysis'] = None
|
||||
|
||||
# 筛选:优先返回有交易机会的市场,但也包含其他活跃市场
|
||||
# 重点:不是简单复制数据,而是找到交易机会,但也要保证有足够的数据展示
|
||||
opportunity_markets = []
|
||||
other_markets = []
|
||||
|
||||
for market in markets:
|
||||
ai_analysis = market.get('ai_analysis')
|
||||
volume = market.get('volume_24h', 0) or 0
|
||||
prob = market.get('current_probability', 50.0) or 50.0
|
||||
|
||||
if ai_analysis:
|
||||
opportunity_score = ai_analysis.get('opportunity_score', 0) or 0
|
||||
divergence = abs(ai_analysis.get('divergence', 0) or 0)
|
||||
confidence = ai_analysis.get('confidence_score', 0) or 0
|
||||
|
||||
# 筛选条件:机会评分>60 或 (差异>15% 且 置信度>70) 或 (差异>10% 且 置信度>60)
|
||||
if opportunity_score > 60 or (divergence > 15 and confidence > 70) or (divergence > 10 and confidence > 60):
|
||||
opportunity_markets.append(market)
|
||||
elif volume > 5000: # 交易量较大的也作为备选
|
||||
other_markets.append(market)
|
||||
else:
|
||||
# 如果没有AI分析,但交易量大且概率不是50%(说明有明确的市场共识),也包含
|
||||
if volume > 5000 and abs(prob - 50.0) > 5: # 降低阈值,包含更多市场
|
||||
other_markets.append(market)
|
||||
|
||||
# 合并结果:优先显示机会市场,然后补充其他活跃市场
|
||||
if opportunity_markets:
|
||||
# 如果有机会市场,优先显示它们,然后补充其他市场直到达到limit
|
||||
result_markets = opportunity_markets[:limit]
|
||||
remaining = limit - len(result_markets)
|
||||
if remaining > 0 and other_markets:
|
||||
result_markets.extend(other_markets[:remaining])
|
||||
opportunity_markets = result_markets
|
||||
elif other_markets:
|
||||
# 如果没有机会市场,至少返回高交易量的市场
|
||||
opportunity_markets = other_markets[:limit]
|
||||
else:
|
||||
# 如果都没有,返回原始市场列表的前几个
|
||||
opportunity_markets = markets[:min(limit, len(markets))]
|
||||
|
||||
# 排序和筛选
|
||||
if sort_by == "ai_score":
|
||||
# 按AI机会评分排序(高概率/高回报比优先)
|
||||
opportunity_markets.sort(
|
||||
key=lambda x: (
|
||||
x.get('ai_analysis', {}).get('opportunity_score', 0) or 0,
|
||||
abs(x.get('ai_analysis', {}).get('divergence', 0) or 0), # 差异越大越好
|
||||
x.get('ai_analysis', {}).get('confidence_score', 0) or 0 # 置信度越高越好
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
elif sort_by == "high_probability":
|
||||
# 高概率机会:AI预测概率 > 市场概率 + 10%
|
||||
opportunity_markets.sort(
|
||||
key=lambda x: (
|
||||
x.get('ai_analysis', {}).get('ai_predicted_probability', 0) or 0,
|
||||
x.get('ai_analysis', {}).get('confidence_score', 0) or 0
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
elif sort_by == "high_return":
|
||||
# 高回报比机会:AI与市场差异大且置信度高
|
||||
opportunity_markets.sort(
|
||||
key=lambda x: (
|
||||
abs(x.get('ai_analysis', {}).get('divergence', 0) or 0) *
|
||||
(x.get('ai_analysis', {}).get('confidence_score', 0) or 0) / 100,
|
||||
x.get('ai_analysis', {}).get('opportunity_score', 0) or 0
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
elif sort_by == "probability_change":
|
||||
# 需要历史数据,暂时按volume排序
|
||||
opportunity_markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True)
|
||||
else:
|
||||
opportunity_markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True)
|
||||
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": opportunity_markets[:limit],
|
||||
"total_opportunities": len(opportunity_markets),
|
||||
"total_markets": len(markets)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_markets failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": str(e),
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
|
||||
@polymarket_bp.route("/markets/<market_id>", methods=["GET"])
|
||||
@login_required
|
||||
def get_market_detail(market_id: str):
|
||||
"""
|
||||
获取单个市场详情和AI分析
|
||||
|
||||
支持通过market ID或slug查询
|
||||
"""
|
||||
try:
|
||||
# 确保market_id是字符串
|
||||
market_id = str(market_id).strip()
|
||||
# 获取市场数据
|
||||
market = polymarket_source.get_market_details(market_id)
|
||||
if not market:
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": "Market not found",
|
||||
"data": None
|
||||
}), 404
|
||||
|
||||
# 从数据库读取缓存的AI分析结果(30分钟内)
|
||||
analysis = None
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cache_cutoff = datetime.now() - timedelta(minutes=30)
|
||||
|
||||
cur.execute("""
|
||||
SELECT ai_predicted_probability, market_probability, divergence,
|
||||
recommendation, confidence_score, opportunity_score,
|
||||
reasoning, key_factors, related_assets, created_at
|
||||
FROM qd_polymarket_ai_analysis
|
||||
WHERE market_id = %s AND user_id IS NULL AND created_at > %s
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (market_id, cache_cutoff))
|
||||
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if row:
|
||||
key_factors_raw = row.get('key_factors')
|
||||
key_factors = []
|
||||
if key_factors_raw:
|
||||
try:
|
||||
if isinstance(key_factors_raw, str):
|
||||
key_factors = json.loads(key_factors_raw)
|
||||
else:
|
||||
key_factors = key_factors_raw if isinstance(key_factors_raw, list) else []
|
||||
except:
|
||||
key_factors = []
|
||||
|
||||
analysis = {
|
||||
"ai_predicted_probability": float(row.get('ai_predicted_probability') or 0),
|
||||
"market_probability": float(row.get('market_probability') or 0),
|
||||
"divergence": float(row.get('divergence') or 0),
|
||||
"recommendation": row.get('recommendation') or 'HOLD',
|
||||
"confidence_score": float(row.get('confidence_score') or 0),
|
||||
"opportunity_score": float(row.get('opportunity_score') or 0),
|
||||
"reasoning": row.get('reasoning') or '',
|
||||
"key_factors": key_factors,
|
||||
"related_assets": row.get('related_assets') if row.get('related_assets') else []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cached analysis for market {market_id}: {e}")
|
||||
|
||||
# 资产交易机会(暂时返回空,可以后续实现)
|
||||
asset_opportunities = []
|
||||
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"market": market,
|
||||
"ai_analysis": analysis,
|
||||
"asset_opportunities": asset_opportunities
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_market_detail failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": str(e),
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
|
||||
@polymarket_bp.route("/markets/<market_id>/opportunities", methods=["GET"])
|
||||
@login_required
|
||||
def get_market_opportunities(market_id: str):
|
||||
"""获取基于该预测市场的资产交易机会(暂时返回空,后续可扩展)"""
|
||||
try:
|
||||
# 暂时返回空列表,可以后续实现基于预测市场的资产推荐
|
||||
opportunities = []
|
||||
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": opportunities
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_market_opportunities failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": str(e),
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
|
||||
@polymarket_bp.route("/recommendations", methods=["GET"])
|
||||
@login_required
|
||||
def get_recommendations():
|
||||
"""
|
||||
获取AI推荐的高价值预测市场
|
||||
|
||||
Query params:
|
||||
limit: 数量 (default: 10)
|
||||
"""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 10))
|
||||
|
||||
# 获取所有活跃市场
|
||||
all_markets = polymarket_source.get_trending_markets(limit=100)
|
||||
|
||||
# 从数据库读取缓存的AI分析结果(30分钟内,按机会评分排序)
|
||||
recommendations = []
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cache_cutoff = datetime.now() - timedelta(minutes=30)
|
||||
market_ids = [m.get('market_id') for m in all_markets if m.get('market_id')]
|
||||
|
||||
if market_ids:
|
||||
placeholders = ','.join(['%s'] * len(market_ids))
|
||||
cur.execute(f"""
|
||||
SELECT a.market_id, a.opportunity_score, a.recommendation,
|
||||
a.confidence_score, a.reasoning, a.key_factors,
|
||||
m.question, m.current_probability, m.volume_24h, m.category
|
||||
FROM qd_polymarket_ai_analysis a
|
||||
JOIN qd_polymarket_markets m ON a.market_id = m.market_id
|
||||
WHERE a.market_id IN ({placeholders})
|
||||
AND a.user_id IS NULL
|
||||
AND a.created_at > %s
|
||||
AND a.opportunity_score > 70
|
||||
ORDER BY a.opportunity_score DESC
|
||||
LIMIT %s
|
||||
""", market_ids + [cache_cutoff, limit])
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
for row in rows:
|
||||
key_factors_raw = row.get('key_factors')
|
||||
key_factors = []
|
||||
if key_factors_raw:
|
||||
try:
|
||||
if isinstance(key_factors_raw, str):
|
||||
key_factors = json.loads(key_factors_raw)
|
||||
else:
|
||||
key_factors = key_factors_raw if isinstance(key_factors_raw, list) else []
|
||||
except:
|
||||
key_factors = []
|
||||
|
||||
recommendations.append({
|
||||
"market_id": row.get('market_id'),
|
||||
"question": row.get('question'),
|
||||
"current_probability": float(row.get('current_probability') or 0),
|
||||
"volume_24h": float(row.get('volume_24h') or 0),
|
||||
"category": row.get('category'),
|
||||
"ai_analysis": {
|
||||
"opportunity_score": float(row.get('opportunity_score') or 0),
|
||||
"recommendation": row.get('recommendation') or 'HOLD',
|
||||
"confidence_score": float(row.get('confidence_score') or 0),
|
||||
"reasoning": row.get('reasoning') or '',
|
||||
"key_factors": key_factors
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load recommendations: {e}")
|
||||
|
||||
# 如果没有缓存结果,返回空列表(等待后台任务分析)
|
||||
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": recommendations[:limit]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_recommendations failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": str(e),
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
|
||||
@polymarket_bp.route("/search", methods=["GET"])
|
||||
@login_required
|
||||
def search_markets():
|
||||
"""搜索预测市场"""
|
||||
try:
|
||||
keyword = request.args.get("q", "").strip()
|
||||
if not keyword:
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": "Keyword required",
|
||||
"data": None
|
||||
}), 400
|
||||
|
||||
limit = int(request.args.get("limit", 20))
|
||||
|
||||
markets = polymarket_source.search_markets(keyword, limit)
|
||||
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": markets
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"search_markets failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": str(e),
|
||||
"data": None
|
||||
}), 500
|
||||
@@ -43,10 +43,12 @@ class AnalysisMemory:
|
||||
self._ensure_table()
|
||||
|
||||
def _ensure_table(self):
|
||||
"""Create memory table if not exists."""
|
||||
"""Create memory table if not exists, and add missing columns if needed."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 创建表(如果不存在)
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS qd_analysis_memory (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -64,6 +66,7 @@ class AnalysisMemory:
|
||||
risks JSONB,
|
||||
scores JSONB,
|
||||
indicators_snapshot JSONB,
|
||||
raw_result JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
validated_at TIMESTAMP,
|
||||
actual_outcome VARCHAR(20),
|
||||
@@ -72,20 +75,50 @@ class AnalysisMemory:
|
||||
user_feedback VARCHAR(20),
|
||||
feedback_at TIMESTAMP
|
||||
);
|
||||
|
||||
""")
|
||||
|
||||
# 检查并添加缺失的列(用于已存在的表)
|
||||
cur.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 添加 user_id 列(如果不存在)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT;
|
||||
END IF;
|
||||
|
||||
-- 添加 raw_result 列(如果不存在)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'raw_result'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN raw_result JSONB;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# 创建索引
|
||||
cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol
|
||||
ON qd_analysis_memory(market, symbol);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_memory_created
|
||||
ON qd_analysis_memory(created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_memory_validated
|
||||
ON qd_analysis_memory(validated_at) WHERE validated_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_memory_user
|
||||
ON qd_analysis_memory(user_id);
|
||||
""")
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
logger.debug("Analysis memory table ensured successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory table creation skipped: {e}")
|
||||
logger.warning(f"Memory table creation/update skipped: {e}")
|
||||
|
||||
def store(self, analysis_result: Dict[str, Any], user_id: int = None) -> Optional[int]:
|
||||
"""
|
||||
|
||||
@@ -26,10 +26,10 @@ class BacktestService:
|
||||
}
|
||||
|
||||
# Multi-timeframe backtest threshold configuration
|
||||
# 1m backtest: max 1 month (~43,200 candles)
|
||||
# 1m backtest: max 15 days (~21,600 candles) - reduced for performance
|
||||
# 5m backtest: max 1 year (~105,120 candles)
|
||||
MTF_CONFIG = {
|
||||
'max_1m_days': 30, # Max days for 1-minute backtest
|
||||
'max_1m_days': 15, # Max days for 1-minute backtest (reduced from 30 for performance)
|
||||
'max_5m_days': 365, # Max days for 5-minute backtest
|
||||
'default_exec_tf': '1m', # Default execution timeframe
|
||||
'fallback_exec_tf': '5m', # Fallback execution timeframe
|
||||
@@ -79,7 +79,7 @@ class BacktestService:
|
||||
}
|
||||
|
||||
if days_diff <= self.MTF_CONFIG['max_1m_days']:
|
||||
# Within 1 month: use 1-minute precision
|
||||
# Within 15 days: use 1-minute precision
|
||||
estimated_candles = days_diff * 24 * 60
|
||||
return '1m', {
|
||||
'enabled': True,
|
||||
@@ -90,7 +90,7 @@ class BacktestService:
|
||||
'message': f'Using 1-minute precision backtest (~{estimated_candles:,} candles)'
|
||||
}
|
||||
elif days_diff <= self.MTF_CONFIG['max_5m_days']:
|
||||
# 1 month to 1 year: use 5-minute precision
|
||||
# 15 days to 1 year: use 5-minute precision
|
||||
estimated_candles = days_diff * 24 * 12
|
||||
return '5m', {
|
||||
'enabled': True,
|
||||
@@ -98,7 +98,7 @@ class BacktestService:
|
||||
'days': days_diff,
|
||||
'estimated_candles': estimated_candles,
|
||||
'precision': 'medium',
|
||||
'message': f'Range exceeds 30 days, using 5-minute precision (~{estimated_candles:,} candles)'
|
||||
'message': f'Range exceeds {self.MTF_CONFIG["max_1m_days"]} days, using 5-minute precision (~{estimated_candles:,} candles)'
|
||||
}
|
||||
else:
|
||||
# Over 1 year: high-precision backtest not supported
|
||||
@@ -192,9 +192,12 @@ class BacktestService:
|
||||
'trade_direction': trade_direction
|
||||
}
|
||||
signals = self._execute_indicator(indicator_code, df_signal, backtest_params)
|
||||
logger.info(f"Signals generated: {list(signals.keys()) if isinstance(signals, dict) else type(signals)}")
|
||||
|
||||
# 3. Fetch execution timeframe candles (for precise trade simulation)
|
||||
logger.info(f"Fetching execution timeframe data: {exec_tf} for {market}:{symbol}")
|
||||
df_exec = self._fetch_kline_data(market, symbol, exec_tf, start_date, end_date)
|
||||
logger.info(f"Execution timeframe data fetched: {len(df_exec)} candles")
|
||||
if df_exec.empty:
|
||||
logger.warning(f"Cannot fetch {exec_tf} candles, falling back to standard backtest")
|
||||
result = self.run(
|
||||
@@ -221,7 +224,9 @@ class BacktestService:
|
||||
logger.info(f"Data fetched: signal_candles={len(df_signal)}, exec_candles={len(df_exec)}")
|
||||
|
||||
# 4. Use execution timeframe for precise trade simulation
|
||||
equity_curve, trades, total_commission = self._simulate_trading_mtf(
|
||||
try:
|
||||
logger.info("Starting MTF trading simulation...")
|
||||
equity_curve, trades, total_commission = self._simulate_trading_mtf(
|
||||
df_signal=df_signal,
|
||||
df_exec=df_exec,
|
||||
signals=signals,
|
||||
@@ -231,19 +236,38 @@ class BacktestService:
|
||||
leverage=leverage,
|
||||
trade_direction=trade_direction,
|
||||
strategy_config=strategy_config,
|
||||
signal_timeframe=timeframe,
|
||||
exec_timeframe=exec_tf
|
||||
)
|
||||
signal_timeframe=timeframe,
|
||||
exec_timeframe=exec_tf
|
||||
)
|
||||
logger.info(f"MTF simulation completed: {len(trades)} trades executed")
|
||||
except Exception as e:
|
||||
logger.error(f"MTF simulation failed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
# 5. Calculate metrics
|
||||
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
|
||||
try:
|
||||
logger.info(f"Calculating metrics: equity_curve_len={len(equity_curve)}, trades_len={len(trades)}, initial_capital={initial_capital}")
|
||||
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
|
||||
logger.info(f"Metrics calculated successfully: {list(metrics.keys())}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate metrics: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
# 6. Format result
|
||||
result = self._format_result(metrics, equity_curve, trades)
|
||||
result['precision_info'] = precision_info
|
||||
result['execution_timeframe'] = exec_tf
|
||||
result['signal_candles'] = len(df_signal)
|
||||
result['execution_candles'] = len(df_exec)
|
||||
try:
|
||||
logger.info("Formatting backtest result...")
|
||||
result = self._format_result(metrics, equity_curve, trades)
|
||||
result['precision_info'] = precision_info
|
||||
result['execution_timeframe'] = exec_tf
|
||||
result['signal_candles'] = len(df_signal)
|
||||
result['execution_candles'] = len(df_exec)
|
||||
logger.info("Backtest result formatted successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to format result: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
return result
|
||||
|
||||
@@ -267,6 +291,11 @@ class BacktestService:
|
||||
Simulates trades candle by candle on execution timeframe,
|
||||
using inferred candle price path to determine trigger order.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Entering _simulate_trading_mtf: df_signal={len(df_signal)}, df_exec={len(df_exec)}, signals_type={type(signals)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _simulate_trading_mtf entry logging: {e}")
|
||||
|
||||
equity_curve = []
|
||||
trades = []
|
||||
total_commission_paid = 0.0
|
||||
@@ -342,19 +371,38 @@ class BacktestService:
|
||||
norm_signals = signals
|
||||
norm_signals['_both_mode'] = False # Explicit 4-signal mode, not both mode
|
||||
elif all(k in signals for k in ['buy', 'sell']):
|
||||
buy = signals['buy'].fillna(False).astype(bool)
|
||||
sell = signals['sell'].fillna(False).astype(bool)
|
||||
# Ensure signals have the same index as df_signal
|
||||
buy_series = signals['buy']
|
||||
sell_series = signals['sell']
|
||||
|
||||
# Reindex to match df_signal.index (fill missing with False)
|
||||
if not buy_series.index.equals(df_signal.index):
|
||||
logger.warning(f"Buy signal index mismatch! Signal index: {buy_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}")
|
||||
buy_series = buy_series.reindex(df_signal.index, fill_value=False)
|
||||
if not sell_series.index.equals(df_signal.index):
|
||||
logger.warning(f"Sell signal index mismatch! Signal index: {sell_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}")
|
||||
sell_series = sell_series.reindex(df_signal.index, fill_value=False)
|
||||
|
||||
buy = buy_series.fillna(False).astype(bool)
|
||||
sell = sell_series.fillna(False).astype(bool)
|
||||
|
||||
# Debug: log signal statistics
|
||||
buy_count = buy.sum()
|
||||
sell_count = sell.sum()
|
||||
logger.info(f"Signal statistics: buy={buy_count}, sell={sell_count}, total_candles={len(df_signal)}")
|
||||
|
||||
td = str(trade_direction or 'both').lower()
|
||||
logger.info(f"Trade direction: {td} (original: {trade_direction})")
|
||||
if td == 'long':
|
||||
norm_signals = {
|
||||
'open_long': buy, 'close_long': sell,
|
||||
'open_short': pd.Series([False] * len(df_signal), index=df_signal.index),
|
||||
'close_short': pd.Series([False] * len(df_signal), index=df_signal.index),
|
||||
'open_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
|
||||
'close_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
|
||||
}
|
||||
elif td == 'short':
|
||||
norm_signals = {
|
||||
'open_long': pd.Series([False] * len(df_signal), index=df_signal.index),
|
||||
'close_long': pd.Series([False] * len(df_signal), index=df_signal.index),
|
||||
'open_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
|
||||
'close_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
|
||||
'open_short': sell, 'close_short': buy,
|
||||
}
|
||||
else:
|
||||
@@ -363,13 +411,15 @@ class BacktestService:
|
||||
# We use special signal types 'enter_long' and 'enter_short' to indicate
|
||||
# that the signal should auto-close opposing position before opening
|
||||
norm_signals = {
|
||||
'open_long': buy, 'close_long': pd.Series([False] * len(df_signal), index=df_signal.index),
|
||||
'open_short': sell, 'close_short': pd.Series([False] * len(df_signal), index=df_signal.index),
|
||||
'open_long': buy, 'close_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
|
||||
'open_short': sell, 'close_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
|
||||
'_both_mode': True # Flag to indicate both mode for special handling
|
||||
}
|
||||
else:
|
||||
raise ValueError("Invalid signal format")
|
||||
|
||||
logger.info("Signal normalization completed, starting signal queue building...")
|
||||
|
||||
# Map signals to execution timeframe
|
||||
# Strategy timeframe seconds (e.g. 1H=3600, 1D=86400)
|
||||
signal_tf_seconds = self.TIMEFRAME_SECONDS.get(signal_timeframe, 3600)
|
||||
@@ -379,24 +429,38 @@ class BacktestService:
|
||||
|
||||
# Preprocessing: create signal queue sorted by effective time
|
||||
# Each signal executes at the open of the next execution candle after its candle closes
|
||||
logger.info("Initializing signal queue...")
|
||||
signal_queue = [] # [(effective_time, signal_type, signal_bar_time), ...]
|
||||
|
||||
# Debug: check signal values
|
||||
debug_signal_counts = {'open_long': 0, 'close_long': 0, 'open_short': 0, 'close_short': 0}
|
||||
|
||||
# Verify all norm_signals have matching index
|
||||
for sig_type in ['open_long', 'close_long', 'open_short', 'close_short']:
|
||||
if not norm_signals[sig_type].index.equals(df_signal.index):
|
||||
logger.error(f"Critical: {sig_type} signal index does not match df_signal.index!")
|
||||
logger.error(f" Signal index: {norm_signals[sig_type].index[:5].tolist()}")
|
||||
logger.error(f" df_signal index: {df_signal.index[:5].tolist()}")
|
||||
# Reindex to fix
|
||||
norm_signals[sig_type] = norm_signals[sig_type].reindex(df_signal.index, fill_value=False)
|
||||
logger.warning(f" Fixed by reindexing {sig_type}")
|
||||
|
||||
for sig_time in df_signal.index:
|
||||
# Signal candle end time = start time + period
|
||||
sig_end = sig_time + timedelta(seconds=signal_tf_seconds)
|
||||
|
||||
# Check if this signal candle has signals
|
||||
# Use .loc[] instead of .get() to be more explicit
|
||||
# All signals should now have matching index, so we can safely use .loc[]
|
||||
try:
|
||||
ol = bool(norm_signals['open_long'].loc[sig_time]) if sig_time in norm_signals['open_long'].index else False
|
||||
cl = bool(norm_signals['close_long'].loc[sig_time]) if sig_time in norm_signals['close_long'].index else False
|
||||
os = bool(norm_signals['open_short'].loc[sig_time]) if sig_time in norm_signals['open_short'].index else False
|
||||
cs = bool(norm_signals['close_short'].loc[sig_time]) if sig_time in norm_signals['close_short'].index else False
|
||||
ol = bool(norm_signals['open_long'].loc[sig_time])
|
||||
cl = bool(norm_signals['close_long'].loc[sig_time])
|
||||
os = bool(norm_signals['open_short'].loc[sig_time])
|
||||
cs = bool(norm_signals['close_short'].loc[sig_time])
|
||||
except (KeyError, IndexError) as e:
|
||||
logger.warning(f"Error accessing signal at {sig_time}: {e}, signal index: {norm_signals['open_long'].index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"Error accessing signal at {sig_time}: {e}")
|
||||
logger.warning(f"Unexpected error accessing signal at {sig_time}: {e}")
|
||||
continue
|
||||
|
||||
if ol:
|
||||
@@ -414,6 +478,22 @@ class BacktestService:
|
||||
|
||||
logger.info(f"Debug signal counts from queue building: {debug_signal_counts}")
|
||||
|
||||
# If no signals found, log detailed diagnostic info
|
||||
if len(signal_queue) == 0:
|
||||
logger.warning("No signals found in signal queue! Diagnostic info:")
|
||||
logger.warning(f" df_signal length: {len(df_signal)}")
|
||||
logger.warning(f" df_signal index range: {df_signal.index[0]} to {df_signal.index[-1]}")
|
||||
for sig_type in ['open_long', 'close_long', 'open_short', 'close_short']:
|
||||
sig_series = norm_signals[sig_type]
|
||||
true_count = sig_series.sum()
|
||||
logger.warning(f" {sig_type}: {true_count} True values out of {len(sig_series)}")
|
||||
if true_count > 0:
|
||||
true_indices = sig_series[sig_series].index.tolist()[:5]
|
||||
logger.warning(f" First few True indices: {true_indices}")
|
||||
# Check if signals might be in wrong format
|
||||
if 'buy' in signals or 'sell' in signals:
|
||||
logger.warning(" Original signals had 'buy'/'sell' keys - check if conversion was correct")
|
||||
|
||||
# Sort by effective time
|
||||
signal_queue.sort(key=lambda x: x[0])
|
||||
signal_queue_idx = 0 # Current signal queue pointer
|
||||
@@ -422,6 +502,8 @@ class BacktestService:
|
||||
if signal_queue:
|
||||
logger.info(f"First signal: {signal_queue[0][1]} @ {signal_queue[0][0]} (from {signal_queue[0][2]})")
|
||||
logger.info(f"Last signal: {signal_queue[-1][1]} @ {signal_queue[-1][0]} (from {signal_queue[-1][2]})")
|
||||
else:
|
||||
logger.error("Signal queue is empty! Backtest will fail. Check indicator code to ensure it generates buy/sell signals.")
|
||||
|
||||
# Count signals by type
|
||||
signal_counts = {}
|
||||
@@ -429,6 +511,12 @@ class BacktestService:
|
||||
signal_counts[sig_type] = signal_counts.get(sig_type, 0) + 1
|
||||
logger.info(f"Signal counts: {signal_counts}")
|
||||
|
||||
# Log first few signal details for debugging
|
||||
if signal_queue:
|
||||
logger.info(f"First 3 signals details:")
|
||||
for idx, (sig_time, sig_type, sig_bar_time) in enumerate(signal_queue[:3]):
|
||||
logger.info(f" Signal {idx+1}: {sig_type} @ effective_time={sig_time}, from_bar={sig_bar_time}")
|
||||
|
||||
# Log execution data range
|
||||
if len(df_exec) > 0:
|
||||
exec_start = df_exec.index[0]
|
||||
@@ -443,7 +531,17 @@ class BacktestService:
|
||||
pending_signal_time = None # Signal effective time
|
||||
executed_trades_count = 0 # Debug counter
|
||||
|
||||
# Progress logging for large datasets
|
||||
total_exec_candles = len(df_exec)
|
||||
progress_log_interval = max(1000, total_exec_candles // 10) # Log every 10% or every 1000 candles
|
||||
|
||||
logger.info(f"Starting execution loop: {total_exec_candles} candles to process, {len(signal_queue)} signals in queue")
|
||||
|
||||
for i, (timestamp, row) in enumerate(df_exec.iterrows()):
|
||||
# Progress logging
|
||||
if i > 0 and i % progress_log_interval == 0:
|
||||
progress_pct = (i / total_exec_candles) * 100
|
||||
logger.info(f"Execution progress: {i}/{total_exec_candles} ({progress_pct:.1f}%), trades={executed_trades_count}, position={position}")
|
||||
# 爆仓后直接停止回测,输出结果
|
||||
if is_liquidated:
|
||||
break
|
||||
@@ -500,13 +598,15 @@ class BacktestService:
|
||||
pending_signal = sig_type
|
||||
pending_signal_time = sig_effective_time
|
||||
signal_queue_idx += 1
|
||||
if executed_trades_count < 5:
|
||||
logger.info(f"Signal ready: {sig_type} @ {timestamp}, will execute at open price (both_mode={both_mode_active})")
|
||||
if executed_trades_count < 5 or signal_queue_idx <= 5:
|
||||
logger.info(f"Signal ready: {sig_type} @ {timestamp} (effective_time={sig_effective_time}, sig_bar_time={sig_bar_time}), "
|
||||
f"will execute at open price (both_mode={both_mode_active}, position={position})")
|
||||
break
|
||||
else:
|
||||
# Signal doesn't meet execution conditions, skip
|
||||
if signal_queue_idx < 5:
|
||||
logger.info(f"Skipping signal #{signal_queue_idx}: {sig_type} (position={position}, can_execute=False)")
|
||||
if signal_queue_idx < 10:
|
||||
logger.info(f"Skipping signal #{signal_queue_idx}: {sig_type} @ {sig_effective_time} "
|
||||
f"(position={position}, can_execute=False, both_mode={both_mode_active})")
|
||||
signal_queue_idx += 1
|
||||
continue
|
||||
else:
|
||||
@@ -713,6 +813,8 @@ class BacktestService:
|
||||
# 2. Execute pending signal (at open price)
|
||||
if pending_signal and path_price == open_:
|
||||
both_mode_active = norm_signals.get('_both_mode', False)
|
||||
if executed_trades_count < 10:
|
||||
logger.info(f"Executing pending signal: {pending_signal} @ {timestamp}, path_price={path_price}, open={open_}, position={position}")
|
||||
|
||||
# open_long: In both mode, first close short if any, then open long
|
||||
if pending_signal == 'open_long' and (position == 0 or (both_mode_active and position < 0)):
|
||||
@@ -909,9 +1011,25 @@ class BacktestService:
|
||||
})
|
||||
|
||||
# Summary log
|
||||
logger.info(f"MTF simulation complete: executed_trades={executed_trades_count}, total_trades_recorded={len(trades)}, final_capital={capital:.2f}")
|
||||
logger.info(f"MTF simulation complete: executed_trades={executed_trades_count}, total_trades_recorded={len(trades)}, final_capital={capital:.2f}, final_position={position}")
|
||||
if len(trades) == 0:
|
||||
logger.warning(f"No trades executed! signal_queue_idx={signal_queue_idx}, total_signals={len(signal_queue)}")
|
||||
if len(signal_queue) == 0:
|
||||
logger.error(f"No trades executed because signal queue is empty! This usually means:")
|
||||
logger.error(" 1. Indicator code did not generate any buy/sell signals")
|
||||
logger.error(" 2. Signal index mismatch between indicator output and df_signal")
|
||||
logger.error(" 3. All signal values are False")
|
||||
raise ValueError("No signals generated by indicator code. Please check your indicator code to ensure it sets df['buy'] and/or df['sell'] columns with boolean values.")
|
||||
else:
|
||||
logger.error(f"No trades executed despite {len(signal_queue)} signals in queue. signal_queue_idx={signal_queue_idx}")
|
||||
logger.error(f" Signal queue processed: {signal_queue_idx}/{len(signal_queue)}")
|
||||
logger.error(f" Final position: {position}, Final capital: {capital:.2f}")
|
||||
logger.error(" This may indicate:")
|
||||
logger.error(" 1. Signal timing issues (signal effective time doesn't match execution timeframe)")
|
||||
logger.error(" 2. Position state conflicts (signals skipped due to position state)")
|
||||
logger.error(" 3. Capital insufficient for trading")
|
||||
logger.error(f" First few signals: {signal_queue[:min(5, len(signal_queue))]}")
|
||||
logger.error(f" Exec data range: {df_exec.index[0]} to {df_exec.index[-1]}")
|
||||
raise ValueError(f"No trades executed despite {len(signal_queue)} signals. Check signal timing and position state logic.")
|
||||
|
||||
return equity_curve, trades, total_commission_paid
|
||||
|
||||
@@ -1057,28 +1175,70 @@ class BacktestService:
|
||||
)
|
||||
|
||||
if not kline_data:
|
||||
logger.warning("No candle data retrieved")
|
||||
logger.warning(f"No candle data retrieved for {market}:{symbol}, timeframe={timeframe}, limit={limit}, before_time={before_time}")
|
||||
return pd.DataFrame()
|
||||
|
||||
if kline_data:
|
||||
first_time = datetime.fromtimestamp(kline_data[0]['time'])
|
||||
last_time = datetime.fromtimestamp(kline_data[-1]['time'])
|
||||
logger.info(f"Retrieved {len(kline_data)} candles for {market}:{symbol}, timeframe={timeframe}")
|
||||
|
||||
# Convert to DataFrame
|
||||
df = pd.DataFrame(kline_data)
|
||||
df['time'] = pd.to_datetime(df['time'], unit='s')
|
||||
df = df.set_index('time')
|
||||
|
||||
if len(df) > 0:
|
||||
pass
|
||||
|
||||
# Filter date range
|
||||
df = df[(df.index >= start_date) & (df.index <= end_date)].copy()
|
||||
|
||||
if len(df) > 0:
|
||||
pass
|
||||
|
||||
return df
|
||||
try:
|
||||
df = pd.DataFrame(kline_data)
|
||||
if df.empty:
|
||||
logger.warning(f"DataFrame is empty after conversion")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Handle time column - could be seconds or milliseconds
|
||||
if 'time' not in df.columns:
|
||||
logger.error(f"Missing 'time' column in kline data. Columns: {df.columns.tolist()}")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Try seconds first, if fails try milliseconds
|
||||
try:
|
||||
df['time'] = pd.to_datetime(df['time'], unit='s')
|
||||
except (ValueError, OverflowError):
|
||||
# If seconds fails, try milliseconds
|
||||
try:
|
||||
df['time'] = pd.to_datetime(df['time'], unit='ms')
|
||||
except (ValueError, OverflowError):
|
||||
# If both fail, try direct conversion
|
||||
df['time'] = pd.to_datetime(df['time'])
|
||||
|
||||
df = df.set_index('time')
|
||||
|
||||
if df.empty:
|
||||
logger.warning(f"DataFrame is empty after setting time index")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Log data range before filtering
|
||||
data_start = df.index.min()
|
||||
data_end = df.index.max()
|
||||
logger.info(f"Kline data range: {data_start} to {data_end}, requested range: {start_date} to {end_date}")
|
||||
|
||||
# Check if requested range is within available data
|
||||
if data_start > start_date:
|
||||
logger.warning(f"Requested start date {start_date} is before available data start {data_start}. "
|
||||
f"Using available start date instead.")
|
||||
if data_end < end_date:
|
||||
logger.warning(f"Requested end date {end_date} is after available data end {data_end}. "
|
||||
f"Using available end date instead. This may affect backtest results.")
|
||||
|
||||
# Filter date range (use available data range if requested range is outside)
|
||||
effective_start = max(start_date, data_start)
|
||||
effective_end = min(end_date, data_end)
|
||||
df_filtered = df[(df.index >= effective_start) & (df.index <= effective_end)].copy()
|
||||
|
||||
if df_filtered.empty:
|
||||
logger.error(f"After filtering date range ({effective_start} to {effective_end}), no data remains. "
|
||||
f"Available data range: {data_start} to {data_end}, requested: {start_date} to {end_date}")
|
||||
return pd.DataFrame()
|
||||
|
||||
logger.info(f"After filtering: {len(df_filtered)} candles remain for backtest (effective range: {effective_start} to {effective_end})")
|
||||
return df_filtered
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing kline data: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return pd.DataFrame()
|
||||
|
||||
def _execute_indicator(self, code: str, df: pd.DataFrame, backtest_params: dict = None):
|
||||
"""Execute indicator code to get signals.
|
||||
@@ -1213,9 +1373,25 @@ import pandas as pd
|
||||
# Position sizing, TP/SL, trailing, etc must be handled by strategy_config / strategy logic.
|
||||
elif all(col in executed_df.columns for col in ['buy', 'sell']):
|
||||
# Simple buy/sell signals (recommended for indicator authors)
|
||||
buy_series = executed_df['buy'].fillna(False).astype(bool)
|
||||
sell_series = executed_df['sell'].fillna(False).astype(bool)
|
||||
|
||||
# Ensure signals have the same index as df
|
||||
if not buy_series.index.equals(df.index):
|
||||
logger.warning(f"Buy signal index mismatch in _execute_indicator! Reindexing...")
|
||||
buy_series = buy_series.reindex(df.index, fill_value=False)
|
||||
if not sell_series.index.equals(df.index):
|
||||
logger.warning(f"Sell signal index mismatch in _execute_indicator! Reindexing...")
|
||||
sell_series = sell_series.reindex(df.index, fill_value=False)
|
||||
|
||||
# Debug: log signal statistics
|
||||
buy_count = buy_series.sum()
|
||||
sell_count = sell_series.sum()
|
||||
logger.info(f"Indicator execution: buy signals={buy_count}, sell signals={sell_count}, total_candles={len(df)}")
|
||||
|
||||
signals = {
|
||||
'buy': executed_df['buy'].fillna(False).astype(bool),
|
||||
'sell': executed_df['sell'].fillna(False).astype(bool)
|
||||
'buy': buy_series,
|
||||
'sell': sell_series
|
||||
}
|
||||
|
||||
else:
|
||||
|
||||
@@ -46,6 +46,7 @@ class FastAnalysisService:
|
||||
2. 基本面: 公司信息、财务数据
|
||||
3. 宏观数据: DXY、VIX、TNX、黄金等
|
||||
4. 情绪数据: 新闻、市场情绪
|
||||
5. 预测市场: 相关预测市场事件(新增)
|
||||
"""
|
||||
return self.data_collector.collect_all(
|
||||
market=market,
|
||||
@@ -53,6 +54,7 @@ class FastAnalysisService:
|
||||
timeframe=timeframe,
|
||||
include_macro=True,
|
||||
include_news=True,
|
||||
include_polymarket=True, # 包含预测市场数据
|
||||
timeout=30
|
||||
)
|
||||
|
||||
@@ -196,6 +198,19 @@ class FastAnalysisService:
|
||||
|
||||
return "\n".join(summaries) if summaries else "No recent news available."
|
||||
|
||||
def _format_polymarket_summary(self, polymarket_events: List[Dict], max_items: int = 3) -> str:
|
||||
"""Format prediction market events into a concise summary for the prompt."""
|
||||
if not polymarket_events:
|
||||
return "No related prediction market events found."
|
||||
|
||||
summaries = []
|
||||
for event in polymarket_events[:max_items]:
|
||||
question = event.get('question', '')
|
||||
prob = event.get('current_probability', 50.0)
|
||||
summaries.append(f"- {question[:80]}: Market probability {prob:.1f}%")
|
||||
|
||||
return "\n".join(summaries) if summaries else "No related prediction market events found."
|
||||
|
||||
# ==================== Memory Layer ====================
|
||||
|
||||
def _get_memory_context(self, market: str, symbol: str, current_indicators: Dict) -> str:
|
||||
@@ -247,6 +262,7 @@ class FastAnalysisService:
|
||||
fundamental = data.get("fundamental") or {}
|
||||
company = data.get("company") or {}
|
||||
news_summary = self._format_news_summary(data.get("news") or [])
|
||||
polymarket_events = data.get("polymarket") or []
|
||||
|
||||
# Language instruction - MUST be enforced strictly
|
||||
lang_map = {
|
||||
@@ -349,22 +365,31 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe
|
||||
- Consider geopolitical events and their potential impact
|
||||
- Evaluate how macro trends affect this specific market/symbol
|
||||
3. **News & Event Analysis**:
|
||||
- **CRITICAL**: Pay special attention to GEOPOLITICAL EVENTS (wars, conflicts, military actions, sanctions)
|
||||
- These events can cause sudden and severe market movements, especially for crypto and global markets
|
||||
- Identify BREAKING NEWS or major events that could cause sudden moves
|
||||
- Assess news sentiment and its credibility
|
||||
- Consider regulatory changes, partnerships, scandals, etc.
|
||||
- Don't ignore major news just because technical indicators look good
|
||||
4. **Fundamental Analysis**: Evaluate valuation, growth, competitive position if data available. If data is insufficient, say so.
|
||||
5. **Risk Assessment**:
|
||||
- Consider regulatory changes, partnerships, scandals, geopolitical tensions, etc.
|
||||
- **DO NOT ignore major geopolitical news** (e.g., US-Iran conflict, Russia-Ukraine war) even if technical indicators look good
|
||||
- Global events like wars can override all technical analysis - treat them as HIGHEST PRIORITY
|
||||
4. **Prediction Market Analysis**:
|
||||
- Review related prediction market events and their current probabilities
|
||||
- Prediction markets reflect collective market wisdom and can indicate future price movements
|
||||
- If prediction markets show high probability for bullish events (e.g., "BTC reaches $100k"), consider this as a positive signal
|
||||
- If prediction markets show high probability for bearish events, consider this as a risk factor
|
||||
- Use prediction market probabilities as a sentiment indicator alongside technical analysis
|
||||
5. **Fundamental Analysis**: Evaluate valuation, growth, competitive position if data available. If data is insufficient, say so.
|
||||
6. **Risk Assessment**:
|
||||
- Explain why the stop loss level is appropriate
|
||||
- List ALL significant risks (technical, macro, news, fundamental)
|
||||
- Consider tail risks from unexpected events
|
||||
6. **Clear Recommendation**: BUY/SELL/HOLD with entry, stop loss (near suggested), take profit (near suggested)
|
||||
7. **Clear Recommendation**: BUY/SELL/HOLD with entry, stop loss (near suggested), take profit (near suggested)
|
||||
- **BUY**: For long positions when indicators suggest upside
|
||||
- **SELL**: For short positions when indicators suggest downside - this is a VALID trading opportunity
|
||||
- **HOLD**: Only when signals are truly unclear - DO NOT default to HOLD just to be safe
|
||||
- Your decision should reflect the WEIGHTED importance of ALL factors
|
||||
- If macro/news factors strongly contradict technical, explain why you prioritize one over the other
|
||||
7. **Trading Opportunity Recognition**:
|
||||
8. **Trading Opportunity Recognition**:
|
||||
- When you see RSI > 60, bearish MACD, downtrend → Give SELL signal (short opportunity)
|
||||
- When you see RSI < 40, bullish MACD, uptrend → Give BUY signal (long opportunity)
|
||||
- Only choose HOLD when signals are genuinely mixed or unclear
|
||||
@@ -397,14 +422,16 @@ Output ONLY valid JSON (do NOT include word counts or format hints in your actua
|
||||
- Do NOT make up facts or exaggerate. Base everything on the provided data.
|
||||
|
||||
📊 OBJECTIVE SCORING SYSTEM (Reference):
|
||||
The system will calculate an objective score based on technical indicators, fundamentals, sentiment, and macro factors.
|
||||
- Score >= +40: Bullish signal → BUY recommended
|
||||
- Score <= -40: Bearish signal → SELL recommended
|
||||
- Score between -40 and +40: Neutral → HOLD recommended
|
||||
The system will calculate an objective score based on technical indicators, fundamentals, sentiment (including geopolitical events), and macro factors.
|
||||
- Score >= +20: Bullish signal → BUY recommended
|
||||
- Score <= -20: Bearish signal → SELL recommended
|
||||
- Score between -20 and +20: Neutral → HOLD recommended (narrow range)
|
||||
- Score >= +70: Strong bullish → Strong BUY signal
|
||||
- Score <= -70: Strong bearish → Strong SELL signal
|
||||
Your decision should align with this objective score when it's significant (>=40 or <=-40).
|
||||
When the score is neutral (-40 to +40), you can use your judgment, but still consider giving BUY/SELL if technical indicators are clear."""
|
||||
- Geopolitical events (wars, conflicts) are heavily weighted in sentiment score and can cause severe negative scores
|
||||
- Macro factors (VIX, DXY, interest rates) are also heavily weighted
|
||||
Your decision should align with this objective score when it's significant (>=20 or <=-20).
|
||||
When the score is neutral (-20 to +20), you can use your judgment, but still consider giving BUY/SELL if technical indicators are clear."""
|
||||
|
||||
# Format indicator data for prompt (ensure safe defaults)
|
||||
rsi_data = indicators.get("rsi") or {}
|
||||
@@ -439,6 +466,9 @@ When the score is neutral (-40 to +40), you can use your judgment, but still con
|
||||
📰 MARKET NEWS ({len(data.get('news') or [])} items):
|
||||
{news_summary}
|
||||
|
||||
🎯 PREDICTION MARKETS ({len(polymarket_events)} related events):
|
||||
{self._format_polymarket_summary(polymarket_events)}
|
||||
|
||||
💼 FUNDAMENTALS:
|
||||
- Company: {company.get('name', data['symbol'])}
|
||||
- Industry: {company.get('industry', 'N/A')}
|
||||
@@ -460,10 +490,12 @@ When the score is neutral (-40 to +40), you can use your judgment, but still con
|
||||
{self._format_earnings_data(fundamental.get('earnings', {}))}
|
||||
|
||||
IMPORTANT:
|
||||
1. Consider the macro environment (especially DXY, VIX, rates, geopolitical events) when making your recommendation.
|
||||
2. Pay attention to BREAKING NEWS and international events that could cause sudden market moves.
|
||||
3. For US stocks, analyze financial statements and earnings trends to assess company health.
|
||||
4. Provide your analysis now. Remember: all prices must be within 10% of ${current_price}."""
|
||||
1. **CRITICAL**: Check for GEOPOLITICAL EVENTS (wars, conflicts, military actions) in the news section. These events have HIGHEST PRIORITY and can override all technical indicators.
|
||||
2. Consider the macro environment (especially DXY, VIX, rates, geopolitical events) when making your recommendation.
|
||||
3. Pay attention to BREAKING NEWS and international events that could cause sudden market moves. Geopolitical tensions (e.g., US-Iran conflict) can cause severe market volatility.
|
||||
4. For US stocks, analyze financial statements and earnings trends to assess company health.
|
||||
5. If you see news about wars, conflicts, or major geopolitical events, you MUST mention them in your analysis and adjust your recommendation accordingly.
|
||||
6. Provide your analysis now. Remember: all prices must be within 10% of ${current_price}."""
|
||||
|
||||
return system_prompt, user_prompt
|
||||
|
||||
@@ -745,8 +777,8 @@ IMPORTANT:
|
||||
llm_decision = analysis.get("decision", "HOLD")
|
||||
if llm_decision != score_based_decision:
|
||||
score_abs = abs(objective_score['overall_score'])
|
||||
# 降低阈值,因为现在HOLD区间更小了,±40以上的评分就应该覆盖
|
||||
if score_abs >= 25: # 如果评分达到±25以上,就覆盖LLM决策(因为阈值是±40)
|
||||
# 降低阈值,因为现在HOLD区间更小了(±20),±15以上的评分就应该覆盖
|
||||
if score_abs >= 15: # 如果评分达到±15以上,就覆盖LLM决策(因为阈值是±20)
|
||||
logger.warning(f"LLM decision '{llm_decision}' conflicts with score-based decision '{score_based_decision}' (score: {objective_score['overall_score']:.1f}). Overriding to score-based decision.")
|
||||
analysis["decision"] = score_based_decision
|
||||
# Adjust confidence based on score strength
|
||||
@@ -908,24 +940,50 @@ IMPORTANT:
|
||||
def _has_major_news(self, news_data: List[Dict]) -> bool:
|
||||
"""
|
||||
检查是否有重大新闻事件。
|
||||
重大新闻包括:监管变化、重大合作、丑闻、重大政策等。
|
||||
重大新闻包括:监管变化、重大合作、丑闻、重大政策、地缘政治事件等。
|
||||
"""
|
||||
if not news_data:
|
||||
return False
|
||||
|
||||
# 检查新闻标题中的关键词
|
||||
# 检查新闻标题中的关键词(扩展了地缘政治相关关键词)
|
||||
major_keywords = [
|
||||
"regulation", "regulatory", "ban", "approval", "partnership", "merger", "acquisition",
|
||||
"scandal", "lawsuit", "investigation", "policy", "government", "central bank",
|
||||
"监管", "禁令", "批准", "合作", "合并", "收购", "丑闻", "诉讼", "调查", "政策", "政府", "央行"
|
||||
# 监管和政策
|
||||
"regulation", "regulatory", "ban", "approval", "policy", "government", "central bank",
|
||||
"监管", "禁令", "批准", "政策", "政府", "央行",
|
||||
# 商业事件
|
||||
"partnership", "merger", "acquisition", "scandal", "lawsuit", "investigation",
|
||||
"合作", "合并", "收购", "丑闻", "诉讼", "调查",
|
||||
# 地缘政治事件(新增)
|
||||
"war", "conflict", "military", "attack", "strike", "sanctions", "tension", "crisis",
|
||||
"geopolitical", "iran", "israel", "russia", "ukraine", "china", "taiwan", "north korea",
|
||||
"middle east", "gulf", "nato", "united states", "us", "usa", "america",
|
||||
"战争", "冲突", "军事", "袭击", "打击", "制裁", "紧张", "危机",
|
||||
"地缘政治", "伊朗", "以色列", "俄罗斯", "乌克兰", "中国", "台湾", "朝鲜",
|
||||
"中东", "海湾", "北约", "美国"
|
||||
]
|
||||
|
||||
for news in news_data[:5]: # 只检查前5条最新新闻
|
||||
for news in news_data[:10]: # 检查前10条最新新闻(增加检查范围)
|
||||
title = (news.get("title") or news.get("headline") or "").lower()
|
||||
summary = (news.get("summary") or "").lower()
|
||||
sentiment = news.get("sentiment", "neutral")
|
||||
|
||||
# 如果有重大关键词且情绪强烈(非中性),认为是重大新闻
|
||||
if any(keyword in title for keyword in major_keywords) and sentiment != "neutral":
|
||||
# 检查标题和摘要中是否包含重大关键词
|
||||
text_to_check = f"{title} {summary}"
|
||||
|
||||
# 地缘政治事件通常很严重,即使情绪是中性也要识别
|
||||
geopolitical_keywords = [
|
||||
"war", "conflict", "military", "attack", "strike", "geopolitical",
|
||||
"战争", "冲突", "军事", "袭击", "打击", "地缘政治"
|
||||
]
|
||||
|
||||
# 如果是地缘政治相关,直接认为是重大新闻
|
||||
if any(keyword in text_to_check for keyword in geopolitical_keywords):
|
||||
logger.info(f"Detected major geopolitical event in news: {title[:60]}")
|
||||
return True
|
||||
|
||||
# 其他重大关键词且情绪强烈(非中性),认为是重大新闻
|
||||
if any(keyword in text_to_check for keyword in major_keywords) and sentiment != "neutral":
|
||||
logger.info(f"Detected major news event: {title[:60]}")
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1061,7 +1119,8 @@ IMPORTANT:
|
||||
conflicts.append("MACD bearish")
|
||||
|
||||
# 均线趋势向下时不应该BUY(除非有重大利好)
|
||||
if "downtrend" in ma_trend.lower():
|
||||
# 只有当趋势非常强烈时才认为是冲突(避免过于敏感)
|
||||
if "strong_downtrend" in ma_trend.lower() or ("downtrend" in ma_trend.lower() and rsi_value > 50):
|
||||
conflicts.append(f"MA trend: {ma_trend}")
|
||||
|
||||
if conflicts:
|
||||
@@ -1140,12 +1199,13 @@ IMPORTANT:
|
||||
macro_score = self._calculate_macro_score(macro, data.get("market", ""))
|
||||
|
||||
# 5. 综合评分(加权平均)
|
||||
# 权重:技术40%,基本面25%,情绪20%,宏观15%
|
||||
# 优化权重:技术35%,基本面20%,情绪25%(包含地缘政治),宏观20%(提高宏观权重)
|
||||
# 提高情绪和宏观权重,因为地缘政治和宏观经济因素对市场影响更大
|
||||
overall_score = (
|
||||
technical_score * 0.40 +
|
||||
fundamental_score * 0.25 +
|
||||
sentiment_score * 0.20 +
|
||||
macro_score * 0.15
|
||||
technical_score * 0.35 +
|
||||
fundamental_score * 0.20 +
|
||||
sentiment_score * 0.25 + # 提高情绪权重,包含地缘政治事件
|
||||
macro_score * 0.20 # 提高宏观权重
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -1318,16 +1378,49 @@ IMPORTANT:
|
||||
return max(-100, min(100, score))
|
||||
|
||||
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
|
||||
"""计算新闻情绪评分 (-100 to +100)"""
|
||||
"""
|
||||
计算新闻情绪评分 (-100 to +100)
|
||||
包含地缘政治事件的特殊处理
|
||||
"""
|
||||
if not news:
|
||||
return 0.0 # 无新闻,中性
|
||||
|
||||
positive_count = 0
|
||||
negative_count = 0
|
||||
neutral_count = 0
|
||||
geopolitical_penalty = 0 # 地缘政治事件惩罚分数
|
||||
geopolitical_count = 0 # 地缘政治事件数量
|
||||
|
||||
for item in news[:10]: # 只看前10条
|
||||
# 地缘政治关键词
|
||||
geopolitical_keywords = [
|
||||
"war", "conflict", "military", "attack", "strike", "sanctions",
|
||||
"geopolitical", "crisis", "tension", "iran", "israel", "russia",
|
||||
"ukraine", "middle east", "nato", "united states",
|
||||
"战争", "冲突", "军事", "袭击", "制裁", "地缘政治", "危机"
|
||||
]
|
||||
|
||||
for item in news[:15]: # 检查前15条新闻
|
||||
title = (item.get("headline") or item.get("title") or "").lower()
|
||||
summary = (item.get("summary") or "").lower()
|
||||
text = f"{title} {summary}"
|
||||
sentiment = item.get("sentiment", "neutral")
|
||||
is_global_event = item.get("is_global_event", False)
|
||||
|
||||
# 检查是否是地缘政治事件
|
||||
is_geopolitical = is_global_event or any(keyword in text for keyword in geopolitical_keywords)
|
||||
|
||||
if is_geopolitical:
|
||||
geopolitical_count += 1
|
||||
# 地缘政治事件通常是利空的,给予严重惩罚
|
||||
if any(kw in text for kw in ["war", "conflict", "attack", "strike", "战争", "冲突", "袭击", "打击"]):
|
||||
geopolitical_penalty -= 50 # 战争/冲突事件严重利空
|
||||
elif any(kw in text for kw in ["sanctions", "crisis", "tension", "制裁", "危机", "紧张"]):
|
||||
geopolitical_penalty -= 30 # 制裁/危机事件利空
|
||||
else:
|
||||
geopolitical_penalty -= 20 # 其他地缘政治事件利空
|
||||
logger.info(f"Detected geopolitical event in sentiment scoring: {title[:60]}, penalty: {geopolitical_penalty}")
|
||||
|
||||
# 统计普通新闻情绪
|
||||
if sentiment == "positive":
|
||||
positive_count += 1
|
||||
elif sentiment == "negative":
|
||||
@@ -1336,66 +1429,97 @@ IMPORTANT:
|
||||
neutral_count += 1
|
||||
|
||||
total = positive_count + negative_count + neutral_count
|
||||
if total == 0:
|
||||
return 0.0
|
||||
|
||||
# 计算净情绪
|
||||
net_sentiment = (positive_count - negative_count) / total
|
||||
# 计算净情绪(普通新闻)
|
||||
if total > 0:
|
||||
net_sentiment = (positive_count - negative_count) / total
|
||||
base_score = net_sentiment * 60 # 基础情绪分数(-60到+60)
|
||||
else:
|
||||
base_score = 0
|
||||
|
||||
# 映射到-100到+100
|
||||
score = net_sentiment * 100
|
||||
# 地缘政治事件惩罚(如果有地缘政治事件,直接应用惩罚)
|
||||
if geopolitical_count > 0:
|
||||
# 地缘政治事件的影响权重很高,直接叠加惩罚
|
||||
final_score = base_score + geopolitical_penalty
|
||||
logger.info(f"Sentiment score: base={base_score:.1f}, geopolitical_penalty={geopolitical_penalty}, final={final_score:.1f}")
|
||||
else:
|
||||
final_score = base_score
|
||||
|
||||
return max(-100, min(100, score))
|
||||
return max(-100, min(100, final_score))
|
||||
|
||||
def _calculate_macro_score(self, macro: Dict, market: str) -> float:
|
||||
"""计算宏观环境评分 (-100 to +100)"""
|
||||
"""
|
||||
计算宏观环境评分 (-100 to +100)
|
||||
包含VIX、DXY、利率等宏观经济指标
|
||||
"""
|
||||
if not macro:
|
||||
return 0.0 # 无宏观数据,中性
|
||||
|
||||
score = 0.0
|
||||
factors = 0
|
||||
|
||||
# VIX 评分(恐慌指数)
|
||||
# VIX 评分(恐慌指数)- 权重提高
|
||||
vix = macro.get("VIX", {})
|
||||
vix_value = vix.get("price", 0)
|
||||
if vix_value > 0:
|
||||
if vix_value > 30:
|
||||
vix_score = -30 # 高恐慌,利空
|
||||
if vix_value > 35:
|
||||
vix_score = -50 # 极高恐慌(如战争期间),严重利空
|
||||
elif vix_value > 30:
|
||||
vix_score = -40 # 高恐慌,严重利空
|
||||
elif vix_value > 25:
|
||||
vix_score = -30 # 较高恐慌,利空
|
||||
elif vix_value > 20:
|
||||
vix_score = -15
|
||||
vix_score = -15 # 中等恐慌,轻微利空
|
||||
elif vix_value < 12:
|
||||
vix_score = +20 # 低恐慌,利多
|
||||
elif vix_value < 15:
|
||||
vix_score = +15 # 低恐慌,利多
|
||||
vix_score = +10 # 较低恐慌,轻微利多
|
||||
else:
|
||||
vix_score = 0
|
||||
score += vix_score
|
||||
factors += 1
|
||||
|
||||
# DXY 评分(美元指数)
|
||||
# DXY 评分(美元指数)- 权重提高
|
||||
dxy = macro.get("DXY", {})
|
||||
dxy_value = dxy.get("price", 0)
|
||||
dxy_change = dxy.get("changePercent", 0)
|
||||
if dxy_value > 0:
|
||||
# 对于加密货币和商品,强美元通常是利空
|
||||
if market in ["Crypto", "Forex", "Futures"]:
|
||||
if dxy_change > 1:
|
||||
if dxy_change > 2:
|
||||
dxy_score = -30 # 美元大幅走强,严重利空
|
||||
elif dxy_change > 1:
|
||||
dxy_score = -20 # 美元走强,利空
|
||||
elif dxy_change < -2:
|
||||
dxy_score = +30 # 美元大幅走弱,利多
|
||||
elif dxy_change < -1:
|
||||
dxy_score = +20 # 美元走弱,利多
|
||||
else:
|
||||
dxy_score = 0
|
||||
else:
|
||||
dxy_score = 0 # 对股票影响较小
|
||||
# 对股票也有影响,但较小
|
||||
if dxy_change > 2:
|
||||
dxy_score = -10
|
||||
elif dxy_change < -2:
|
||||
dxy_score = +10
|
||||
else:
|
||||
dxy_score = 0
|
||||
score += dxy_score
|
||||
factors += 1
|
||||
|
||||
# 利率评分(TNX)
|
||||
# 利率评分(TNX)- 权重提高
|
||||
tnx = macro.get("TNX", {})
|
||||
tnx_change = tnx.get("changePercent", 0)
|
||||
if tnx_change != 0:
|
||||
tnx_value = tnx.get("price", 0)
|
||||
if tnx_change != 0 or tnx_value > 0:
|
||||
# 利率上升对成长股和加密货币通常是利空
|
||||
if market in ["Crypto", "USStock"]:
|
||||
if tnx_change > 2:
|
||||
tnx_score = -20 # 利率大幅上升,利空
|
||||
if tnx_change > 3:
|
||||
tnx_score = -30 # 利率大幅上升,严重利空
|
||||
elif tnx_change > 2:
|
||||
tnx_score = -20 # 利率上升,利空
|
||||
elif tnx_change < -3:
|
||||
tnx_score = +30 # 利率大幅下降,利多
|
||||
elif tnx_change < -2:
|
||||
tnx_score = +20 # 利率下降,利多
|
||||
else:
|
||||
@@ -1405,9 +1529,12 @@ IMPORTANT:
|
||||
score += tnx_score
|
||||
factors += 1
|
||||
|
||||
# 归一化
|
||||
# 归一化(考虑权重)
|
||||
if factors > 0:
|
||||
score = score / factors * 100 / 3 # 最大可能分数是3个因素各30分=90,归一化到100
|
||||
# 最大可能分数:VIX(-50~+20), DXY(-30~+30), TNX(-30~+30) = 约-110到+80
|
||||
# 归一化到-100到+100
|
||||
max_possible = 110 # 最大绝对值
|
||||
score = score / max_possible * 100
|
||||
|
||||
return max(-100, min(100, score))
|
||||
|
||||
@@ -1415,24 +1542,26 @@ IMPORTANT:
|
||||
"""
|
||||
根据客观评分转换为决策
|
||||
|
||||
优化后的阈值(缩小HOLD区间,使决策更明确):
|
||||
- score >= +40: BUY(利多)
|
||||
- score <= -40: SELL(利空)
|
||||
- -40 < score < +40: HOLD(中性)
|
||||
优化后的阈值(大幅缩小HOLD区间,使决策更明确):
|
||||
- score >= +20: BUY(利多)
|
||||
- score <= -20: SELL(利空)
|
||||
- -20 < score < +20: HOLD(中性)
|
||||
|
||||
分级决策(可选,用于更细粒度的判断):
|
||||
分级决策(用于更细粒度的判断):
|
||||
- score >= +70: 强烈BUY
|
||||
- +40 <= score < +70: BUY
|
||||
- +10 < score < +40: 弱利多(倾向于BUY,但可HOLD)
|
||||
- -10 <= score <= +10: 中性HOLD
|
||||
- -40 < score < -10: 弱利空(倾向于SELL,但可HOLD)
|
||||
- -70 < score <= -40: SELL
|
||||
- +40 <= score < +70: 明显BUY
|
||||
- +20 <= score < +40: BUY
|
||||
- +10 < score < +20: 弱利多(倾向于BUY,但可HOLD)
|
||||
- -10 <= score <= +10: 中性HOLD(真正的中性区间)
|
||||
- -20 < score < -10: 弱利空(倾向于SELL,但可HOLD)
|
||||
- -40 < score <= -20: SELL
|
||||
- -70 < score <= -40: 明显SELL
|
||||
- score <= -70: 强烈SELL
|
||||
"""
|
||||
# 使用±40作为主要阈值,缩小HOLD区间
|
||||
if score >= 40:
|
||||
# 使用±20作为主要阈值,大幅缩小HOLD区间
|
||||
if score >= 20:
|
||||
return "BUY"
|
||||
elif score <= -40:
|
||||
elif score <= -20:
|
||||
return "SELL"
|
||||
else:
|
||||
return "HOLD"
|
||||
|
||||
@@ -74,6 +74,7 @@ class MarketDataCollector:
|
||||
timeframe: str = "1D",
|
||||
include_macro: bool = True,
|
||||
include_news: bool = True,
|
||||
include_polymarket: bool = True, # 新增:是否包含预测市场数据
|
||||
timeout: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -85,6 +86,7 @@ class MarketDataCollector:
|
||||
timeframe: K线周期
|
||||
include_macro: 是否包含宏观数据
|
||||
include_news: 是否包含新闻
|
||||
include_polymarket: 是否包含预测市场数据
|
||||
timeout: 总超时时间(秒)
|
||||
|
||||
Returns:
|
||||
@@ -109,6 +111,8 @@ class MarketDataCollector:
|
||||
# 情绪
|
||||
"news": [],
|
||||
"sentiment": {},
|
||||
# 预测市场
|
||||
"polymarket": [],
|
||||
# 元数据
|
||||
"_meta": {
|
||||
"success_items": [],
|
||||
@@ -181,6 +185,17 @@ class MarketDataCollector:
|
||||
logger.warning(f"News fetch failed: {e}")
|
||||
data["_meta"]["failed_items"].append("news")
|
||||
|
||||
# === 阶段4: 预测市场数据 (如果需要) ===
|
||||
if include_polymarket:
|
||||
try:
|
||||
polymarket_events = self._get_polymarket_events(symbol, market)
|
||||
data["polymarket"] = polymarket_events
|
||||
if polymarket_events:
|
||||
data["_meta"]["success_items"].append("polymarket")
|
||||
except Exception as e:
|
||||
logger.debug(f"Polymarket data fetch failed: {e}")
|
||||
data["_meta"]["failed_items"].append("polymarket")
|
||||
|
||||
# 记录总耗时
|
||||
data["_meta"]["duration_ms"] = int((time.time() - start_time) * 1000)
|
||||
logger.info(f"Market data collection completed for {market}:{symbol} in {data['_meta']['duration_ms']}ms")
|
||||
@@ -1045,6 +1060,13 @@ class MarketDataCollector:
|
||||
search_news = self._get_news_from_search(market, symbol, company_name)
|
||||
news_list.extend(search_news)
|
||||
|
||||
# === 4) 获取全球重大事件新闻(地缘政治、战争等) ===
|
||||
# 这些事件会影响所有市场,特别是加密货币
|
||||
global_events = self._get_global_major_events()
|
||||
if global_events:
|
||||
news_list.extend(global_events)
|
||||
logger.info(f"Added {len(global_events)} global major events to news list")
|
||||
|
||||
# 去重(按标题)
|
||||
seen_titles = set()
|
||||
unique_news = []
|
||||
@@ -1105,6 +1127,171 @@ class MarketDataCollector:
|
||||
logger.debug(f"搜索引擎新闻获取失败: {e}")
|
||||
|
||||
return news_list
|
||||
|
||||
def _get_global_major_events(self) -> List[Dict]:
|
||||
"""
|
||||
获取全球重大事件新闻(地缘政治、战争、重大政策等)
|
||||
这些事件会影响所有市场,特别是加密货币
|
||||
|
||||
Returns:
|
||||
全球重大事件新闻列表
|
||||
"""
|
||||
news_list = []
|
||||
|
||||
try:
|
||||
from app.services.search import get_search_service
|
||||
search_service = get_search_service()
|
||||
|
||||
if not search_service.is_available:
|
||||
return news_list
|
||||
|
||||
# 搜索全球重大事件(最近24小时)
|
||||
global_event_queries = [
|
||||
"war conflict breaking news today",
|
||||
"geopolitical crisis latest",
|
||||
"US Iran military news"
|
||||
]
|
||||
|
||||
for query in global_event_queries:
|
||||
try:
|
||||
response = search_service.search_with_fallback(
|
||||
query=query,
|
||||
max_results=2,
|
||||
days=1 # 只搜索最近1天的新闻
|
||||
)
|
||||
|
||||
if response.success and response.results:
|
||||
for result in response.results:
|
||||
# 检查是否是重大事件(包含关键词)
|
||||
title_lower = result.title.lower()
|
||||
snippet_lower = (result.snippet or "").lower()
|
||||
text = f"{title_lower} {snippet_lower}"
|
||||
|
||||
# 重大事件关键词
|
||||
major_event_keywords = [
|
||||
"war", "conflict", "military", "attack", "strike", "sanctions",
|
||||
"geopolitical", "crisis", "tension", "iran", "israel", "russia",
|
||||
"ukraine", "middle east", "nato", "united states",
|
||||
"战争", "冲突", "军事", "袭击", "制裁", "地缘政治", "危机"
|
||||
]
|
||||
|
||||
if any(keyword in text for keyword in major_event_keywords):
|
||||
news_list.append({
|
||||
"datetime": result.published_date or datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
"headline": result.title,
|
||||
"summary": result.snippet[:300] if result.snippet else '',
|
||||
"source": f"全球事件:{result.source}",
|
||||
"url": result.url,
|
||||
"sentiment": "negative" if any(kw in text for kw in ["war", "conflict", "attack", "战争", "冲突", "袭击"]) else "neutral",
|
||||
"is_global_event": True # 标记为全球事件
|
||||
})
|
||||
logger.info(f"Found global major event: {result.title[:60]}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to search global events with query '{query}': {e}")
|
||||
continue
|
||||
|
||||
# 去重
|
||||
seen_titles = set()
|
||||
unique_events = []
|
||||
for item in news_list:
|
||||
title = item.get('headline', '')
|
||||
if title and title not in seen_titles:
|
||||
seen_titles.add(title)
|
||||
unique_events.append(item)
|
||||
|
||||
return unique_events[:5] # 最多返回5条全球重大事件
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get global major events: {e}")
|
||||
return []
|
||||
|
||||
def _get_polymarket_events(self, symbol: str, market: str) -> List[Dict]:
|
||||
"""
|
||||
获取与资产相关的预测市场事件
|
||||
直接调用Polymarket API获取实时数据,不依赖本地数据库
|
||||
|
||||
Args:
|
||||
symbol: 资产符号
|
||||
market: 市场类型
|
||||
|
||||
Returns:
|
||||
相关预测市场事件列表
|
||||
"""
|
||||
try:
|
||||
from app.data_sources.polymarket import PolymarketDataSource
|
||||
|
||||
polymarket_source = PolymarketDataSource()
|
||||
|
||||
# 提取关键词
|
||||
keywords = self._extract_polymarket_keywords(symbol, market)
|
||||
logger.info(f"Extracted Polymarket keywords for {symbol}: {keywords}")
|
||||
|
||||
# 直接调用API搜索,不使用数据库缓存
|
||||
# 因为AI分析需要最新的、相关的数据,数据库不可能包含所有市场
|
||||
related_markets = []
|
||||
for keyword in keywords:
|
||||
# 使用use_cache=False强制从API获取
|
||||
markets = polymarket_source.search_markets(keyword, limit=5, use_cache=False)
|
||||
logger.info(f"Found {len(markets)} markets for keyword '{keyword}' (from API)")
|
||||
related_markets.extend(markets)
|
||||
|
||||
# 去重
|
||||
seen = set()
|
||||
result = []
|
||||
for market_data in related_markets:
|
||||
market_id = market_data.get('market_id')
|
||||
if market_id and market_id not in seen:
|
||||
seen.add(market_id)
|
||||
result.append({
|
||||
"market_id": market_id,
|
||||
"question": market_data.get('question', ''),
|
||||
"current_probability": market_data.get('current_probability', 50.0),
|
||||
"volume_24h": market_data.get('volume_24h', 0),
|
||||
"liquidity": market_data.get('liquidity', 0),
|
||||
"category": market_data.get('category', 'other'),
|
||||
"polymarket_url": market_data.get('polymarket_url', f"https://polymarket.com/event/{market_id}")
|
||||
})
|
||||
|
||||
logger.info(f"Total {len(result)} unique Polymarket events found for {symbol}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get polymarket events for {symbol}: {e}")
|
||||
return []
|
||||
|
||||
def _extract_polymarket_keywords(self, symbol: str, market: str) -> List[str]:
|
||||
"""提取用于搜索预测市场的关键词"""
|
||||
keywords = []
|
||||
|
||||
# 基础符号
|
||||
if '/' in symbol:
|
||||
base = symbol.split('/')[0]
|
||||
keywords.append(base)
|
||||
else:
|
||||
keywords.append(symbol)
|
||||
|
||||
# 加密货币全名映射
|
||||
crypto_names = {
|
||||
'BTC': ['Bitcoin', 'bitcoin'],
|
||||
'ETH': ['Ethereum', 'ethereum'],
|
||||
'SOL': ['Solana', 'solana'],
|
||||
'BNB': ['Binance', 'binance'],
|
||||
'XRP': ['Ripple', 'ripple'],
|
||||
'ADA': ['Cardano', 'cardano'],
|
||||
'DOGE': ['Dogecoin', 'dogecoin'],
|
||||
'AVAX': ['Avalanche', 'avalanche'],
|
||||
'DOT': ['Polkadot', 'polkadot'],
|
||||
'MATIC': ['Polygon', 'polygon']
|
||||
}
|
||||
|
||||
base_symbol = symbol.split('/')[0] if '/' in symbol else symbol
|
||||
if base_symbol in crypto_names:
|
||||
keywords.extend(crypto_names[base_symbol])
|
||||
|
||||
# 添加价格相关关键词
|
||||
if market == 'Crypto':
|
||||
keywords.extend(['$100k', '$100000', '100k', 'ETF', 'approval'])
|
||||
|
||||
return keywords
|
||||
|
||||
|
||||
# 全局实例
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
"""
|
||||
Polymarket预测市场分析器
|
||||
分析预测市场,生成AI预测和交易机会推荐
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.services.llm import LLMService
|
||||
from app.services.market_data_collector import get_market_data_collector
|
||||
from app.data_sources.polymarket import PolymarketDataSource
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PolymarketAnalyzer:
|
||||
"""预测市场AI分析器"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm_service = LLMService()
|
||||
self.data_collector = get_market_data_collector()
|
||||
self.polymarket_source = PolymarketDataSource()
|
||||
|
||||
def analyze_market(self, market_id: str, user_id: int = None, use_cache: bool = True) -> Dict:
|
||||
"""
|
||||
分析单个预测市场
|
||||
|
||||
Args:
|
||||
market_id: 市场ID
|
||||
user_id: 用户ID(可选,用于用户特定分析)
|
||||
use_cache: 是否使用缓存的分析结果(默认True)
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
"""
|
||||
try:
|
||||
# 1. 获取市场数据
|
||||
market = self.polymarket_source.get_market_details(market_id)
|
||||
if not market:
|
||||
return {
|
||||
"error": "Market not found",
|
||||
"market_id": market_id
|
||||
}
|
||||
|
||||
# 2. 如果使用缓存,检查是否有缓存的分析结果(30分钟有效)
|
||||
if use_cache:
|
||||
cached_analysis = self._get_cached_analysis(market_id, user_id)
|
||||
if cached_analysis:
|
||||
cache_minutes = 30 # 缓存30分钟
|
||||
if self._is_analysis_fresh(cached_analysis, max_age_minutes=cache_minutes):
|
||||
logger.debug(f"Using cached analysis for market {market_id}")
|
||||
return cached_analysis
|
||||
|
||||
# 3. 收集相关数据
|
||||
related_news = self._get_related_news(market['question'])
|
||||
related_assets = self._identify_related_assets(market['question'])
|
||||
asset_data = self._get_asset_data(related_assets)
|
||||
|
||||
# 4. AI分析
|
||||
ai_result = self._ai_predict_probability(
|
||||
question=market['question'],
|
||||
current_market_prob=market['current_probability'],
|
||||
related_news=related_news,
|
||||
asset_data=asset_data
|
||||
)
|
||||
|
||||
# 5. 计算机会评分
|
||||
opportunity_score = self._calculate_opportunity_score(
|
||||
ai_prob=ai_result['predicted_probability'],
|
||||
market_prob=market['current_probability'],
|
||||
confidence=ai_result['confidence']
|
||||
)
|
||||
|
||||
# 6. 生成推荐
|
||||
recommendation = self._generate_recommendation(
|
||||
divergence=ai_result['predicted_probability'] - market['current_probability'],
|
||||
confidence=ai_result['confidence']
|
||||
)
|
||||
|
||||
# 7. 构建分析结果
|
||||
analysis_result = {
|
||||
"market_id": market_id,
|
||||
"ai_predicted_probability": ai_result['predicted_probability'],
|
||||
"market_probability": market['current_probability'],
|
||||
"divergence": ai_result['predicted_probability'] - market['current_probability'],
|
||||
"recommendation": recommendation,
|
||||
"confidence_score": ai_result['confidence'],
|
||||
"reasoning": ai_result['reasoning'],
|
||||
"key_factors": ai_result.get('key_factors', []),
|
||||
"risk_factors": ai_result.get('risk_factors', []),
|
||||
"related_assets": related_assets,
|
||||
"risk_level": self._assess_risk(market, ai_result),
|
||||
"opportunity_score": opportunity_score
|
||||
}
|
||||
|
||||
# 8. 保存到数据库
|
||||
self._save_analysis_to_db(analysis_result, user_id)
|
||||
|
||||
return analysis_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze market {market_id}: {e}", exc_info=True)
|
||||
return {
|
||||
"error": str(e),
|
||||
"market_id": market_id
|
||||
}
|
||||
|
||||
def generate_asset_trading_opportunities(self, market_id: str) -> List[Dict]:
|
||||
"""
|
||||
基于预测市场生成相关资产的交易机会
|
||||
|
||||
Args:
|
||||
market_id: 预测市场ID
|
||||
|
||||
Returns:
|
||||
资产交易机会列表
|
||||
"""
|
||||
try:
|
||||
# 1. 分析预测市场
|
||||
market_analysis = self.analyze_market(market_id)
|
||||
if market_analysis.get('error'):
|
||||
return []
|
||||
|
||||
# 2. 识别相关资产
|
||||
related_assets = market_analysis.get('related_assets', [])
|
||||
if not related_assets:
|
||||
return []
|
||||
|
||||
# 3. 对每个资产进行技术分析
|
||||
opportunities = []
|
||||
for asset in related_assets:
|
||||
try:
|
||||
# 推断市场类型
|
||||
market_type = self._infer_market(asset)
|
||||
|
||||
# 获取资产数据
|
||||
asset_data = self.data_collector.collect_all(
|
||||
market=market_type,
|
||||
symbol=asset,
|
||||
timeframe="1D",
|
||||
include_polymarket=False # 避免循环
|
||||
)
|
||||
|
||||
# 技术分析
|
||||
technical_analysis = self._analyze_technical(asset_data)
|
||||
|
||||
# 结合预测市场信号
|
||||
if market_analysis['recommendation'] == "YES":
|
||||
# 预测事件发生概率高 → 相关资产可能上涨
|
||||
signal = "BUY" if technical_analysis.get('trend') == "bullish" else "HOLD"
|
||||
elif market_analysis['recommendation'] == "NO":
|
||||
# 预测事件发生概率低 → 相关资产可能下跌
|
||||
signal = "SELL" if technical_analysis.get('trend') == "bearish" else "HOLD"
|
||||
else:
|
||||
signal = "HOLD"
|
||||
|
||||
# 计算综合置信度
|
||||
confidence = (
|
||||
market_analysis['confidence_score'] * 0.6 +
|
||||
technical_analysis.get('confidence', 50) * 0.4
|
||||
)
|
||||
|
||||
if signal != "HOLD" and confidence > 60:
|
||||
opportunities.append({
|
||||
"asset": asset,
|
||||
"market": market_type,
|
||||
"signal": signal,
|
||||
"confidence": round(confidence, 2),
|
||||
"reasoning": f"预测市场分析:{market_analysis['reasoning'][:200]}。技术面:{technical_analysis.get('summary', '')[:200]}",
|
||||
"related_prediction": {
|
||||
"market_id": market_id,
|
||||
"question": market_analysis.get('question', ''),
|
||||
"ai_probability": market_analysis['ai_predicted_probability'],
|
||||
"market_probability": market_analysis['market_probability']
|
||||
},
|
||||
"entry_suggestion": technical_analysis.get('entry_suggestion', {})
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to analyze asset {asset} for market {market_id}: {e}")
|
||||
continue
|
||||
|
||||
# 保存机会到数据库
|
||||
if opportunities:
|
||||
self._save_opportunities_to_db(market_id, opportunities)
|
||||
|
||||
return opportunities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate asset opportunities for {market_id}: {e}")
|
||||
return []
|
||||
|
||||
def _ai_predict_probability(self, question: str, current_market_prob: float,
|
||||
related_news: List, asset_data: Dict) -> Dict:
|
||||
"""使用AI预测事件概率"""
|
||||
try:
|
||||
# 构建prompt
|
||||
news_text = "\n".join([f"- {n.get('title', '')[:100]}" for n in related_news[:5]])
|
||||
|
||||
asset_text = ""
|
||||
if asset_data:
|
||||
price_data = asset_data.get('price', {})
|
||||
indicators = asset_data.get('indicators', {})
|
||||
if price_data:
|
||||
asset_text = f"""
|
||||
相关资产数据:
|
||||
- 当前价格: {price_data.get('current_price', 'N/A')}
|
||||
- 24h涨跌幅: {price_data.get('change_24h', 0):.2f}%
|
||||
- RSI: {indicators.get('rsi', {}).get('value', 'N/A')}
|
||||
- MACD: {indicators.get('macd', {}).get('signal', 'N/A')}
|
||||
"""
|
||||
|
||||
prompt = f"""分析以下预测市场事件,评估其发生的概率:
|
||||
|
||||
问题:{question}
|
||||
当前市场概率:{current_market_prob}%
|
||||
|
||||
相关新闻:
|
||||
{news_text if news_text else "暂无相关新闻"}
|
||||
|
||||
{asset_text}
|
||||
|
||||
请基于以下维度分析:
|
||||
1. 历史类似事件的成功率
|
||||
2. 当前新闻和趋势
|
||||
3. 相关资产价格走势和技术指标
|
||||
4. 宏观环境因素(VIX、DXY、利率等)
|
||||
5. 市场情绪指标
|
||||
|
||||
输出JSON格式:
|
||||
{{
|
||||
"predicted_probability": 72.5, // 你预测的概率(0-100)
|
||||
"confidence": 75.0, // 置信度(0-100)
|
||||
"reasoning": "详细分析...",
|
||||
"key_factors": ["因素1", "因素2"],
|
||||
"risk_factors": ["风险1", "风险2"]
|
||||
}}"""
|
||||
|
||||
# 调用LLM
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个专业的市场分析师,擅长分析预测市场事件。请基于提供的数据,客观评估事件发生的概率。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
|
||||
result = self.llm_service.call_llm_api(
|
||||
messages=messages,
|
||||
use_json_mode=True,
|
||||
temperature=0.3
|
||||
)
|
||||
|
||||
# 解析结果
|
||||
if isinstance(result, str):
|
||||
result = json.loads(result)
|
||||
|
||||
# 验证和规范化
|
||||
predicted_prob = float(result.get('predicted_probability', current_market_prob))
|
||||
predicted_prob = max(0, min(100, predicted_prob)) # 限制在0-100
|
||||
|
||||
confidence = float(result.get('confidence', 70))
|
||||
confidence = max(0, min(100, confidence))
|
||||
|
||||
return {
|
||||
'predicted_probability': round(predicted_prob, 2),
|
||||
'confidence': round(confidence, 2),
|
||||
'reasoning': result.get('reasoning', ''),
|
||||
'key_factors': result.get('key_factors', []),
|
||||
'risk_factors': result.get('risk_factors', [])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI prediction failed: {e}", exc_info=True)
|
||||
# 返回默认值
|
||||
return {
|
||||
'predicted_probability': current_market_prob,
|
||||
'confidence': 50.0,
|
||||
'reasoning': f'分析失败: {str(e)}',
|
||||
'key_factors': [],
|
||||
'risk_factors': []
|
||||
}
|
||||
|
||||
def _calculate_opportunity_score(self, ai_prob: float, market_prob: float,
|
||||
confidence: float) -> float:
|
||||
"""
|
||||
计算机会评分(0-100)
|
||||
|
||||
逻辑:
|
||||
- AI与市场差异越大,机会越好
|
||||
- 置信度越高,机会越好
|
||||
"""
|
||||
divergence = abs(ai_prob - market_prob)
|
||||
# 差异越大,机会越好(最大40分)
|
||||
divergence_score = min(divergence * 2, 40)
|
||||
# 置信度越高,机会越好(最大60分)
|
||||
confidence_score = confidence * 0.6
|
||||
|
||||
return round(divergence_score + confidence_score, 2)
|
||||
|
||||
def _generate_recommendation(self, divergence: float, confidence: float) -> str:
|
||||
"""
|
||||
生成推荐:YES/NO/HOLD
|
||||
|
||||
逻辑:
|
||||
- AI概率 > 市场概率 + 5% 且置信度 > 60 → YES
|
||||
- AI概率 < 市场概率 - 5% 且置信度 > 60 → NO
|
||||
- 其他 → HOLD
|
||||
"""
|
||||
if divergence > 5 and confidence > 60:
|
||||
return "YES"
|
||||
elif divergence < -5 and confidence > 60:
|
||||
return "NO"
|
||||
else:
|
||||
return "HOLD"
|
||||
|
||||
def _assess_risk(self, market: Dict, ai_result: Dict) -> str:
|
||||
"""评估风险等级"""
|
||||
confidence = ai_result.get('confidence', 50)
|
||||
divergence = abs(ai_result.get('predicted_probability', 50) - market.get('current_probability', 50))
|
||||
|
||||
if confidence < 50 or divergence > 30:
|
||||
return "high"
|
||||
elif confidence < 70 or divergence > 15:
|
||||
return "medium"
|
||||
else:
|
||||
return "low"
|
||||
|
||||
def _get_related_news(self, question: str) -> List[Dict]:
|
||||
"""获取相关问题相关的新闻"""
|
||||
# 提取关键词
|
||||
keywords = self._extract_keywords(question)
|
||||
|
||||
# 这里可以调用新闻API,暂时返回空列表
|
||||
# 实际实现时可以调用现有的新闻服务
|
||||
return []
|
||||
|
||||
def _identify_related_assets(self, question: str) -> List[str]:
|
||||
"""识别问题中提到的相关资产"""
|
||||
assets = []
|
||||
|
||||
# 加密货币关键词映射
|
||||
crypto_keywords = {
|
||||
'BTC': ['BTC', 'Bitcoin', 'bitcoin', 'btc'],
|
||||
'ETH': ['ETH', 'Ethereum', 'ethereum', 'eth'],
|
||||
'SOL': ['SOL', 'Solana', 'solana', 'sol'],
|
||||
'BNB': ['BNB', 'Binance', 'binance', 'bnb'],
|
||||
'XRP': ['XRP', 'Ripple', 'ripple', 'xrp'],
|
||||
'ADA': ['ADA', 'Cardano', 'cardano', 'ada'],
|
||||
'DOGE': ['DOGE', 'Dogecoin', 'dogecoin', 'doge'],
|
||||
'AVAX': ['AVAX', 'Avalanche', 'avalanche', 'avax'],
|
||||
'DOT': ['DOT', 'Polkadot', 'polkadot', 'dot'],
|
||||
'MATIC': ['MATIC', 'Polygon', 'polygon', 'matic']
|
||||
}
|
||||
|
||||
question_upper = question.upper()
|
||||
for symbol, keywords in crypto_keywords.items():
|
||||
if any(kw in question_upper for kw in keywords):
|
||||
assets.append(f"{symbol}/USDT")
|
||||
|
||||
# 去重
|
||||
return list(set(assets))
|
||||
|
||||
def _get_asset_data(self, assets: List[str]) -> Optional[Dict]:
|
||||
"""获取资产数据(取第一个资产)"""
|
||||
if not assets:
|
||||
return None
|
||||
|
||||
try:
|
||||
asset = assets[0]
|
||||
market_type = self._infer_market(asset)
|
||||
return self.data_collector.collect_all(
|
||||
market=market_type,
|
||||
symbol=asset,
|
||||
timeframe="1D"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get asset data for {assets}: {e}")
|
||||
return None
|
||||
|
||||
def _analyze_technical(self, asset_data: Dict) -> Dict:
|
||||
"""简单的技术分析"""
|
||||
if not asset_data:
|
||||
return {
|
||||
'trend': 'neutral',
|
||||
'confidence': 50,
|
||||
'summary': '数据不足',
|
||||
'entry_suggestion': {}
|
||||
}
|
||||
|
||||
indicators = asset_data.get('indicators', {})
|
||||
price_data = asset_data.get('price', {})
|
||||
|
||||
# 简单的趋势判断
|
||||
rsi = indicators.get('rsi', {}).get('value', 50)
|
||||
macd_signal = indicators.get('macd', {}).get('signal', 'neutral')
|
||||
|
||||
trend = 'neutral'
|
||||
if rsi > 60 and macd_signal == 'bullish':
|
||||
trend = 'bullish'
|
||||
elif rsi < 40 and macd_signal == 'bearish':
|
||||
trend = 'bearish'
|
||||
|
||||
confidence = 60 if abs(rsi - 50) > 15 else 50
|
||||
|
||||
return {
|
||||
'trend': trend,
|
||||
'confidence': confidence,
|
||||
'summary': f'RSI: {rsi:.1f}, MACD: {macd_signal}',
|
||||
'entry_suggestion': {}
|
||||
}
|
||||
|
||||
def _infer_market(self, symbol: str) -> str:
|
||||
"""推断市场类型"""
|
||||
if '/' in symbol:
|
||||
return "Crypto"
|
||||
elif len(symbol) <= 5 and symbol.isupper():
|
||||
return "USStock"
|
||||
else:
|
||||
return "Crypto" # 默认
|
||||
|
||||
def _extract_keywords(self, text: str) -> List[str]:
|
||||
"""提取关键词"""
|
||||
# 简单的关键词提取
|
||||
words = re.findall(r'\b[A-Z][a-z]+\b|\b[A-Z]{2,}\b', text)
|
||||
return [w.lower() for w in words if len(w) > 2]
|
||||
|
||||
def _get_cached_analysis(self, market_id: str, user_id: int = None) -> Optional[Dict]:
|
||||
"""获取缓存的分析结果"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
query = """
|
||||
SELECT ai_predicted_probability, market_probability, divergence,
|
||||
recommendation, confidence_score, opportunity_score,
|
||||
reasoning, key_factors, related_assets, created_at
|
||||
FROM qd_polymarket_ai_analysis
|
||||
WHERE market_id = %s
|
||||
"""
|
||||
params = [market_id]
|
||||
|
||||
if user_id:
|
||||
query += " AND user_id = %s"
|
||||
params.append(user_id)
|
||||
else:
|
||||
query += " AND user_id IS NULL"
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT 1"
|
||||
|
||||
cur.execute(query, params)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if row:
|
||||
# RealDictCursor返回字典,使用键访问
|
||||
key_factors_raw = row.get('key_factors')
|
||||
key_factors = []
|
||||
if key_factors_raw:
|
||||
try:
|
||||
if isinstance(key_factors_raw, str):
|
||||
key_factors = json.loads(key_factors_raw)
|
||||
else:
|
||||
key_factors = key_factors_raw if isinstance(key_factors_raw, list) else []
|
||||
except:
|
||||
key_factors = []
|
||||
|
||||
return {
|
||||
"market_id": market_id,
|
||||
"ai_predicted_probability": float(row.get('ai_predicted_probability') or 0),
|
||||
"market_probability": float(row.get('market_probability') or 0),
|
||||
"divergence": float(row.get('divergence') or 0),
|
||||
"recommendation": row.get('recommendation') or 'HOLD',
|
||||
"confidence_score": float(row.get('confidence_score') or 0),
|
||||
"opportunity_score": float(row.get('opportunity_score') or 0),
|
||||
"reasoning": row.get('reasoning') or '',
|
||||
"key_factors": key_factors,
|
||||
"related_assets": row.get('related_assets') if row.get('related_assets') else [],
|
||||
"created_at": row.get('created_at')
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get cached analysis: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_analysis_fresh(self, analysis: Dict, max_age_minutes: int = 30) -> bool:
|
||||
"""检查分析结果是否新鲜"""
|
||||
created_at = analysis.get('created_at')
|
||||
if not created_at:
|
||||
return False
|
||||
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||
|
||||
age = (datetime.now() - created_at.replace(tzinfo=None)).total_seconds() / 60
|
||||
return age < max_age_minutes
|
||||
|
||||
def _save_analysis_to_db(self, analysis: Dict, user_id: int = None):
|
||||
"""保存分析结果到数据库"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO qd_polymarket_ai_analysis
|
||||
(market_id, user_id, ai_predicted_probability, market_probability,
|
||||
divergence, recommendation, confidence_score, opportunity_score,
|
||||
reasoning, key_factors, related_assets, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
""", (
|
||||
analysis['market_id'],
|
||||
user_id,
|
||||
analysis['ai_predicted_probability'],
|
||||
analysis['market_probability'],
|
||||
analysis['divergence'],
|
||||
analysis['recommendation'],
|
||||
analysis['confidence_score'],
|
||||
analysis['opportunity_score'],
|
||||
analysis['reasoning'],
|
||||
json.dumps(analysis.get('key_factors', [])),
|
||||
analysis.get('related_assets', [])
|
||||
))
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save analysis to DB: {e}")
|
||||
|
||||
def _save_opportunities_to_db(self, market_id: str, opportunities: List[Dict]):
|
||||
"""保存交易机会到数据库"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
for opp in opportunities:
|
||||
cur.execute("""
|
||||
INSERT INTO qd_polymarket_asset_opportunities
|
||||
(market_id, asset_symbol, asset_market, signal, confidence,
|
||||
reasoning, entry_suggestion, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
""", (
|
||||
market_id,
|
||||
opp['asset'],
|
||||
opp['market'],
|
||||
opp['signal'],
|
||||
opp['confidence'],
|
||||
opp['reasoning'],
|
||||
json.dumps(opp.get('entry_suggestion', {}))
|
||||
))
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save opportunities to DB: {e}")
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Polymarket批量分析器
|
||||
一次性分析多个市场,由AI筛选出有交易机会的市场
|
||||
"""
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.services.llm import LLMService
|
||||
from app.data_sources.polymarket import PolymarketDataSource
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PolymarketBatchAnalyzer:
|
||||
"""批量分析预测市场,由AI筛选交易机会"""
|
||||
|
||||
def __init__(self):
|
||||
self.llm_service = LLMService()
|
||||
self.polymarket_source = PolymarketDataSource()
|
||||
|
||||
def batch_analyze_markets(self, markets: List[Dict], max_opportunities: int = 20) -> List[Dict]:
|
||||
"""
|
||||
批量分析市场,由AI筛选出有交易机会的市场
|
||||
|
||||
Args:
|
||||
markets: 市场列表
|
||||
max_opportunities: 最多返回多少个交易机会
|
||||
|
||||
Returns:
|
||||
筛选后的市场列表(包含AI分析结果)
|
||||
"""
|
||||
if not markets:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 1. 构建批量分析的prompt
|
||||
markets_summary = self._build_markets_summary(markets)
|
||||
|
||||
prompt = f"""你是一个专业的预测市场分析师。请分析以下预测市场列表,筛选出最有交易机会的市场。
|
||||
|
||||
市场列表:
|
||||
{markets_summary}
|
||||
|
||||
请基于以下维度评估每个市场:
|
||||
1. **市场活跃度**:交易量、流动性是否足够
|
||||
2. **概率偏差**:当前市场概率是否偏离合理预期(偏离50%越多,机会越大)
|
||||
3. **事件重要性**:事件对市场的影响程度
|
||||
4. **时间窗口**:距离结算时间是否合适(太近或太远都不好)
|
||||
5. **信息优势**:是否有明显的信息不对称或市场误判
|
||||
|
||||
请返回JSON格式,包含筛选出的市场ID和简要分析:
|
||||
{{
|
||||
"opportunities": [
|
||||
{{
|
||||
"market_id": "市场ID",
|
||||
"opportunity_score": 85, // 机会评分 0-100
|
||||
"reason": "为什么这个市场有交易机会(简要说明)",
|
||||
"recommendation": "YES/NO/HOLD", // 推荐方向
|
||||
"confidence": 75, // 置信度 0-100
|
||||
"key_factors": ["因素1", "因素2"] // 关键因素
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
要求:
|
||||
- 只返回最有价值的 {max_opportunities} 个机会
|
||||
- 机会评分 >= 60 才考虑
|
||||
- 优先选择:高交易量 + 明显概率偏差 + 高置信度
|
||||
- 简要说明原因,不要冗长"""
|
||||
|
||||
# 2. 调用LLM进行批量分析
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个专业的预测市场分析师,擅长从大量市场中快速识别有价值的交易机会。请客观、理性地分析,只推荐真正有优势的机会。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
|
||||
logger.info(f"Batch analyzing {len(markets)} markets, requesting {max_opportunities} opportunities")
|
||||
result = self.llm_service.call_llm_api(
|
||||
messages=messages,
|
||||
use_json_mode=True,
|
||||
temperature=0.3
|
||||
)
|
||||
|
||||
# 3. 解析结果
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result = json.loads(result)
|
||||
except:
|
||||
logger.error(f"Failed to parse LLM result as JSON: {result[:200]}")
|
||||
return self._fallback_analysis(markets, max_opportunities)
|
||||
|
||||
opportunities = result.get('opportunities', [])
|
||||
if not opportunities:
|
||||
logger.warning("LLM returned no opportunities, using fallback")
|
||||
return self._fallback_analysis(markets, max_opportunities)
|
||||
|
||||
# 4. 将AI分析结果合并到市场数据中
|
||||
opportunities_map = {opp.get('market_id'): opp for opp in opportunities}
|
||||
analyzed_markets = []
|
||||
|
||||
for market in markets:
|
||||
market_id = market.get('market_id')
|
||||
if not market_id:
|
||||
continue
|
||||
|
||||
opp = opportunities_map.get(market_id)
|
||||
if opp:
|
||||
# 获取AI预测的概率
|
||||
predicted_prob = float(opp.get('predicted_probability', market.get('current_probability', 50.0)))
|
||||
market_prob = market.get('current_probability', 50.0)
|
||||
divergence = predicted_prob - market_prob
|
||||
|
||||
# 合并AI分析结果
|
||||
market['ai_analysis'] = {
|
||||
'predicted_probability': predicted_prob, # 使用AI预测的概率
|
||||
'recommendation': opp.get('recommendation', 'HOLD'),
|
||||
'confidence_score': float(opp.get('confidence', 0)),
|
||||
'opportunity_score': float(opp.get('opportunity_score', 0)),
|
||||
'divergence': divergence, # AI预测概率 - 市场概率
|
||||
'reasoning': opp.get('reason', ''),
|
||||
'key_factors': opp.get('key_factors', [])
|
||||
}
|
||||
analyzed_markets.append(market)
|
||||
|
||||
# 5. 按机会评分排序
|
||||
analyzed_markets.sort(
|
||||
key=lambda x: x.get('ai_analysis', {}).get('opportunity_score', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
logger.info(f"Batch analysis completed: {len(analyzed_markets)} opportunities identified")
|
||||
return analyzed_markets
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Batch analysis failed: {e}", exc_info=True)
|
||||
return self._fallback_analysis(markets, max_opportunities)
|
||||
|
||||
def _build_markets_summary(self, markets: List[Dict]) -> str:
|
||||
"""构建市场摘要,用于批量分析"""
|
||||
summary_lines = []
|
||||
|
||||
for i, market in enumerate(markets[:50], 1): # 限制最多50个,避免prompt过长
|
||||
market_id = market.get('market_id', '')
|
||||
question = market.get('question', '')[:100] # 限制长度
|
||||
prob = market.get('current_probability', 50.0)
|
||||
volume = market.get('volume_24h', 0)
|
||||
category = market.get('category', 'other')
|
||||
|
||||
summary_lines.append(
|
||||
f"{i}. ID: {market_id}\n"
|
||||
f" 问题: {question}\n"
|
||||
f" 当前概率: {prob:.1f}%\n"
|
||||
f" 24h交易量: ${volume:,.0f}\n"
|
||||
f" 分类: {category}"
|
||||
)
|
||||
|
||||
return "\n\n".join(summary_lines)
|
||||
|
||||
def _fallback_analysis(self, markets: List[Dict], max_opportunities: int) -> List[Dict]:
|
||||
"""回退分析:基于简单规则筛选"""
|
||||
opportunities = []
|
||||
|
||||
for market in markets:
|
||||
prob = market.get('current_probability', 50.0)
|
||||
volume = market.get('volume_24h', 0)
|
||||
|
||||
# 简单规则:交易量大 + 概率偏离50%
|
||||
if volume > 10000 and abs(prob - 50.0) > 10:
|
||||
opportunity_score = min(60 + abs(prob - 50.0) * 0.5, 90)
|
||||
|
||||
market['ai_analysis'] = {
|
||||
'predicted_probability': prob,
|
||||
'recommendation': 'YES' if prob > 50 else 'NO',
|
||||
'confidence_score': 60.0,
|
||||
'opportunity_score': opportunity_score,
|
||||
'divergence': 0,
|
||||
'reasoning': f'高交易量({volume:,.0f}) + 明显概率偏差({prob:.1f}%)',
|
||||
'key_factors': ['高交易量', '概率偏差']
|
||||
}
|
||||
opportunities.append(market)
|
||||
|
||||
# 按机会评分排序
|
||||
opportunities.sort(
|
||||
key=lambda x: x.get('ai_analysis', {}).get('opportunity_score', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return opportunities[:max_opportunities]
|
||||
|
||||
def save_batch_analysis(self, markets: List[Dict]):
|
||||
"""保存批量分析结果到数据库"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
for market in markets:
|
||||
market_id = market.get('market_id')
|
||||
ai_analysis = market.get('ai_analysis')
|
||||
|
||||
if not market_id or not ai_analysis:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 先删除该市场的旧分析记录(user_id为NULL的通用分析)
|
||||
cur.execute("""
|
||||
DELETE FROM qd_polymarket_ai_analysis
|
||||
WHERE market_id = %s AND user_id IS NULL
|
||||
""", (market_id,))
|
||||
|
||||
# 插入新的分析记录
|
||||
cur.execute("""
|
||||
INSERT INTO qd_polymarket_ai_analysis
|
||||
(market_id, user_id, ai_predicted_probability, market_probability,
|
||||
divergence, recommendation, confidence_score, opportunity_score,
|
||||
reasoning, key_factors, related_assets, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
""", (
|
||||
market_id,
|
||||
None, # 通用分析
|
||||
float(ai_analysis.get('predicted_probability', market.get('current_probability', 50.0))),
|
||||
market.get('current_probability', 50.0),
|
||||
float(ai_analysis.get('divergence', 0)),
|
||||
ai_analysis.get('recommendation', 'HOLD'),
|
||||
ai_analysis.get('confidence_score', 0),
|
||||
ai_analysis.get('opportunity_score', 0),
|
||||
ai_analysis.get('reasoning', ''),
|
||||
json.dumps(ai_analysis.get('key_factors', [])),
|
||||
[]
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save analysis for market {market_id}: {e}")
|
||||
continue
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
logger.info(f"Saved batch analysis for {len(markets)} markets")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save batch analysis: {e}", exc_info=True)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Polymarket后台任务
|
||||
每30分钟更新一次市场数据,并批量分析市场机会
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.data_sources.polymarket import PolymarketDataSource
|
||||
from app.services.polymarket_batch_analyzer import PolymarketBatchAnalyzer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PolymarketWorker:
|
||||
"""Polymarket数据更新和分析后台任务"""
|
||||
|
||||
def __init__(self, update_interval_minutes: int = 30, analysis_cache_minutes: int = 30):
|
||||
"""
|
||||
初始化后台任务
|
||||
|
||||
Args:
|
||||
update_interval_minutes: 市场数据更新间隔(分钟)
|
||||
analysis_cache_minutes: AI分析结果缓存时间(分钟)
|
||||
"""
|
||||
self.update_interval_minutes = update_interval_minutes
|
||||
self.analysis_cache_minutes = analysis_cache_minutes
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
self.polymarket_source = PolymarketDataSource()
|
||||
self.batch_analyzer = PolymarketBatchAnalyzer()
|
||||
self._last_update_ts = 0.0
|
||||
|
||||
def start(self) -> bool:
|
||||
"""启动后台任务"""
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return True
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run_loop, name="PolymarketWorker", daemon=True)
|
||||
self._thread.start()
|
||||
logger.info(f"PolymarketWorker started (update_interval={self.update_interval_minutes}min, cache={self.analysis_cache_minutes}min)")
|
||||
return True
|
||||
|
||||
def stop(self, timeout_sec: float = 5.0) -> None:
|
||||
"""停止后台任务"""
|
||||
with self._lock:
|
||||
if not self._thread or not self._thread.is_alive():
|
||||
return
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=timeout_sec)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("PolymarketWorker thread did not stop within timeout")
|
||||
else:
|
||||
logger.info("PolymarketWorker stopped")
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
"""主循环"""
|
||||
logger.info("PolymarketWorker loop started")
|
||||
|
||||
# 启动时立即执行一次
|
||||
self._update_markets_and_analyze()
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# 等待指定时间间隔
|
||||
wait_seconds = self.update_interval_minutes * 60
|
||||
if self._stop_event.wait(wait_seconds):
|
||||
break # 如果收到停止信号,退出循环
|
||||
|
||||
# 执行更新和分析
|
||||
self._update_markets_and_analyze()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PolymarketWorker loop error: {e}", exc_info=True)
|
||||
# 出错后等待1分钟再重试
|
||||
self._stop_event.wait(60)
|
||||
|
||||
logger.info("PolymarketWorker loop stopped")
|
||||
|
||||
def _update_markets_and_analyze(self) -> None:
|
||||
"""更新市场数据并分析"""
|
||||
try:
|
||||
logger.info("Starting Polymarket data update and analysis...")
|
||||
start_time = time.time()
|
||||
|
||||
# 1. 更新市场数据(从所有主要分类获取)
|
||||
categories = ["crypto", "politics", "economics", "sports", "tech", "finance", "geopolitics", "culture", "climate", "entertainment"]
|
||||
all_markets = []
|
||||
|
||||
for category in categories:
|
||||
try:
|
||||
markets = self.polymarket_source.get_trending_markets(category, limit=50)
|
||||
all_markets.extend(markets)
|
||||
logger.info(f"Fetched {len(markets)} markets from category: {category}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch markets for category {category}: {e}")
|
||||
|
||||
# 去重(按market_id)
|
||||
unique_markets = {}
|
||||
for market in all_markets:
|
||||
market_id = market.get('market_id')
|
||||
if market_id:
|
||||
unique_markets[market_id] = market
|
||||
|
||||
logger.info(f"Total unique markets: {len(unique_markets)}")
|
||||
|
||||
# 2. 批量分析市场(一次性分析所有市场,由AI筛选机会)
|
||||
markets_list = list(unique_markets.values())
|
||||
logger.info(f"Starting batch analysis for {len(markets_list)} markets...")
|
||||
|
||||
# 使用批量分析器,一次性分析所有市场
|
||||
# AI会筛选出最有交易机会的市场(最多20个)
|
||||
analyzed_markets = self.batch_analyzer.batch_analyze_markets(
|
||||
markets_list,
|
||||
max_opportunities=20
|
||||
)
|
||||
|
||||
# 3. 保存分析结果到数据库
|
||||
if analyzed_markets:
|
||||
self.batch_analyzer.save_batch_analysis(analyzed_markets)
|
||||
analyzed_count = len(analyzed_markets)
|
||||
else:
|
||||
analyzed_count = 0
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"Polymarket update completed: {len(unique_markets)} markets updated, {analyzed_count} opportunities identified in {elapsed:.1f}s")
|
||||
self._last_update_ts = time.time()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update markets and analyze: {e}", exc_info=True)
|
||||
|
||||
|
||||
def force_update(self) -> None:
|
||||
"""强制立即更新(用于手动触发)"""
|
||||
logger.info("Force update triggered")
|
||||
self._update_markets_and_analyze()
|
||||
|
||||
|
||||
# 全局单例
|
||||
_polymarket_worker: Optional[PolymarketWorker] = None
|
||||
_worker_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_polymarket_worker() -> PolymarketWorker:
|
||||
"""获取PolymarketWorker单例"""
|
||||
global _polymarket_worker
|
||||
with _worker_lock:
|
||||
if _polymarket_worker is None:
|
||||
update_interval = int(os.getenv("POLYMARKET_UPDATE_INTERVAL_MIN", "30"))
|
||||
cache_minutes = int(os.getenv("POLYMARKET_ANALYSIS_CACHE_MIN", "30"))
|
||||
_polymarket_worker = PolymarketWorker(
|
||||
update_interval_minutes=update_interval,
|
||||
analysis_cache_minutes=cache_minutes
|
||||
)
|
||||
return _polymarket_worker
|
||||
|
||||
|
||||
# 需要导入os
|
||||
import os
|
||||
@@ -167,12 +167,16 @@ class PostgresCursor:
|
||||
row = self._cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
# RealDictCursor already returns a dict, so return as-is
|
||||
return row if isinstance(row, dict) else dict(row) if row else None
|
||||
|
||||
def fetchall(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch all rows"""
|
||||
rows = self._cursor.fetchall()
|
||||
return [dict(row) for row in rows] if rows else []
|
||||
if not rows:
|
||||
return []
|
||||
# RealDictCursor already returns dicts, so return as-is
|
||||
return [row if isinstance(row, dict) else dict(row) for row in rows]
|
||||
|
||||
def close(self):
|
||||
"""Close cursor"""
|
||||
@@ -234,7 +238,10 @@ def get_pg_connection():
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"PostgreSQL operation error: {e}")
|
||||
# 记录更详细的错误信息
|
||||
error_msg = str(e) if e else repr(e)
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"PostgreSQL operation error ({error_type}): {error_msg}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
|
||||
@@ -16,6 +16,16 @@ def setup_logger():
|
||||
format=log_format
|
||||
)
|
||||
|
||||
# 过滤 werkzeug 的 INFO 级别日志(减少噪音)
|
||||
# 只保留 WARNING 及以上级别
|
||||
werkzeug_logger = logging.getLogger('werkzeug')
|
||||
werkzeug_logger.setLevel(logging.WARNING)
|
||||
|
||||
# 过滤 kline 路由的 INFO 级别日志(减少噪音)
|
||||
# 只保留 WARNING 及以上级别
|
||||
kline_logger = logging.getLogger('app.routes.kline')
|
||||
kline_logger.setLevel(logging.WARNING)
|
||||
|
||||
# 创建日志目录
|
||||
log_dir = 'logs'
|
||||
if not os.path.exists(log_dir):
|
||||
|
||||
@@ -11,7 +11,7 @@ backlog = 2048
|
||||
workers = multiprocessing.cpu_count() * 2 + 1
|
||||
worker_class = "sync"
|
||||
worker_connections = 1000
|
||||
timeout = 120
|
||||
timeout = 600 # 10 minutes for long-running backtests
|
||||
keepalive = 5
|
||||
|
||||
# 日志
|
||||
|
||||
@@ -832,6 +832,68 @@ CREATE TABLE IF NOT EXISTS qd_quick_trades (
|
||||
CREATE INDEX IF NOT EXISTS idx_quick_trades_user ON qd_quick_trades(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_quick_trades_created ON qd_quick_trades(created_at DESC);
|
||||
|
||||
-- =============================================================================
|
||||
-- Polymarket Prediction Markets (预测市场数据和分析)
|
||||
-- =============================================================================
|
||||
|
||||
-- 预测市场表(缓存)
|
||||
CREATE TABLE IF NOT EXISTS qd_polymarket_markets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
question TEXT,
|
||||
category VARCHAR(100), -- crypto, politics, economics, sports
|
||||
current_probability DECIMAL(5,2), -- YES概率(0-100)
|
||||
volume_24h DECIMAL(20,2),
|
||||
liquidity DECIMAL(20,2),
|
||||
end_date_iso TIMESTAMP,
|
||||
status VARCHAR(50), -- active, closed, resolved
|
||||
outcome_tokens JSONB, -- YES/NO价格和交易量
|
||||
slug VARCHAR(255), -- Polymarket事件slug,用于构建URL
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_category ON qd_polymarket_markets(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_status ON qd_polymarket_markets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_updated ON qd_polymarket_markets(updated_at DESC);
|
||||
|
||||
-- AI分析记录表
|
||||
CREATE TABLE IF NOT EXISTS qd_polymarket_ai_analysis (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_id VARCHAR(255) NOT NULL,
|
||||
user_id INTEGER, -- 可选:用户特定的分析
|
||||
ai_predicted_probability DECIMAL(5,2),
|
||||
market_probability DECIMAL(5,2),
|
||||
divergence DECIMAL(5,2), -- AI - 市场
|
||||
recommendation VARCHAR(20), -- YES/NO/HOLD
|
||||
confidence_score DECIMAL(5,2),
|
||||
opportunity_score DECIMAL(5,2),
|
||||
reasoning TEXT,
|
||||
key_factors JSONB,
|
||||
related_assets TEXT[], -- 相关资产列表
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_analysis_market ON qd_polymarket_ai_analysis(market_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_analysis_opportunity ON qd_polymarket_ai_analysis(opportunity_score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_analysis_user ON qd_polymarket_ai_analysis(user_id);
|
||||
|
||||
-- 资产交易机会表(基于预测市场生成)
|
||||
CREATE TABLE IF NOT EXISTS qd_polymarket_asset_opportunities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_id VARCHAR(255) NOT NULL,
|
||||
asset_symbol VARCHAR(100),
|
||||
asset_market VARCHAR(50),
|
||||
signal VARCHAR(20), -- BUY/SELL/HOLD
|
||||
confidence DECIMAL(5,2),
|
||||
reasoning TEXT,
|
||||
entry_suggestion JSONB, -- 入场建议
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_opp_market ON qd_polymarket_asset_opportunities(market_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_opp_asset ON qd_polymarket_asset_opportunities(asset_symbol, asset_market);
|
||||
|
||||
-- =============================================================================
|
||||
-- Completion Notice
|
||||
-- =============================================================================
|
||||
|
||||
@@ -82,7 +82,7 @@ app = create_app()
|
||||
def main():
|
||||
"""启动应用"""
|
||||
# Keep startup messages ASCII-only and short.
|
||||
print("QuantDinger Python API v2.0.0")
|
||||
print("QuantDinger Python API v2.2.2")
|
||||
|
||||
# Check demo mode status for debugging
|
||||
demo_status = os.getenv('IS_DEMO_MODE', 'false').lower()
|
||||
|
||||
@@ -4,6 +4,131 @@ This document records version updates, new features, bug fixes, and database mig
|
||||
|
||||
---
|
||||
|
||||
## V2.2.2 (2026-02-28)
|
||||
|
||||
### 🚀 New Features
|
||||
|
||||
#### Polymarket Prediction Markets Integration 🔮
|
||||
- **Prediction Market Analysis**: Integrated Polymarket prediction markets as a new data source for AI analysis
|
||||
- **AI-Driven Insights**: AI analyzes prediction market events and compares AI predictions with market consensus
|
||||
- **Opportunity Discovery**: Identifies undervalued prediction opportunities with AI vs market divergence analysis
|
||||
- **Asset Trading Recommendations**: Links prediction market events to related asset trading opportunities (e.g., BTC/USDT, ETH/USDT)
|
||||
- **Data Analysis Only**: Focuses on data analysis and trading opportunity recommendations without live trading
|
||||
- **Frontend Pages**: New `/polymarket` page with market listings, filtering, sorting, and search functionality
|
||||
- **Market Detail View**: Comprehensive analysis view showing market info, AI analysis results, and related asset opportunities
|
||||
- **AI Trading Radar Integration**: Prediction market opportunities appear in the AI Trading Radar alongside Crypto, US Stocks, and Forex
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed duplicate `common.refresh` key in internationalization files (`zh-CN.js` and `en-US.js`)
|
||||
- Fixed OKX position `entry_price` extraction (now correctly reads `avgPx`, `avgPxEp`, or `last` from position data)
|
||||
- Improved symbol normalization across all exchanges to handle edge cases (e.g., PI, TRX without quote currency)
|
||||
- Enhanced LLM provider fallback mechanism to handle 403/402/404/429 errors automatically
|
||||
|
||||
### 🎨 UI/UX Improvements
|
||||
- Added Polymarket market cards with AI analysis summaries and opportunity scores
|
||||
- Enhanced AI Trading Radar to display prediction market opportunities with distinct styling
|
||||
- Improved symbol selector in Quick Trade panel with watchlist integration
|
||||
|
||||
### 📋 Database Migration
|
||||
|
||||
**Run the following SQL on your PostgreSQL database before deploying V2.2.2:**
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- QuantDinger V2.2.2 Database Migration
|
||||
-- Polymarket Prediction Markets Integration
|
||||
-- ============================================================
|
||||
|
||||
-- 预测市场表(缓存)
|
||||
CREATE TABLE IF NOT EXISTS qd_polymarket_markets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
question TEXT,
|
||||
category VARCHAR(100), -- crypto, politics, economics, sports
|
||||
current_probability DECIMAL(5,2), -- YES概率(0-100)
|
||||
volume_24h DECIMAL(20,2),
|
||||
liquidity DECIMAL(20,2),
|
||||
end_date_iso TIMESTAMP,
|
||||
status VARCHAR(50), -- active, closed, resolved
|
||||
outcome_tokens JSONB, -- YES/NO价格和交易量
|
||||
slug VARCHAR(255), -- Polymarket事件slug,用于构建URL
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 添加slug字段(如果表已存在但字段不存在)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_polymarket_markets' AND column_name = 'slug'
|
||||
) THEN
|
||||
ALTER TABLE qd_polymarket_markets ADD COLUMN slug VARCHAR(255);
|
||||
RAISE NOTICE 'Added slug column to qd_polymarket_markets';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_category ON qd_polymarket_markets(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_status ON qd_polymarket_markets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_updated ON qd_polymarket_markets(updated_at DESC);
|
||||
|
||||
-- AI分析记录表
|
||||
CREATE TABLE IF NOT EXISTS qd_polymarket_ai_analysis (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_id VARCHAR(255) NOT NULL,
|
||||
user_id INTEGER, -- 可选:用户特定的分析
|
||||
ai_predicted_probability DECIMAL(5,2),
|
||||
market_probability DECIMAL(5,2),
|
||||
divergence DECIMAL(5,2), -- AI - 市场
|
||||
recommendation VARCHAR(20), -- YES/NO/HOLD
|
||||
confidence_score DECIMAL(5,2),
|
||||
opportunity_score DECIMAL(5,2),
|
||||
reasoning TEXT,
|
||||
key_factors JSONB,
|
||||
related_assets TEXT[], -- 相关资产列表
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_analysis_market ON qd_polymarket_ai_analysis(market_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_analysis_opportunity ON qd_polymarket_ai_analysis(opportunity_score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_analysis_user ON qd_polymarket_ai_analysis(user_id);
|
||||
|
||||
-- 资产交易机会表(基于预测市场生成)
|
||||
CREATE TABLE IF NOT EXISTS qd_polymarket_asset_opportunities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_id VARCHAR(255) NOT NULL,
|
||||
asset_symbol VARCHAR(100),
|
||||
asset_market VARCHAR(50),
|
||||
signal VARCHAR(20), -- BUY/SELL/HOLD
|
||||
confidence DECIMAL(5,2),
|
||||
reasoning TEXT,
|
||||
entry_suggestion JSONB, -- 入场建议
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_opp_market ON qd_polymarket_asset_opportunities(market_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_polymarket_opp_asset ON qd_polymarket_asset_opportunities(asset_symbol, asset_market);
|
||||
|
||||
-- Migration Complete
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ QuantDinger V2.2.2 database migration completed!';
|
||||
END $$;
|
||||
```
|
||||
|
||||
**Migration Notes:**
|
||||
- All statements use `IF NOT EXISTS` — safe to run multiple times
|
||||
- No existing data is modified or deleted
|
||||
- New tables are created for Polymarket data caching and AI analysis
|
||||
- Polymarket integration is read-only (data analysis only, no live trading)
|
||||
|
||||
### 📝 Configuration Notes
|
||||
- No new environment variables required for basic Polymarket integration
|
||||
- Polymarket data source uses placeholder/dummy data by default (can be extended with actual API integration)
|
||||
- AI analysis leverages existing LLM configuration from System Settings
|
||||
|
||||
---
|
||||
|
||||
## V2.2.1 (2026-02-27)
|
||||
|
||||
### 🚀 New Features
|
||||
@@ -640,6 +765,7 @@ END $$;
|
||||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|------------|
|
||||
| V2.2.2 | 2026-02-28 | Polymarket prediction markets integration, AI-driven prediction analysis, asset trading recommendations |
|
||||
| V2.2.1 | 2026-02-27 | Membership & Billing, USDT TRC20 payment, VIP free indicators, AI Trading Radar, simplified strategy creation |
|
||||
| V2.1.3 | 2026-02-XX | Cross-sectional strategy support |
|
||||
| V2.1.2 | 2026-02-01 | Indicator parameters, cross-indicator calling |
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.2.1
|
||||
2.2.2
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -421,4 +421,4 @@
|
||||
.brand-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
}</style><script defer="defer" src="/js/chunk-vendors.8e02e3db.js" type="module"></script><script defer="defer" src="/js/app.e81906b1.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.b8761398.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.36882298.js" nomodule></script><script defer="defer" src="/js/app-legacy.56c65dc3.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
|
||||
}</style><script defer="defer" src="/js/chunk-vendors.c958a611.js" type="module"></script><script defer="defer" src="/js/app.ccd43f4c.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.b8761398.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.03569e60.js" nomodule></script><script defer="defer" src="/js/app-legacy.71d900dd.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
|
||||
Vendored
+18
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-18
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-18
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+2
-1
@@ -34,8 +34,9 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_read_timeout 600s; # 10 minutes for long-running backtests
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 600s; # 10 minutes for sending response
|
||||
# Allow large file uploads (e.g. indicator import)
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user