@@ -150,6 +150,12 @@ def restore_running_strategies():
|
||||
logger.info(f"[OK] {strategy_type_name} {strategy_id} restored")
|
||||
else:
|
||||
logger.warning(f"[FAIL] {strategy_type_name} {strategy_id} restore failed (state may be stale)")
|
||||
# 如果恢复失败,更新数据库状态为stopped,避免策略处于"僵尸"状态
|
||||
try:
|
||||
strategy_service.update_strategy_status(strategy_id, 'stopped')
|
||||
logger.info(f"[FIX] Updated strategy {strategy_id} status to 'stopped' after restore failure")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update strategy {strategy_id} status after restore failure: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring strategy {strategy_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from app.config.settings import Config
|
||||
from app.config.api_keys import APIKeys
|
||||
from app.config.database import RedisConfig, SQLiteConfig, CacheConfig
|
||||
from app.config.database import RedisConfig, CacheConfig
|
||||
from app.config.data_sources import (
|
||||
DataSourceConfig,
|
||||
FinnhubConfig,
|
||||
@@ -23,7 +23,6 @@ __all__ = [
|
||||
|
||||
# 数据库/缓存
|
||||
'RedisConfig',
|
||||
'SQLiteConfig',
|
||||
'CacheConfig',
|
||||
|
||||
# 数据源
|
||||
|
||||
@@ -46,26 +46,6 @@ class RedisConfig(metaclass=MetaRedisConfig):
|
||||
return f"redis://{cls.HOST}:{cls.PORT}/{cls.DB}"
|
||||
|
||||
|
||||
class MetaSQLiteConfig(type):
|
||||
"""SQLite 配置"""
|
||||
|
||||
@property
|
||||
def DATABASE_FILE(cls):
|
||||
# 默认放在 backend_api_python/data 目录(更干净,也与 docker-compose 挂载一致)
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
default_path = os.path.join(base_dir, 'data', 'quantdinger.db')
|
||||
return os.getenv('SQLITE_DATABASE_FILE', default_path)
|
||||
|
||||
|
||||
class SQLiteConfig(metaclass=MetaSQLiteConfig):
|
||||
"""SQLite 数据库配置"""
|
||||
|
||||
@classmethod
|
||||
def get_path(cls) -> str:
|
||||
"""获取数据库文件路径"""
|
||||
return cls.DATABASE_FILE
|
||||
|
||||
|
||||
class MetaCacheConfig(type):
|
||||
"""缓存业务配置"""
|
||||
|
||||
|
||||
@@ -182,14 +182,44 @@ class PolymarketDataSource:
|
||||
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))
|
||||
# 改进搜索:同时搜索question和slug字段,也支持market_id精确匹配
|
||||
keyword_lower = keyword.lower()
|
||||
is_numeric = keyword_lower.isdigit()
|
||||
has_hyphens = '-' in keyword_lower
|
||||
|
||||
if is_numeric:
|
||||
# 如果是纯数字,可能是market_id,精确匹配
|
||||
cur.execute("""
|
||||
SELECT market_id, question, category, current_probability,
|
||||
volume_24h, liquidity, end_date_iso, status, slug
|
||||
FROM qd_polymarket_markets
|
||||
WHERE market_id = %s AND status = 'active'
|
||||
ORDER BY volume_24h DESC
|
||||
LIMIT %s
|
||||
""", (keyword, limit))
|
||||
elif has_hyphens:
|
||||
# 如果包含连字符,可能是slug,优先匹配slug
|
||||
cur.execute("""
|
||||
SELECT market_id, question, category, current_probability,
|
||||
volume_24h, liquidity, end_date_iso, status, slug
|
||||
FROM qd_polymarket_markets
|
||||
WHERE (slug ILIKE %s OR question ILIKE %s) AND status = 'active'
|
||||
ORDER BY
|
||||
CASE WHEN slug ILIKE %s THEN 1 ELSE 2 END,
|
||||
volume_24h DESC
|
||||
LIMIT %s
|
||||
""", (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", limit))
|
||||
else:
|
||||
# 普通文本搜索
|
||||
cur.execute("""
|
||||
SELECT market_id, question, category, current_probability,
|
||||
volume_24h, liquidity, end_date_iso, status, slug
|
||||
FROM qd_polymarket_markets
|
||||
WHERE (question ILIKE %s OR slug ILIKE %s) AND status = 'active'
|
||||
ORDER BY volume_24h DESC
|
||||
LIMIT %s
|
||||
""", (f"%{keyword}%", f"%{keyword}%", limit))
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
@@ -210,23 +240,133 @@ class PolymarketDataSource:
|
||||
|
||||
# 直接从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)
|
||||
|
||||
# 优化:如果关键词看起来像slug,先尝试直接查询(避免全量获取)
|
||||
import re
|
||||
keyword_lower = keyword.lower().strip()
|
||||
is_slug_like = '-' in keyword_lower and not keyword_lower.isdigit()
|
||||
|
||||
if is_slug_like:
|
||||
# 尝试直接通过slug查询(最高效,根据Polymarket API文档)
|
||||
direct_market = self._fetch_market_by_slug(keyword_lower)
|
||||
if direct_market:
|
||||
logger.info(f"Found market directly by slug (no need to fetch all markets): {keyword_lower}")
|
||||
return [direct_market]
|
||||
|
||||
# 如果直接查询失败,获取更多数据以便有足够的选择空间
|
||||
# 进行多次请求以获取更多市场(每次最多100个事件,但每个事件可能包含多个市场)
|
||||
all_markets = []
|
||||
max_requests = 3 # 最多请求3次,获取300个事件(约4500个市场)
|
||||
for page in range(max_requests):
|
||||
page_markets = self._fetch_from_gamma_api(category=None, limit=100)
|
||||
if not page_markets:
|
||||
break
|
||||
all_markets.extend(page_markets)
|
||||
# 如果已经获取了足够多的市场,可以提前停止
|
||||
if len(all_markets) >= 3000: # 最多获取3000个市场
|
||||
break
|
||||
logger.info(f"Fetched page {page + 1}/{max_requests}, total markets: {len(all_markets)}")
|
||||
# 短暂延迟,避免API限流
|
||||
if page < max_requests - 1:
|
||||
time.sleep(0.5)
|
||||
logger.info(f"Fetched {len(all_markets)} markets from API, filtering for keyword '{keyword}'...")
|
||||
|
||||
# 按关键词过滤(支持多个关键词匹配)
|
||||
keyword_lower = keyword.lower()
|
||||
# 如果关键词看起来像slug(包含连字符),也尝试匹配slug
|
||||
keyword_is_slug = '-' in keyword_lower
|
||||
# 提取关键词(去除常见停用词和标点)
|
||||
# 提取关键词:去除标点,保留字母数字和连字符
|
||||
keyword_words = re.findall(r'\b\w+\b', keyword_lower)
|
||||
# 过滤掉太短的词(少于3个字符)和常见停用词
|
||||
stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'will', 'would', 'should', 'could', 'may', 'might', 'can', 'must'}
|
||||
keyword_words = [w for w in keyword_words if len(w) >= 3 and w not in stop_words]
|
||||
|
||||
# 如果没有提取到关键词,使用原始关键词
|
||||
if not keyword_words:
|
||||
keyword_words = [keyword_lower]
|
||||
|
||||
logger.info(f"Extracted keywords: {keyword_words} from '{keyword}'")
|
||||
|
||||
filtered = []
|
||||
scored_markets = [] # 用于存储带评分的结果
|
||||
top_candidates = [] # 用于存储接近匹配的候选(用于调试)
|
||||
|
||||
for market in all_markets:
|
||||
question = market.get("question", "").lower()
|
||||
# 检查关键词是否在问题中
|
||||
slug = (market.get("slug") or "").lower()
|
||||
market_id = str(market.get("market_id") or "")
|
||||
|
||||
score = 0
|
||||
match_reason = ""
|
||||
|
||||
# 1. 完全匹配(最高优先级,分数100)
|
||||
if keyword_lower in question:
|
||||
filtered.append(market)
|
||||
logger.debug(f"Matched market: {market.get('question', '')[:60]}")
|
||||
if len(filtered) >= limit:
|
||||
break
|
||||
score = 100
|
||||
match_reason = "exact_match_question"
|
||||
elif keyword_lower == slug:
|
||||
score = 100
|
||||
match_reason = "exact_match_slug"
|
||||
|
||||
# 2. 如果关键词看起来像slug,检查slug字段
|
||||
if score < 100 and keyword_is_slug:
|
||||
if keyword_lower == slug:
|
||||
score = 100
|
||||
match_reason = "exact_slug_match"
|
||||
elif keyword_lower in slug or slug in keyword_lower:
|
||||
score = 90
|
||||
match_reason = "partial_slug_match"
|
||||
|
||||
# 3. 如果关键词是纯数字,检查market_id
|
||||
if score < 90 and keyword_lower.isdigit():
|
||||
if keyword_lower == market_id:
|
||||
score = 100
|
||||
match_reason = "market_id_match"
|
||||
|
||||
# 4. 关键词匹配:检查所有关键词是否都在问题中
|
||||
if score < 90 and keyword_words:
|
||||
# 计算匹配的关键词数量
|
||||
matched_words = sum(1 for word in keyword_words if word in question or word in slug)
|
||||
if matched_words > 0:
|
||||
# 匹配率
|
||||
match_ratio = matched_words / len(keyword_words)
|
||||
# 降低阈值:从60%降到40%,提高匹配率
|
||||
if match_ratio >= 0.4:
|
||||
score = int(60 + match_ratio * 30) # 60-90分
|
||||
match_reason = f"keyword_match_{matched_words}/{len(keyword_words)}"
|
||||
else:
|
||||
# 记录接近匹配的候选(用于调试)
|
||||
if matched_words >= 1 and len(top_candidates) < 5:
|
||||
top_candidates.append((match_ratio, market.get('question', '')[:80], matched_words, len(keyword_words)))
|
||||
|
||||
# 5. 部分匹配:检查关键词的主要部分是否在问题中
|
||||
if score < 60 and keyword_words:
|
||||
# 如果关键词包含多个词,尝试匹配主要部分
|
||||
if len(keyword_words) > 1:
|
||||
# 取前3个最重要的词(通常是名词)
|
||||
important_words = keyword_words[:3]
|
||||
matched_important = sum(1 for word in important_words if word in question or word in slug)
|
||||
# 降低要求:至少匹配1个重要词即可
|
||||
if matched_important >= 1:
|
||||
score = 50
|
||||
match_reason = f"important_words_match_{matched_important}/{len(important_words)}"
|
||||
|
||||
if score >= 50: # 降低最低分数要求从60到50
|
||||
scored_markets.append((score, market, match_reason))
|
||||
logger.debug(f"Matched (score={score}, reason={match_reason}): {market.get('question', '')[:60]}")
|
||||
|
||||
logger.info(f"Filtered {len(filtered)} markets matching keyword '{keyword}' from API")
|
||||
# 按分数排序,取前limit个
|
||||
scored_markets.sort(key=lambda x: x[0], reverse=True)
|
||||
filtered = [market for score, market, reason in scored_markets[:limit]]
|
||||
|
||||
# 输出调试信息
|
||||
if len(scored_markets) == 0 and top_candidates:
|
||||
logger.warning(f"No exact matches found. Top candidates (partial matches):")
|
||||
for ratio, question, matched, total in top_candidates:
|
||||
logger.warning(f" - {question} (matched {matched}/{total} keywords, ratio={ratio:.2f})")
|
||||
|
||||
logger.info(f"Filtered {len(filtered)} markets matching keyword '{keyword}' from API (from {len(all_markets)} total markets, {len(scored_markets)} scored matches)")
|
||||
if len(scored_markets) > 0:
|
||||
logger.info(f"Top match: {filtered[0].get('question', '')[:80]} (score={scored_markets[0][0]})")
|
||||
return filtered
|
||||
|
||||
except Exception as e:
|
||||
@@ -301,7 +441,8 @@ class PolymarketDataSource:
|
||||
markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True)
|
||||
return markets[:limit] # 返回排序后的前limit个
|
||||
|
||||
logger.warning("Gamma API failed to fetch markets")
|
||||
# 如果API返回空列表,记录警告(可能是API暂时不可用、网络问题或限流)
|
||||
logger.warning(f"Gamma API failed to fetch markets for category '{category}' (可能原因: API暂时不可用、网络问题、限流或返回空数据)")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
@@ -378,19 +519,27 @@ class PolymarketDataSource:
|
||||
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]}")
|
||||
status_code = response.status_code
|
||||
if status_code == 429:
|
||||
logger.warning(f"Gamma API rate limited (429). 建议: 稍后重试或减少请求频率")
|
||||
elif status_code == 503:
|
||||
logger.warning(f"Gamma API service unavailable (503). Polymarket API可能正在维护")
|
||||
elif status_code >= 500:
|
||||
logger.warning(f"Gamma API server error ({status_code}). Polymarket服务器可能暂时不可用")
|
||||
else:
|
||||
logger.warning(f"Gamma API returned status {status_code}")
|
||||
logger.debug(f"Response headers: {dict(response.headers)}")
|
||||
logger.debug(f"Response text (first 500 chars): {response.text[:500]}")
|
||||
return []
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error("Gamma API request timeout after 15 seconds")
|
||||
logger.warning("Gamma API request timeout after 15 seconds (可能原因: 网络延迟或API响应慢)")
|
||||
return []
|
||||
except requests.exceptions.ConnectionError as ce:
|
||||
logger.error(f"Gamma API connection error: {ce}")
|
||||
logger.warning(f"Gamma API connection error: {ce} (可能原因: 网络连接问题或Polymarket API不可达)")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Gamma API failed: {e}", exc_info=True)
|
||||
logger.warning(f"Gamma API failed: {e} (可能原因: API格式变更、网络问题或服务异常)")
|
||||
return []
|
||||
|
||||
def _parse_gamma_events(self, events_data: List[Dict], category_filter: str = None) -> List[Dict]:
|
||||
@@ -798,8 +947,41 @@ class PolymarketDataSource:
|
||||
if slug_clean:
|
||||
return f"https://polymarket.com/event/{slug_clean}"
|
||||
|
||||
# 如果没有有效slug,使用markets端点作为备选
|
||||
return f"https://polymarket.com/markets/{market_id}"
|
||||
# 如果没有有效slug,尝试通过API获取slug
|
||||
if market_id:
|
||||
try:
|
||||
detail_market = self._fetch_market_detail_by_id(market_id)
|
||||
if detail_market:
|
||||
# 尝试从detail中获取slug
|
||||
event_slug = detail_market.get('slug')
|
||||
if event_slug:
|
||||
slug_str = str(event_slug).strip()
|
||||
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
|
||||
import re
|
||||
slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str)
|
||||
slug_clean = slug_clean.strip('-')
|
||||
if slug_clean:
|
||||
return f"https://polymarket.com/event/{slug_clean}"
|
||||
|
||||
# 如果event没有slug,尝试从markets中获取
|
||||
markets = detail_market.get('markets', [])
|
||||
if markets:
|
||||
for m in markets:
|
||||
market_slug = m.get('slug')
|
||||
if market_slug:
|
||||
slug_str = str(market_slug).strip()
|
||||
if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)):
|
||||
import re
|
||||
slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str)
|
||||
slug_clean = slug_clean.strip('-')
|
||||
if slug_clean:
|
||||
return f"https://polymarket.com/event/{slug_clean}"
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch slug for market {market_id}: {e}")
|
||||
|
||||
# 如果所有方法都失败,返回搜索页面(更可靠)
|
||||
# 注意:Polymarket的URL格式可能已经改变,使用搜索作为fallback
|
||||
return f"https://polymarket.com/search?q={market_id}"
|
||||
|
||||
def _fetch_market_detail_by_id(self, market_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
@@ -858,31 +1040,98 @@ class PolymarketDataSource:
|
||||
logger.debug(f"Failed to fetch market detail by ID {market_id}: {e}")
|
||||
return None
|
||||
|
||||
def _fetch_market_by_slug(self, slug: str) -> Optional[Dict]:
|
||||
"""
|
||||
直接通过slug查询市场(最高效的方式)
|
||||
根据Polymarket API文档:https://docs.polymarket.com/market-data/fetching-markets
|
||||
可以使用 /markets?slug=xxx 直接查询
|
||||
"""
|
||||
try:
|
||||
# 方法1: 尝试通过markets端点直接查询slug
|
||||
url = f"{self.gamma_api}/markets"
|
||||
params = {"slug": slug, "limit": 10}
|
||||
logger.info(f"Fetching market by slug from Gamma API: {url} with params: {params}")
|
||||
response = self.session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
# 解析返回的市场数据
|
||||
markets = self._parse_gamma_events(data)
|
||||
# 精确匹配slug
|
||||
for market in markets:
|
||||
market_slug = market.get("slug", "").lower()
|
||||
if market_slug == slug.lower() or slug.lower() in market_slug:
|
||||
logger.info(f"Found market by slug: {slug}")
|
||||
return market
|
||||
# 如果没有精确匹配,返回第一个
|
||||
if markets:
|
||||
logger.info(f"Found market by slug (fuzzy match): {slug}")
|
||||
return markets[0]
|
||||
elif isinstance(data, dict):
|
||||
# 单个市场对象
|
||||
markets = self._parse_gamma_events([data])
|
||||
if markets:
|
||||
logger.info(f"Found market by slug: {slug}")
|
||||
return markets[0]
|
||||
|
||||
# 方法2: 尝试通过events端点查询(events可能包含slug信息)
|
||||
url = f"{self.gamma_api}/events"
|
||||
params = {"active": "true", "closed": "false", "limit": 100}
|
||||
response = self.session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
events = data if isinstance(data, list) else (data.get("data", []) if isinstance(data, dict) else [])
|
||||
|
||||
# 在返回的事件中查找匹配的slug
|
||||
for event in events:
|
||||
event_slug = (event.get("slug") or "").lower()
|
||||
if event_slug == slug.lower() or slug.lower() in event_slug:
|
||||
parsed = self._parse_gamma_events([event])
|
||||
if parsed:
|
||||
logger.info(f"Found market by slug via events: {slug}")
|
||||
return parsed[0]
|
||||
|
||||
logger.warning(f"Market with slug '{slug}' not found via direct query")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch market by slug {slug}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
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]
|
||||
# 判断是slug还是market_id
|
||||
is_slug = not market_id.isdigit() and ('-' in market_id or any(c.isalpha() for c in market_id))
|
||||
|
||||
# 方法2: 通过events端点搜索
|
||||
# 如果是slug,优先使用直接查询方法
|
||||
if is_slug:
|
||||
market = self._fetch_market_by_slug(market_id)
|
||||
if market:
|
||||
return market
|
||||
|
||||
# 方法1: 通过markets端点查询(支持id和slug)
|
||||
url = f"{self.gamma_api}/markets"
|
||||
params = {"id": market_id, "limit": 10} if not is_slug else {"slug": market_id, "limit": 10}
|
||||
response = self.session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
markets = self._parse_gamma_events(data)
|
||||
if markets:
|
||||
return markets[0]
|
||||
elif isinstance(data, dict):
|
||||
markets = self._parse_gamma_events([data])
|
||||
if markets:
|
||||
return markets[0]
|
||||
|
||||
# 方法2: 通过events端点搜索(作为备选)
|
||||
url = f"{self.gamma_api}/events"
|
||||
params = {
|
||||
"active": "true",
|
||||
@@ -903,7 +1152,7 @@ class PolymarketDataSource:
|
||||
|
||||
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:
|
||||
if str(m_id) == str(market_id) or market.get("slug") == market_id:
|
||||
parsed = self._parse_gamma_events([event])
|
||||
if parsed:
|
||||
return parsed[0]
|
||||
|
||||
@@ -57,7 +57,7 @@ def _extract_indicator_meta_from_code(code: str) -> Dict[str, str]:
|
||||
|
||||
def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Map SQLite row -> frontend expected indicator shape.
|
||||
Map database row -> frontend expected indicator shape.
|
||||
|
||||
Frontend uses:
|
||||
- id, name, description, code
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""
|
||||
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
|
||||
from app.data_sources.polymarket import PolymarketDataSource
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -19,286 +19,208 @@ polymarket_bp = Blueprint('polymarket', __name__)
|
||||
polymarket_source = PolymarketDataSource()
|
||||
|
||||
|
||||
@polymarket_bp.route("/markets", methods=["GET"])
|
||||
@polymarket_bp.route("/analyze", methods=["POST"])
|
||||
@login_required
|
||||
def get_markets():
|
||||
def analyze_polymarket():
|
||||
"""
|
||||
获取预测市场列表
|
||||
分析Polymarket预测市场(用户输入链接或标题)
|
||||
|
||||
Query params:
|
||||
category: crypto/politics/economics/sports (optional)
|
||||
sort_by: volume_24h/ai_score/probability_change (default: volume_24h)
|
||||
limit: 数量 (default: 20)
|
||||
POST /api/polymarket/analyze
|
||||
Body: {
|
||||
"input": "https://polymarket.com/event/xxx" 或 "市场标题",
|
||||
"language": "zh-CN" (optional)
|
||||
}
|
||||
|
||||
流程:
|
||||
1. 从输入中解析market_id或slug
|
||||
2. 从API获取市场数据
|
||||
3. 检查计费并扣除积分
|
||||
4. 调用AI分析
|
||||
5. 返回分析结果
|
||||
"""
|
||||
try:
|
||||
category = request.args.get("category")
|
||||
sort_by = request.args.get("sort_by", "volume_24h")
|
||||
limit = int(request.args.get("limit", 20))
|
||||
from app.services.billing_service import BillingService
|
||||
from app.services.polymarket_analyzer import PolymarketAnalyzer
|
||||
from decimal import Decimal
|
||||
|
||||
# 获取市场列表
|
||||
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.")
|
||||
user_id = getattr(g, 'user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": [],
|
||||
"warning": "No markets available. API may be unavailable or cache is empty."
|
||||
})
|
||||
"code": 0,
|
||||
"msg": "User not authenticated",
|
||||
"data": None
|
||||
}), 401
|
||||
|
||||
# 从数据库读取缓存的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
|
||||
data = request.get_json() or {}
|
||||
input_text = (data.get('input') or '').strip()
|
||||
language = data.get('language', 'zh-CN')
|
||||
|
||||
if not input_text:
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": "Input is required (Polymarket URL or market title)",
|
||||
"data": None
|
||||
}), 400
|
||||
|
||||
# 1. 解析market_id或slug
|
||||
market_id = None
|
||||
slug = None
|
||||
|
||||
# 尝试从URL中提取
|
||||
url_patterns = [
|
||||
r'polymarket\.com/event/([^/?]+)',
|
||||
r'polymarket\.com/markets/(\d+)',
|
||||
r'polymarket\.com/market/(\d+)',
|
||||
]
|
||||
|
||||
for pattern in url_patterns:
|
||||
match = re.search(pattern, input_text)
|
||||
if match:
|
||||
extracted = match.group(1)
|
||||
# 如果是数字,是market_id;否则是slug
|
||||
if extracted.isdigit():
|
||||
market_id = extracted
|
||||
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
|
||||
slug = extracted
|
||||
break
|
||||
|
||||
# 筛选:优先返回有交易机会的市场,但也包含其他活跃市场
|
||||
# 重点:不是简单复制数据,而是找到交易机会,但也要保证有足够的数据展示
|
||||
opportunity_markets = []
|
||||
other_markets = []
|
||||
# 如果没有从URL提取到,尝试搜索市场
|
||||
if not market_id and not slug:
|
||||
# 尝试通过标题搜索
|
||||
logger.info(f"Searching for market by title: {input_text[:100]}")
|
||||
search_results = polymarket_source.search_markets(input_text, limit=5)
|
||||
if search_results:
|
||||
# 使用第一个搜索结果
|
||||
market_id = search_results[0].get('market_id')
|
||||
logger.info(f"Found market via search: {market_id}")
|
||||
|
||||
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 not market_id and not slug:
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": "Could not parse market ID or slug from input. Please provide a valid Polymarket URL or market title.",
|
||||
"data": None
|
||||
}), 400
|
||||
|
||||
# 2. 获取市场数据
|
||||
if market_id:
|
||||
market = polymarket_source.get_market_details(market_id)
|
||||
elif slug:
|
||||
# 通过slug查找市场(需要先搜索)
|
||||
search_results = polymarket_source.search_markets(slug, limit=10)
|
||||
market = None
|
||||
for result in search_results:
|
||||
if result.get('slug') == slug or slug in (result.get('question') or ''):
|
||||
market = result
|
||||
market_id = result.get('market_id')
|
||||
break
|
||||
|
||||
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 not market and search_results:
|
||||
# 使用第一个搜索结果
|
||||
market = search_results[0]
|
||||
market_id = market.get('market_id')
|
||||
|
||||
# 合并结果:优先显示机会市场,然后补充其他活跃市场
|
||||
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))]
|
||||
|
||||
# 排序和筛选
|
||||
# 辅助函数:安全获取 ai_analysis 数据(处理 None 情况)
|
||||
def safe_get_ai_analysis(market, key, default=0):
|
||||
ai_analysis = market.get('ai_analysis')
|
||||
if ai_analysis is None:
|
||||
return default
|
||||
return ai_analysis.get(key, default) or default
|
||||
|
||||
if sort_by == "ai_score":
|
||||
# 按AI机会评分排序(高概率/高回报比优先)
|
||||
opportunity_markets.sort(
|
||||
key=lambda x: (
|
||||
safe_get_ai_analysis(x, 'opportunity_score', 0),
|
||||
abs(safe_get_ai_analysis(x, 'divergence', 0)), # 差异越大越好
|
||||
safe_get_ai_analysis(x, 'confidence_score', 0) # 置信度越高越好
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
elif sort_by == "high_probability":
|
||||
# 高概率机会:AI预测概率 > 市场概率 + 10%
|
||||
opportunity_markets.sort(
|
||||
key=lambda x: (
|
||||
safe_get_ai_analysis(x, 'ai_predicted_probability', 0),
|
||||
safe_get_ai_analysis(x, 'confidence_score', 0)
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
elif sort_by == "high_return":
|
||||
# 高回报比机会:AI与市场差异大且置信度高
|
||||
opportunity_markets.sort(
|
||||
key=lambda x: (
|
||||
abs(safe_get_ai_analysis(x, 'divergence', 0)) *
|
||||
safe_get_ai_analysis(x, 'confidence_score', 0) / 100,
|
||||
safe_get_ai_analysis(x, 'opportunity_score', 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",
|
||||
"msg": "Market not found. Please check the URL or title.",
|
||||
"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}")
|
||||
if not market_id:
|
||||
market_id = market.get('market_id')
|
||||
|
||||
# 资产交易机会(暂时返回空,可以后续实现)
|
||||
asset_opportunities = []
|
||||
if not market_id:
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": "Invalid market data",
|
||||
"data": None
|
||||
}), 400
|
||||
|
||||
# 3. 检查计费
|
||||
billing = BillingService()
|
||||
cost = 0
|
||||
|
||||
if billing.is_billing_enabled():
|
||||
cost = billing.get_feature_cost('polymarket_deep_analysis')
|
||||
|
||||
if cost > 0:
|
||||
user_credits = billing.get_user_credits(user_id)
|
||||
if user_credits < Decimal(str(cost)):
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": "Insufficient credits",
|
||||
"data": {
|
||||
"required": cost,
|
||||
"current": float(user_credits),
|
||||
"shortage": float(Decimal(str(cost)) - user_credits)
|
||||
}
|
||||
}), 400
|
||||
|
||||
# 扣除积分(使用check_and_consume方法,它会自动从配置中获取成本)
|
||||
success, error_msg = billing.check_and_consume(
|
||||
user_id=user_id,
|
||||
feature='polymarket_deep_analysis',
|
||||
reference_id=f"polymarket_{market_id}"
|
||||
)
|
||||
|
||||
if not success:
|
||||
# 检查是否是积分不足的错误
|
||||
if error_msg.startswith('insufficient_credits'):
|
||||
parts = error_msg.split(':')
|
||||
if len(parts) >= 3:
|
||||
current_credits = parts[1]
|
||||
required_credits = parts[2]
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": "Insufficient credits",
|
||||
"data": {
|
||||
"required": float(required_credits),
|
||||
"current": float(current_credits),
|
||||
"shortage": float(Decimal(required_credits) - Decimal(current_credits))
|
||||
}
|
||||
}), 400
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": f"Failed to deduct credits: {error_msg}",
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
# 4. 执行AI分析(传递语言和模型参数)
|
||||
analyzer = PolymarketAnalyzer()
|
||||
model = request.get_json().get('model') # 可选:从请求中获取模型参数
|
||||
analysis_result = analyzer.analyze_market(
|
||||
market_id,
|
||||
user_id=user_id,
|
||||
use_cache=False,
|
||||
language=language,
|
||||
model=model
|
||||
)
|
||||
|
||||
if analysis_result.get('error'):
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": analysis_result.get('error', 'Analysis failed'),
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
# 5. 获取剩余积分
|
||||
remaining_credits = 0
|
||||
if billing.is_billing_enabled():
|
||||
remaining_credits = float(billing.get_user_credits(user_id))
|
||||
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"market": market,
|
||||
"ai_analysis": analysis,
|
||||
"asset_opportunities": asset_opportunities
|
||||
"analysis": analysis_result,
|
||||
"credits_charged": cost,
|
||||
"remaining_credits": remaining_credits
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_market_detail failed: {e}", exc_info=True)
|
||||
logger.error(f"Polymarket analyze API failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": str(e),
|
||||
@@ -306,142 +228,99 @@ def get_market_detail(market_id: str):
|
||||
}), 500
|
||||
|
||||
|
||||
@polymarket_bp.route("/markets/<market_id>/opportunities", methods=["GET"])
|
||||
@polymarket_bp.route("/history", 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():
|
||||
def get_polymarket_history():
|
||||
"""
|
||||
获取AI推荐的高价值预测市场
|
||||
Get user's Polymarket analysis history.
|
||||
|
||||
Query params:
|
||||
limit: 数量 (default: 10)
|
||||
GET /api/polymarket/history?page=1&page_size=20
|
||||
"""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 10))
|
||||
user_id = g.user_id
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = min(request.args.get('page_size', 20, type=int), 100)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 获取所有活跃市场
|
||||
all_markets = polymarket_source.get_trending_markets(limit=100)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 获取总数
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) AS total
|
||||
FROM qd_analysis_tasks
|
||||
WHERE user_id = %s AND market = 'Polymarket'
|
||||
""", (user_id,))
|
||||
total_row = cur.fetchone()
|
||||
total = total_row['total'] if total_row else 0
|
||||
|
||||
# 获取历史记录
|
||||
cur.execute("""
|
||||
SELECT
|
||||
t.id,
|
||||
t.symbol AS market_id,
|
||||
t.model,
|
||||
t.language,
|
||||
t.status,
|
||||
t.created_at,
|
||||
t.completed_at,
|
||||
t.result_json
|
||||
FROM qd_analysis_tasks t
|
||||
WHERE t.user_id = %s AND t.market = 'Polymarket'
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", (user_id, page_size, offset))
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
# 从数据库读取缓存的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}")
|
||||
|
||||
# 如果没有缓存结果,返回空列表(等待后台任务分析)
|
||||
# 解析结果
|
||||
items = []
|
||||
for row in rows:
|
||||
result_json = row.get('result_json', '{}')
|
||||
try:
|
||||
result_data = json.loads(result_json) if result_json else {}
|
||||
except:
|
||||
result_data = {}
|
||||
|
||||
market_data = result_data.get('market', {})
|
||||
analysis_data = result_data.get('analysis', {})
|
||||
|
||||
created_at = row.get('created_at')
|
||||
completed_at = row.get('completed_at')
|
||||
if created_at and hasattr(created_at, 'isoformat'):
|
||||
created_at = created_at.isoformat()
|
||||
if completed_at and hasattr(completed_at, 'isoformat'):
|
||||
completed_at = completed_at.isoformat()
|
||||
|
||||
items.append({
|
||||
'id': row.get('id'),
|
||||
'market_id': row.get('market_id'),
|
||||
'market_title': market_data.get('question') or market_data.get('title') or f"Market {row.get('market_id')}",
|
||||
'market_url': market_data.get('polymarket_url'),
|
||||
'ai_predicted_probability': analysis_data.get('ai_predicted_probability'),
|
||||
'market_probability': analysis_data.get('market_probability'),
|
||||
'recommendation': analysis_data.get('recommendation'),
|
||||
'opportunity_score': analysis_data.get('opportunity_score'),
|
||||
'confidence_score': analysis_data.get('confidence_score'),
|
||||
'status': row.get('status'),
|
||||
'created_at': created_at,
|
||||
'completed_at': completed_at
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": recommendations[:limit]
|
||||
"data": {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
logger.error(f"Get Polymarket history failed: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 0,
|
||||
"msg": str(e),
|
||||
|
||||
@@ -1334,32 +1334,29 @@ def get_admin_ai_stats():
|
||||
memory_summary = {}
|
||||
|
||||
# --- Per-user stats ---
|
||||
user_conditions = []
|
||||
# Build WHERE clause for user search (applied after JOIN)
|
||||
user_where_clause = ""
|
||||
user_params = []
|
||||
if search:
|
||||
user_conditions.append("(u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)")
|
||||
like_val = f"%{search}%"
|
||||
user_params.extend([like_val, like_val, like_val])
|
||||
user_where_clause = "WHERE (u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)"
|
||||
like_val = f"%{search.strip()}%"
|
||||
user_params = [like_val, like_val, like_val]
|
||||
|
||||
user_where = ""
|
||||
if user_conditions:
|
||||
user_where = "WHERE " + " AND ".join(user_conditions)
|
||||
|
||||
# Count distinct users who have analysis records
|
||||
cur.execute(
|
||||
f"""
|
||||
# Count distinct users who have analysis records (matching search criteria)
|
||||
count_sql = f"""
|
||||
SELECT COUNT(DISTINCT t.user_id) AS cnt
|
||||
FROM qd_analysis_tasks t
|
||||
LEFT JOIN qd_users u ON u.id = t.user_id
|
||||
{user_where}
|
||||
""",
|
||||
tuple(user_params)
|
||||
)
|
||||
user_total = cur.fetchone()['cnt']
|
||||
{user_where_clause}
|
||||
"""
|
||||
cur.execute(count_sql, tuple(user_params))
|
||||
count_result = cur.fetchone()
|
||||
user_total = count_result['cnt'] if count_result else 0
|
||||
|
||||
# Get per-user aggregated stats
|
||||
cur.execute(
|
||||
f"""
|
||||
# Important: Filter by user search criteria AFTER grouping, but we need to apply it in WHERE
|
||||
# Since we're grouping by user fields, we need to filter before GROUP BY
|
||||
stats_sql = f"""
|
||||
SELECT
|
||||
t.user_id,
|
||||
u.username,
|
||||
@@ -1372,13 +1369,12 @@ def get_admin_ai_stats():
|
||||
MIN(t.created_at) AS first_analysis_at
|
||||
FROM qd_analysis_tasks t
|
||||
LEFT JOIN qd_users u ON u.id = t.user_id
|
||||
{user_where}
|
||||
{user_where_clause}
|
||||
GROUP BY t.user_id, u.username, u.nickname, u.email
|
||||
ORDER BY analysis_count DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
tuple(user_params) + (page_size, offset)
|
||||
)
|
||||
"""
|
||||
cur.execute(stats_sql, tuple(user_params) + (page_size, offset))
|
||||
user_rows = cur.fetchall() or []
|
||||
|
||||
# Get per-user analysis_memory stats (correct/helpful counts)
|
||||
@@ -1417,13 +1413,15 @@ def get_admin_ai_stats():
|
||||
memory_stats_map = {}
|
||||
|
||||
# Get recent analysis records (last 50)
|
||||
# Ensure we get user info even if user_id is NULL or user doesn't exist
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
COALESCE(u.username, '') AS username,
|
||||
COALESCE(u.nickname, '') AS nickname,
|
||||
COALESCE(u.email, '') AS email,
|
||||
t.market,
|
||||
t.symbol,
|
||||
t.model,
|
||||
@@ -1432,6 +1430,7 @@ def get_admin_ai_stats():
|
||||
t.completed_at
|
||||
FROM qd_analysis_tasks t
|
||||
LEFT JOIN qd_users u ON u.id = t.user_id
|
||||
WHERE t.user_id IS NOT NULL
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
@@ -1444,26 +1443,40 @@ def get_admin_ai_stats():
|
||||
user_items = []
|
||||
for row in user_rows:
|
||||
uid = row.get('user_id')
|
||||
if not uid: # Skip rows with NULL user_id
|
||||
continue
|
||||
|
||||
ms = memory_stats_map.get(uid, {})
|
||||
last_at = row.get('last_analysis_at')
|
||||
first_at = row.get('first_analysis_at')
|
||||
if hasattr(last_at, 'isoformat'):
|
||||
|
||||
# Convert datetime to ISO format string if needed
|
||||
if last_at and hasattr(last_at, 'isoformat'):
|
||||
last_at = last_at.isoformat()
|
||||
if hasattr(first_at, 'isoformat'):
|
||||
elif last_at:
|
||||
last_at = str(last_at)
|
||||
else:
|
||||
last_at = None
|
||||
|
||||
if first_at and hasattr(first_at, 'isoformat'):
|
||||
first_at = first_at.isoformat()
|
||||
elif first_at:
|
||||
first_at = str(first_at)
|
||||
else:
|
||||
first_at = None
|
||||
|
||||
user_items.append({
|
||||
'user_id': uid,
|
||||
'username': row.get('username') or '',
|
||||
'nickname': row.get('nickname') or '',
|
||||
'email': row.get('email') or '',
|
||||
'analysis_count': row.get('analysis_count') or 0,
|
||||
'symbol_count': row.get('symbol_count') or 0,
|
||||
'market_count': row.get('market_count') or 0,
|
||||
'correct': ms.get('correct', 0),
|
||||
'incorrect': ms.get('incorrect', 0),
|
||||
'helpful': ms.get('helpful', 0),
|
||||
'not_helpful': ms.get('not_helpful', 0),
|
||||
'user_id': int(uid),
|
||||
'username': str(row.get('username') or ''),
|
||||
'nickname': str(row.get('nickname') or ''),
|
||||
'email': str(row.get('email') or ''),
|
||||
'analysis_count': int(row.get('analysis_count') or 0),
|
||||
'symbol_count': int(row.get('symbol_count') or 0),
|
||||
'market_count': int(row.get('market_count') or 0),
|
||||
'correct': int(ms.get('correct', 0)),
|
||||
'incorrect': int(ms.get('incorrect', 0)),
|
||||
'helpful': int(ms.get('helpful', 0)),
|
||||
'not_helpful': int(ms.get('not_helpful', 0)),
|
||||
'last_analysis_at': last_at,
|
||||
'first_analysis_at': first_at
|
||||
})
|
||||
@@ -1471,22 +1484,38 @@ def get_admin_ai_stats():
|
||||
# Build recent records
|
||||
recent_items = []
|
||||
for row in recent_rows:
|
||||
user_id = row.get('user_id')
|
||||
if not user_id: # Skip rows with NULL user_id
|
||||
continue
|
||||
|
||||
created_at = row.get('created_at')
|
||||
completed_at = row.get('completed_at')
|
||||
if hasattr(created_at, 'isoformat'):
|
||||
|
||||
# Convert datetime to ISO format string if needed
|
||||
if created_at and hasattr(created_at, 'isoformat'):
|
||||
created_at = created_at.isoformat()
|
||||
if hasattr(completed_at, 'isoformat'):
|
||||
elif created_at:
|
||||
created_at = str(created_at)
|
||||
else:
|
||||
created_at = None
|
||||
|
||||
if completed_at and hasattr(completed_at, 'isoformat'):
|
||||
completed_at = completed_at.isoformat()
|
||||
elif completed_at:
|
||||
completed_at = str(completed_at)
|
||||
else:
|
||||
completed_at = None
|
||||
|
||||
recent_items.append({
|
||||
'id': row['id'],
|
||||
'user_id': row.get('user_id'),
|
||||
'username': row.get('username') or '',
|
||||
'nickname': row.get('nickname') or '',
|
||||
'market': row.get('market') or '',
|
||||
'symbol': row.get('symbol') or '',
|
||||
'model': row.get('model') or '',
|
||||
'status': row.get('status') or '',
|
||||
'id': int(row.get('id') or 0),
|
||||
'user_id': int(user_id),
|
||||
'username': str(row.get('username') or ''),
|
||||
'nickname': str(row.get('nickname') or ''),
|
||||
'email': str(row.get('email') or ''),
|
||||
'market': str(row.get('market') or ''),
|
||||
'symbol': str(row.get('symbol') or ''),
|
||||
'model': str(row.get('model') or ''),
|
||||
'status': str(row.get('status') or ''),
|
||||
'created_at': created_at,
|
||||
'completed_at': completed_at
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ DEFAULT_BILLING_CONFIG = {
|
||||
'cost_backtest': 3, # 回测 每次消耗积分
|
||||
'cost_portfolio_monitor': 8, # Portfolio AI监控 每次消耗积分
|
||||
'cost_indicator_create': 0, # 创建指标 免费
|
||||
'cost_polymarket_deep_analysis': 15, # Polymarket深度分析 每次消耗积分
|
||||
}
|
||||
|
||||
# Feature name mapping (for log recording)
|
||||
@@ -45,6 +46,7 @@ FEATURE_NAMES = {
|
||||
'backtest': 'Backtest',
|
||||
'portfolio_monitor': 'Portfolio Monitor',
|
||||
'indicator_create': 'Indicator Create',
|
||||
'polymarket_deep_analysis': 'Polymarket Deep Analysis',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class FastAnalysisService:
|
||||
include_macro=True,
|
||||
include_news=True,
|
||||
include_polymarket=True, # 包含预测市场数据
|
||||
timeout=30
|
||||
timeout=45 # 增加超时时间,确保数据收集完成
|
||||
)
|
||||
|
||||
def _calculate_indicators(self, kline_data: List[Dict]) -> Dict[str, Any]:
|
||||
@@ -678,10 +678,16 @@ IMPORTANT:
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Get default model if not specified
|
||||
if not model:
|
||||
model = self.llm_service.get_default_model()
|
||||
logger.debug(f"Using default model: {model}")
|
||||
|
||||
result = {
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"language": language,
|
||||
"model": model, # Include model in result from the start
|
||||
"timeframe": timeframe,
|
||||
"analysis_time_ms": 0,
|
||||
"error": None,
|
||||
@@ -823,6 +829,8 @@ IMPORTANT:
|
||||
"decision": analysis.get("decision", "HOLD"),
|
||||
"confidence": analysis.get("confidence", 50),
|
||||
"summary": analysis.get("summary", ""),
|
||||
"model": model, # Model is already set in result initialization
|
||||
"language": language, # Ensure language is included for task record
|
||||
"detailed_analysis": {
|
||||
"technical": detailed_analysis.get("technical", ""),
|
||||
"fundamental": detailed_analysis.get("fundamental", ""),
|
||||
@@ -1600,11 +1608,81 @@ IMPORTANT:
|
||||
from app.services.analysis_memory import get_analysis_memory
|
||||
memory = get_analysis_memory()
|
||||
memory_id = memory.store(result, user_id=user_id)
|
||||
|
||||
# Also save to qd_analysis_tasks for admin statistics
|
||||
self._save_analysis_task(result, user_id=user_id)
|
||||
|
||||
return memory_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory storage failed: {e}")
|
||||
return None
|
||||
|
||||
def _save_analysis_task(self, result: Dict, user_id: int = None) -> Optional[int]:
|
||||
"""
|
||||
Save analysis record to qd_analysis_tasks table for admin statistics.
|
||||
|
||||
Args:
|
||||
result: Analysis result dictionary
|
||||
user_id: User ID who created this analysis
|
||||
|
||||
Returns:
|
||||
Task ID or None if failed
|
||||
"""
|
||||
try:
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
market = result.get("market", "")
|
||||
symbol = result.get("symbol", "")
|
||||
model = result.get("model", "")
|
||||
# If model is empty, get default model
|
||||
if not model:
|
||||
from app.services.llm import LLMService
|
||||
llm_service = LLMService()
|
||||
model = llm_service.get_default_model()
|
||||
language = result.get("language", "en-US")
|
||||
status = "completed" if not result.get("error") else "failed"
|
||||
result_json = json.dumps(result, ensure_ascii=False)
|
||||
error_message = result.get("error", "")
|
||||
|
||||
if not market or not symbol:
|
||||
logger.warning(f"Cannot save analysis task: missing market or symbol")
|
||||
return None
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# PostgreSQL: Use RETURNING to get the inserted ID
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_analysis_tasks
|
||||
(user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
int(user_id) if user_id else 1, # Default to user 1 if not provided
|
||||
str(market),
|
||||
str(symbol),
|
||||
str(model) if model else '',
|
||||
str(language),
|
||||
str(status),
|
||||
str(result_json),
|
||||
str(error_message) if error_message else ''
|
||||
)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
task_id = row['id'] if row else None
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
if task_id:
|
||||
logger.debug(f"Saved analysis task {task_id} for user {user_id}: {market}:{symbol}")
|
||||
return task_id
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save analysis task: {e}")
|
||||
return None
|
||||
|
||||
# ==================== Backward Compatibility ====================
|
||||
|
||||
def analyze_legacy_format(self, market: str, symbol: str, language: str = 'en-US',
|
||||
|
||||
@@ -1146,10 +1146,9 @@ class MarketDataCollector:
|
||||
return news_list
|
||||
|
||||
# 搜索全球重大事件(最近24小时)
|
||||
# 优化:减少搜索次数,只搜索最重要的查询
|
||||
global_event_queries = [
|
||||
"war conflict breaking news today",
|
||||
"geopolitical crisis latest",
|
||||
"US Iran military news"
|
||||
"war conflict breaking news today" # 只搜索最重要的查询,减少API调用
|
||||
]
|
||||
|
||||
for query in global_event_queries:
|
||||
@@ -1226,14 +1225,20 @@ class MarketDataCollector:
|
||||
keywords = self._extract_polymarket_keywords(symbol, market)
|
||||
logger.info(f"Extracted Polymarket keywords for {symbol}: {keywords}")
|
||||
|
||||
# 直接调用API搜索,不使用数据库缓存
|
||||
# 因为AI分析需要最新的、相关的数据,数据库不可能包含所有市场
|
||||
# 优化:使用缓存加速,减少API调用时间
|
||||
# 对于AI分析,使用短期缓存(5分钟)即可,既保证时效性又提升性能
|
||||
# 进一步优化:限制关键词数量,只搜索最重要的关键词(最多2个)
|
||||
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)
|
||||
max_keywords = 2 # 最多只搜索2个关键词,减少API调用
|
||||
for keyword in keywords[:max_keywords]:
|
||||
try:
|
||||
# 使用use_cache=True启用缓存,减少API调用时间
|
||||
markets = polymarket_source.search_markets(keyword, limit=5, use_cache=True)
|
||||
logger.info(f"Found {len(markets)} markets for keyword '{keyword}' (cached)")
|
||||
related_markets.extend(markets)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to search Polymarket for keyword '{keyword}': {e}")
|
||||
continue
|
||||
|
||||
# 去重
|
||||
seen = set()
|
||||
@@ -1271,39 +1276,54 @@ class MarketDataCollector:
|
||||
return []
|
||||
|
||||
def _extract_polymarket_keywords(self, symbol: str, market: str) -> List[str]:
|
||||
"""提取用于搜索预测市场的关键词"""
|
||||
"""
|
||||
提取用于搜索预测市场的关键词
|
||||
优化:只保留最重要的关键词,减少API调用次数
|
||||
"""
|
||||
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']
|
||||
'BTC': 'Bitcoin',
|
||||
'ETH': 'Ethereum',
|
||||
'SOL': 'Solana',
|
||||
'BNB': 'Binance',
|
||||
'XRP': 'Ripple',
|
||||
'ADA': 'Cardano',
|
||||
'DOGE': 'Dogecoin',
|
||||
'AVAX': 'Avalanche',
|
||||
'DOT': 'Polkadot',
|
||||
'MATIC': 'Polygon'
|
||||
}
|
||||
|
||||
base_symbol = symbol.split('/')[0] if '/' in symbol else symbol
|
||||
if base_symbol in crypto_names:
|
||||
keywords.extend(crypto_names[base_symbol])
|
||||
# 只添加一个全名,避免大小写重复
|
||||
keywords.append(crypto_names[base_symbol])
|
||||
|
||||
# 添加价格相关关键词
|
||||
if market == 'Crypto':
|
||||
keywords.extend(['$100k', '$100000', '100k', 'ETF', 'approval'])
|
||||
# 优化:移除通用关键词(如 '$100k', 'ETF', 'approval'),这些会匹配到很多不相关的市场
|
||||
# 只保留与资产直接相关的关键词,最多2-3个
|
||||
|
||||
return keywords
|
||||
# 去重并限制数量(最多3个关键词)
|
||||
unique_keywords = []
|
||||
seen = set()
|
||||
for kw in keywords:
|
||||
kw_lower = kw.lower()
|
||||
if kw_lower not in seen:
|
||||
seen.add(kw_lower)
|
||||
unique_keywords.append(kw)
|
||||
if len(unique_keywords) >= 3: # 最多3个关键词
|
||||
break
|
||||
|
||||
logger.info(f"Extracted {len(unique_keywords)} Polymarket keywords (optimized from {len(keywords)}): {unique_keywords}")
|
||||
return unique_keywords
|
||||
|
||||
|
||||
# 全局实例
|
||||
|
||||
@@ -204,12 +204,21 @@ class PendingOrderWorker:
|
||||
try:
|
||||
sc = load_strategy_configs(int(sid))
|
||||
exec_mode = (sc.get("execution_mode") or "").strip().lower()
|
||||
if exec_mode != "live":
|
||||
logger.debug(f"[PositionSync] Strategy {sid} skipped: execution_mode='{exec_mode}' (needs 'live')")
|
||||
# 修改:即使signal模式,如果指定了target_strategy_id(策略启动时调用),也要同步
|
||||
# 这样可以清理用户在交易所手动平仓但数据库记录还在的"幽灵持仓"
|
||||
if exec_mode != "live" and not target_strategy_id:
|
||||
logger.debug(f"[PositionSync] Strategy {sid} skipped: execution_mode='{exec_mode}' (needs 'live' or explicit target)")
|
||||
continue
|
||||
sync_user_id = int(sc.get("user_id") or 1)
|
||||
exchange_config = resolve_exchange_config(sc.get("exchange_config") or {}, user_id=sync_user_id)
|
||||
safe_cfg = safe_exchange_config_for_log(exchange_config)
|
||||
|
||||
# 检查 exchange_id 是否有效,如果为空或无效则跳过同步(signal模式可能没有配置交易所)
|
||||
exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower()
|
||||
if not exchange_id:
|
||||
logger.debug(f"[PositionSync] Strategy {sid} skipped: exchange_id is empty (signal mode or no exchange config)")
|
||||
continue
|
||||
|
||||
market_type = (sc.get("market_type") or exchange_config.get("market_type") or "swap")
|
||||
market_type = str(market_type or "swap").strip().lower()
|
||||
if market_type in ("futures", "future", "perp", "perpetual"):
|
||||
@@ -237,7 +246,12 @@ class PendingOrderWorker:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
client = create_client(exchange_config, market_type=market_type)
|
||||
# 尝试创建客户端,如果失败则跳过(可能是配置错误)
|
||||
try:
|
||||
client = create_client(exchange_config, market_type=market_type)
|
||||
except Exception as e:
|
||||
logger.debug(f"[PositionSync] Strategy {sid} skipped: failed to create client (exchange_id={exchange_id}): {e}")
|
||||
continue
|
||||
|
||||
# Build an "exchange snapshot" per symbol+side
|
||||
exch_size: Dict[str, Dict[str, float]] = {} # {symbol: {long: size, short: size}}
|
||||
|
||||
@@ -24,7 +24,7 @@ class PolymarketAnalyzer:
|
||||
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:
|
||||
def analyze_market(self, market_id: str, user_id: int = None, use_cache: bool = True, language: str = 'zh-CN', model: str = None) -> Dict:
|
||||
"""
|
||||
分析单个预测市场
|
||||
|
||||
@@ -32,6 +32,7 @@ class PolymarketAnalyzer:
|
||||
market_id: 市场ID
|
||||
user_id: 用户ID(可选,用于用户特定分析)
|
||||
use_cache: 是否使用缓存的分析结果(默认True)
|
||||
language: 语言设置('zh-CN' 或 'en-US'),用于生成对应语言的AI分析结果
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
@@ -64,7 +65,8 @@ class PolymarketAnalyzer:
|
||||
question=market['question'],
|
||||
current_market_prob=market['current_probability'],
|
||||
related_news=related_news,
|
||||
asset_data=asset_data
|
||||
asset_data=asset_data,
|
||||
language=language
|
||||
)
|
||||
|
||||
# 5. 计算机会评分
|
||||
@@ -193,9 +195,12 @@ class PolymarketAnalyzer:
|
||||
return []
|
||||
|
||||
def _ai_predict_probability(self, question: str, current_market_prob: float,
|
||||
related_news: List, asset_data: Dict) -> Dict:
|
||||
related_news: List, asset_data: Dict, language: str = 'zh-CN') -> Dict:
|
||||
"""使用AI预测事件概率"""
|
||||
try:
|
||||
# 根据语言设置构建prompt
|
||||
is_english = language.lower() in ['en', 'en-us', 'en_us']
|
||||
|
||||
# 构建prompt
|
||||
news_text = "\n".join([f"- {n.get('title', '')[:100]}" for n in related_news[:5]])
|
||||
|
||||
@@ -204,7 +209,16 @@ class PolymarketAnalyzer:
|
||||
price_data = asset_data.get('price', {})
|
||||
indicators = asset_data.get('indicators', {})
|
||||
if price_data:
|
||||
asset_text = f"""
|
||||
if is_english:
|
||||
asset_text = f"""
|
||||
Related Asset Data:
|
||||
- Current Price: {price_data.get('current_price', 'N/A')}
|
||||
- 24h Change: {price_data.get('change_24h', 0):.2f}%
|
||||
- RSI: {indicators.get('rsi', {}).get('value', 'N/A')}
|
||||
- MACD: {indicators.get('macd', {}).get('signal', 'N/A')}
|
||||
"""
|
||||
else:
|
||||
asset_text = f"""
|
||||
相关资产数据:
|
||||
- 当前价格: {price_data.get('current_price', 'N/A')}
|
||||
- 24h涨跌幅: {price_data.get('change_24h', 0):.2f}%
|
||||
@@ -212,7 +226,38 @@ class PolymarketAnalyzer:
|
||||
- MACD: {indicators.get('macd', {}).get('signal', 'N/A')}
|
||||
"""
|
||||
|
||||
prompt = f"""分析以下预测市场事件,评估其发生的概率:
|
||||
if is_english:
|
||||
prompt = f"""Analyze the following prediction market event and assess its probability of occurrence:
|
||||
|
||||
Question: {question}
|
||||
Current Market Probability: {current_market_prob}%
|
||||
|
||||
Related News:
|
||||
{news_text if news_text else "No related news available"}
|
||||
|
||||
{asset_text}
|
||||
|
||||
Please analyze based on the following dimensions:
|
||||
1. Success rate of similar historical events
|
||||
2. Current news and trends
|
||||
3. Related asset price movements and technical indicators
|
||||
4. Macro environment factors (VIX, DXY, interest rates, etc.)
|
||||
5. Market sentiment indicators
|
||||
|
||||
Output JSON format:
|
||||
{{
|
||||
"predicted_probability": 72.5, // Your predicted probability (0-100)
|
||||
"confidence": 75.0, // Confidence level (0-100)
|
||||
"reasoning": "Detailed analysis...",
|
||||
"key_factors": ["Factor 1", "Factor 2"],
|
||||
"risk_factors": ["Risk 1", "Risk 2"]
|
||||
}}
|
||||
|
||||
IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) must be in English."""
|
||||
|
||||
system_prompt = "You are a professional market analyst specializing in prediction market analysis. Please objectively assess the probability of events occurring based on the provided data. Respond in English."
|
||||
else:
|
||||
prompt = f"""分析以下预测市场事件,评估其发生的概率:
|
||||
|
||||
问题:{question}
|
||||
当前市场概率:{current_market_prob}%
|
||||
@@ -236,13 +281,17 @@ class PolymarketAnalyzer:
|
||||
"reasoning": "详细分析...",
|
||||
"key_factors": ["因素1", "因素2"],
|
||||
"risk_factors": ["风险1", "风险2"]
|
||||
}}"""
|
||||
}}
|
||||
|
||||
重要提示:JSON响应中的所有文本(reasoning、key_factors、risk_factors)必须使用中文。"""
|
||||
|
||||
system_prompt = "你是一个专业的市场分析师,擅长分析预测市场事件。请基于提供的数据,客观评估事件发生的概率。请使用中文回答。"
|
||||
|
||||
# 调用LLM
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个专业的市场分析师,擅长分析预测市场事件。请基于提供的数据,客观评估事件发生的概率。"
|
||||
"content": system_prompt
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
@@ -500,11 +549,21 @@ class PolymarketAnalyzer:
|
||||
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):
|
||||
"""保存分析结果到数据库"""
|
||||
def _save_analysis_to_db(self, analysis: Dict, user_id: int = None, language: str = 'en-US', model: str = None):
|
||||
"""
|
||||
保存分析结果到数据库
|
||||
|
||||
Args:
|
||||
analysis: 分析结果字典
|
||||
user_id: 用户ID
|
||||
language: 语言设置
|
||||
model: 使用的模型
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 1. 保存到 qd_polymarket_ai_analysis 表(Polymarket专用表)
|
||||
cur.execute("""
|
||||
INSERT INTO qd_polymarket_ai_analysis
|
||||
(market_id, user_id, ai_predicted_probability, market_probability,
|
||||
@@ -524,8 +583,41 @@ class PolymarketAnalyzer:
|
||||
json.dumps(analysis.get('key_factors', [])),
|
||||
analysis.get('related_assets', [])
|
||||
))
|
||||
|
||||
# 2. 同时保存到 qd_analysis_tasks 表(用于管理员统计和统一的历史记录查看)
|
||||
market_info = analysis.get('market', {})
|
||||
market_title = market_info.get('question', '') or market_info.get('title', '') or f"Polymarket Market {analysis['market_id']}"
|
||||
result_json = json.dumps({
|
||||
'market_id': analysis['market_id'],
|
||||
'market_title': market_title,
|
||||
'analysis': analysis,
|
||||
'market': market_info,
|
||||
'type': 'polymarket' # 标记为Polymarket分析
|
||||
}, ensure_ascii=False)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO qd_analysis_tasks
|
||||
(user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
RETURNING id
|
||||
""", (
|
||||
int(user_id) if user_id else 1,
|
||||
'Polymarket', # market字段
|
||||
str(analysis['market_id']), # symbol字段存储market_id
|
||||
str(model) if model else '',
|
||||
str(language),
|
||||
'completed',
|
||||
result_json,
|
||||
''
|
||||
))
|
||||
task_row = cur.fetchone()
|
||||
task_id = task_row['id'] if task_row else None
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
if task_id:
|
||||
logger.debug(f"Saved Polymarket analysis to both tables: task_id={task_id}, market_id={analysis['market_id']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save analysis to DB: {e}")
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ logger = get_logger(__name__)
|
||||
class PolymarketWorker:
|
||||
"""Polymarket数据更新和分析后台任务"""
|
||||
|
||||
def __init__(self, update_interval_minutes: int = 30, analysis_cache_minutes: int = 30):
|
||||
def __init__(self, update_interval_minutes: int = 30, analysis_cache_minutes: int = 1440): # 24小时缓存
|
||||
"""
|
||||
初始化后台任务
|
||||
|
||||
@@ -112,12 +112,37 @@ class PolymarketWorker:
|
||||
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
|
||||
)
|
||||
# 优化策略:先用规则筛选,只对高价值机会调用LLM
|
||||
# 这样可以大幅减少LLM调用次数,节省token
|
||||
|
||||
# 1. 先用规则筛选出最有价值的机会(不调用LLM)
|
||||
rule_based_opportunities = []
|
||||
for market in markets_list:
|
||||
prob = market.get('current_probability', 50.0)
|
||||
volume = market.get('volume_24h', 0)
|
||||
divergence = abs(prob - 50.0)
|
||||
|
||||
# 规则筛选:高交易量 + 明显概率偏差
|
||||
if volume > 5000 and divergence > 8:
|
||||
rule_based_opportunities.append(market)
|
||||
|
||||
# 2. 只对规则筛选出的机会调用LLM(最多30个,节省token)
|
||||
if rule_based_opportunities:
|
||||
logger.info(f"Rule-based filtering: {len(rule_based_opportunities)} opportunities, analyzing top 30 with LLM")
|
||||
# 按交易量和概率偏差排序,取前30个
|
||||
rule_based_opportunities.sort(
|
||||
key=lambda x: (x.get('volume_24h', 0) * abs(x.get('current_probability', 50) - 50)),
|
||||
reverse=True
|
||||
)
|
||||
top_opportunities = rule_based_opportunities[:30]
|
||||
|
||||
analyzed_markets = self.batch_analyzer.batch_analyze_markets(
|
||||
top_opportunities,
|
||||
max_opportunities=30 # 只分析30个最有价值的机会
|
||||
)
|
||||
else:
|
||||
logger.info("No rule-based opportunities found, skipping LLM analysis")
|
||||
analyzed_markets = []
|
||||
|
||||
# 3. 保存分析结果到数据库
|
||||
if analyzed_markets:
|
||||
|
||||
@@ -53,48 +53,32 @@ class TradingExecutor:
|
||||
self._ensure_db_columns()
|
||||
|
||||
def _ensure_db_columns(self):
|
||||
"""确保必要的数据库字段存在(支持 SQLite 和 PostgreSQL)"""
|
||||
"""确保必要的数据库字段存在(PostgreSQL)"""
|
||||
try:
|
||||
db_type = os.getenv('DB_TYPE', 'sqlite').lower()
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
col_names = set()
|
||||
|
||||
if db_type == 'postgresql':
|
||||
# PostgreSQL: 使用 information_schema 查询列
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'qd_strategy_positions'
|
||||
""")
|
||||
cols = cursor.fetchall() or []
|
||||
col_names = {c.get('column_name') or c.get('COLUMN_NAME') for c in cols if isinstance(c, dict)}
|
||||
except Exception:
|
||||
col_names = set()
|
||||
else:
|
||||
# SQLite: 使用 PRAGMA table_info
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(qd_strategy_positions)")
|
||||
cols = cursor.fetchall() or []
|
||||
col_names = {c.get('name') for c in cols if isinstance(c, dict)}
|
||||
except Exception:
|
||||
col_names = set()
|
||||
# PostgreSQL: 使用 information_schema 查询列
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'qd_strategy_positions'
|
||||
""")
|
||||
cols = cursor.fetchall() or []
|
||||
col_names = {c.get('column_name') or c.get('COLUMN_NAME') for c in cols if isinstance(c, dict)}
|
||||
except Exception:
|
||||
col_names = set()
|
||||
|
||||
if 'highest_price' not in col_names:
|
||||
logger.info(f"Adding highest_price column to qd_strategy_positions ({db_type})...")
|
||||
if db_type == 'postgresql':
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS highest_price DOUBLE PRECISION DEFAULT 0")
|
||||
else:
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN highest_price REAL DEFAULT 0")
|
||||
logger.info("Adding highest_price column to qd_strategy_positions...")
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS highest_price DOUBLE PRECISION DEFAULT 0")
|
||||
db.commit()
|
||||
logger.info("highest_price column added")
|
||||
|
||||
if 'lowest_price' not in col_names:
|
||||
logger.info(f"Adding lowest_price column to qd_strategy_positions ({db_type})...")
|
||||
if db_type == 'postgresql':
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS lowest_price DOUBLE PRECISION DEFAULT 0")
|
||||
else:
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN lowest_price REAL DEFAULT 0")
|
||||
logger.info("Adding lowest_price column to qd_strategy_positions...")
|
||||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS lowest_price DOUBLE PRECISION DEFAULT 0")
|
||||
db.commit()
|
||||
logger.info("lowest_price column added")
|
||||
|
||||
@@ -642,10 +626,20 @@ class TradingExecutor:
|
||||
return
|
||||
|
||||
# ============================================
|
||||
# 启动时:完全依赖本地数据库的持仓状态(虚拟持仓)
|
||||
# 启动时:同步持仓状态,清理"幽灵持仓"
|
||||
# ============================================
|
||||
# 信号模式下,不再同步交易所持仓
|
||||
pass
|
||||
# 即使信号模式下,也要在启动时检查并清理用户在交易所手动平仓但数据库记录还在的情况
|
||||
# 这样可以避免策略认为还有持仓而无法执行新的开仓信号
|
||||
try:
|
||||
logger.info(f"策略 {strategy_id} 启动时检查持仓同步...")
|
||||
# 调用持仓同步逻辑(即使signal模式也要检查)
|
||||
from app import get_pending_order_worker
|
||||
worker = get_pending_order_worker()
|
||||
if worker and hasattr(worker, '_sync_positions_best_effort'):
|
||||
worker._sync_positions_best_effort(target_strategy_id=strategy_id)
|
||||
logger.info(f"策略 {strategy_id} 启动时持仓同步完成")
|
||||
except Exception as e:
|
||||
logger.warning(f"策略 {strategy_id} 启动时持仓同步失败(不影响启动): {e}")
|
||||
|
||||
# 获取当前持仓最高价(从本地数据库读取)
|
||||
current_pos_list = self._get_current_positions(strategy_id, symbol)
|
||||
@@ -1100,8 +1094,12 @@ class TradingExecutor:
|
||||
return None
|
||||
|
||||
def _is_strategy_running(self, strategy_id: int) -> bool:
|
||||
"""检查策略是否在运行"""
|
||||
"""
|
||||
检查策略是否在运行
|
||||
同时检查数据库状态和线程状态,避免重启后状态不一致
|
||||
"""
|
||||
try:
|
||||
# 1. 检查数据库状态
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
@@ -1110,8 +1108,34 @@ class TradingExecutor:
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
return result and result.get('status') == 'running'
|
||||
except:
|
||||
db_status = result and result.get('status') == 'running'
|
||||
|
||||
# 2. 检查线程是否真的在运行
|
||||
with self.lock:
|
||||
thread = self.running_strategies.get(strategy_id)
|
||||
thread_running = thread is not None and thread.is_alive()
|
||||
|
||||
# 3. 如果数据库状态是running但线程不在运行,说明状态不一致(可能是重启后恢复失败)
|
||||
if db_status and not thread_running:
|
||||
logger.warning(f"Strategy {strategy_id} status mismatch: DB=running but thread not running. Updating DB status to stopped.")
|
||||
# 更新数据库状态为stopped,避免策略"僵尸"状态
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s",
|
||||
(strategy_id,)
|
||||
)
|
||||
db.commit()
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update strategy {strategy_id} status to stopped: {e}")
|
||||
return False
|
||||
|
||||
# 4. 只有数据库状态和线程状态都一致时才返回True
|
||||
return db_status and thread_running
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking strategy {strategy_id} running status: {e}")
|
||||
return False
|
||||
|
||||
def _init_exchange(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
PostgreSQL Database Connection Utility
|
||||
|
||||
Supports multi-user mode with connection pooling and SQLite compatibility layer.
|
||||
Supports multi-user mode with connection pooling.
|
||||
Provides placeholder conversion for backward compatibility with legacy code.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
@@ -116,7 +117,7 @@ def _get_connection_pool():
|
||||
|
||||
|
||||
class PostgresCursor:
|
||||
"""PostgreSQL cursor wrapper with SQLite placeholder compatibility"""
|
||||
"""PostgreSQL cursor wrapper with placeholder conversion for backward compatibility"""
|
||||
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
@@ -124,13 +125,13 @@ class PostgresCursor:
|
||||
|
||||
def _convert_placeholders(self, query: str) -> str:
|
||||
"""
|
||||
Convert SQLite-style ? placeholders to PostgreSQL %s
|
||||
Also handle some SQL syntax differences
|
||||
Convert ? placeholders to PostgreSQL %s for backward compatibility.
|
||||
Also handle some SQL syntax differences.
|
||||
"""
|
||||
# Replace ? -> %s
|
||||
query = query.replace('?', '%s')
|
||||
|
||||
# SQLite: INSERT OR IGNORE -> PostgreSQL: INSERT ... ON CONFLICT DO NOTHING
|
||||
# INSERT OR IGNORE -> PostgreSQL: INSERT ... ON CONFLICT DO NOTHING
|
||||
query = query.replace('INSERT OR IGNORE', 'INSERT')
|
||||
|
||||
return query
|
||||
|
||||
@@ -309,6 +309,7 @@ BILLING_COST_AI_ANALYSIS=10
|
||||
BILLING_COST_STRATEGY_RUN=5
|
||||
BILLING_COST_BACKTEST=3
|
||||
BILLING_COST_PORTFOLIO_MONITOR=8
|
||||
BILLING_COST_POLYMARKET_DEEP_ANALYSIS=15
|
||||
|
||||
# Telegram customer service URL for recharge (充值跳转的Telegram链接)
|
||||
RECHARGE_TELEGRAM_URL=https://t.me/quantdinger
|
||||
|
||||
@@ -5,7 +5,7 @@ Goal:
|
||||
- Create one indicator strategy using `indicator_python_code/code_test.py`
|
||||
- Inject deterministic K-lines and a deterministic tick-price sequence
|
||||
- Run TradingExecutor for a short period
|
||||
- Verify orders are enqueued into SQLite table `pending_orders`
|
||||
- Verify orders are enqueued into PostgreSQL table `pending_orders`
|
||||
|
||||
Notes:
|
||||
- This is a local-only test helper. It does NOT talk to real exchanges.
|
||||
|
||||
Reference in New Issue
Block a user