Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-03-01 17:20:37 +08:00
parent d60409f7f8
commit db91fa4580
53 changed files with 1596 additions and 611 deletions
+32 -3
View File
@@ -9,7 +9,7 @@
<p><strong>Vibe Coding Meets Algo Trading</strong></p>
<p>
<strong>7 AI Agents · Python Strategies · 10+ Exchanges · Your Server, Your Keys</strong>
<strong>7 AI Agents · Python Strategies · 10+ Exchanges · Prediction Markets · Your Server, Your Keys</strong>
</p>
<p>
<i>Describe your trading idea in natural language → AI writes the Python strategy → Backtest → Live trade.<br/>
@@ -25,7 +25,7 @@
<p>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square&logo=apache" alt="License"></a>
<img src="https://img.shields.io/badge/Version-2.2.1-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Version-2.2.3-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Python-3.10+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Docker-One%20Click-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker">
<img src="https://img.shields.io/badge/Vibe%20Coding-Ready-FF6B6B?style=flat-square&logo=sparkles&logoColor=white" alt="Vibe Coding">
@@ -38,7 +38,7 @@
<a href="https://x.com/HenryCryption"><img src="https://img.shields.io/badge/X-Follow-000000?style=for-the-badge&logo=x" alt="X"></a>
</p>
<sub>🇺🇸 English · 🇨🇳 简体中文 · 🇹🇼 繁體中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇩🇪 Deutsch · 🇫🇷 Français · 🇹🇭 ไทย · 🇻🇳 Tiếng Việt · 🇸🇦 العربية</sub>
<sub>🇺🇸 <a href="README.md">English</a> · 🇨🇳 <a href="docs/README_CN.md">简体中文</a> · 🇹🇼 繁體中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇩🇪 Deutsch · 🇫🇷 Français · 🇹🇭 ไทย · 🇻🇳 Tiếng Việt · 🇸🇦 العربية</sub>
</div>
---
@@ -139,6 +139,7 @@ BACKEND_PORT=127.0.0.1:5001 # Default: 5000
| 🐍 **Python-Native** | Full ecosystem (Pandas, NumPy, TA-Lib, scikit-learn) — no proprietary language limits |
| 📊 **Professional Charts** | K-line charts with Python indicators, real-time visualization |
| 🌍 **Crypto + Stocks + Forex** | 10+ exchanges, IBKR, MT5 — all in one platform |
| 📊 **Prediction Markets** | On-demand AI analysis for Polymarket — probability divergence, opportunity scoring |
| 💰 **Monetization-Ready** | Membership, credits, USDT on-chain payment — built-in |
| ⚡ **2-Minute Deploy** | `docker-compose up -d` — production-ready, zero build |
@@ -209,6 +210,7 @@ Phase 3 (Decision): 🎯 TraderAgent → BUY / SELL / HOLD (with confidence %)
- **⚡ Quick Trade Panel** — See a signal? One-click to execute. No page switching.
- **🧠 Memory-Augmented** — Agents learn from past analyses (local RAG, not cloud)
- **🔌 5+ LLM Providers**: OpenRouter (100+ models), OpenAI, Gemini, DeepSeek, Grok
- **📊 Polymarket Prediction Markets** — On-demand AI analysis for prediction markets. Input a market link or title → AI analyzes probability divergence, opportunity score, and trading recommendations. Full history tracking and billing integration.
### 📈 Full Trading Lifecycle
@@ -221,6 +223,33 @@ Phase 3 (Decision): 🎯 TraderAgent → BUY / SELL / HOLD (with confidence %)
| **5. 🚀 Execute** | Live trade on 10+ crypto exchanges, IBKR (stocks), MT5 (forex) |
| **6. 📡 Monitor** | Portfolio tracker, alerts via Telegram/Discord/Email/SMS/Webhook |
### 📊 Polymarket Prediction Market Analysis
> **On-demand AI analysis for prediction markets.** Input a Polymarket link or market title → AI analyzes probability divergence, opportunity score, and provides trading recommendations.
**Features:**
- **🔍 Smart Search** — Supports market links, slugs, or natural language titles
- **🤖 AI Probability Prediction** — Compares AI-predicted probability vs market probability
- **📈 Opportunity Scoring** — Calculates opportunity score based on divergence and confidence
- **💡 Trading Recommendations** — YES/NO/HOLD with detailed reasoning and key factors
- **📚 History Tracking** — View all your past analyses with full details in a dedicated history tab
- **💰 Billing Integration** — Configurable credit consumption per analysis (set via `BILLING_COST_POLYMARKET_DEEP_ANALYSIS`)
- **🌍 Multi-Language** — AI responses match your frontend language (English/Chinese)
- **📊 Admin Statistics** — All analyses tracked in user management dashboard
**Usage:**
```
1. Navigate to AI Asset Analysis → Prediction Markets tab
2. Input Polymarket link or market title
3. AI analyzes and returns:
- Market probability vs AI-predicted probability
- Divergence analysis
- Opportunity score (0-100)
- Trading recommendation (YES/NO/HOLD)
- Detailed reasoning and key factors
4. View analysis history anytime
```
### 💰 Built-in Monetization
> Most open-source projects need months of custom billing work. QuantDinger ships with a **complete monetization system** out of the box:
+6
View File
@@ -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())
+1 -2
View File
@@ -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',
# 数据源
-20
View File
@@ -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):
"""缓存业务配置"""
+294 -45
View File
@@ -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]
+1 -1
View File
@@ -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
+254 -375
View File
@@ -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),
+76 -47
View File
@@ -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响应中的所有文本reasoningkey_factorsrisk_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(
+6 -5
View File
@@ -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
+1
View File
@@ -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.
+560
View File
@@ -0,0 +1,560 @@
<div align="center">
<a href="https://github.com/brokermr810/QuantDinger">
<img src="https://ai.quantdinger.com/img/logo.e0f510a8.png" alt="QuantDinger Logo" width="160" height="160">
</a>
<h1>QuantDinger</h1>
<h3>AI原生量化交易平台</h3>
<p><strong>Vibe Coding 遇见算法交易</strong></p>
<p>
<strong>7个AI智能体 · Python策略 · 10+交易所 · 预测市场 · 您的服务器,您的密钥</strong>
</p>
<p>
<i>用自然语言描述您的交易想法 → AI编写Python策略 → 回测 → 实盘交易。<br/>
无需编程。自托管 — 您的API密钥和策略永远不会离开您的机器。</i>
</p>
<p>
<a href="https://ai.quantdinger.com"><strong>🌐 在线演示</strong></a> &nbsp;·&nbsp;
<a href="https://youtu.be/HPTVpqL7knM"><strong>📺 视频</strong></a> &nbsp;·&nbsp;
<a href="https://www.quantdinger.com"><strong>💬 社区</strong></a> &nbsp;·&nbsp;
<a href="#-快速开始-2分钟"><strong>🚀 快速开始</strong></a>
</p>
<p>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square&logo=apache" alt="License"></a>
<img src="https://img.shields.io/badge/Version-2.2.3-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Python-3.10+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/Docker-一键部署-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker">
<img src="https://img.shields.io/badge/Vibe%20Coding-就绪-FF6B6B?style=flat-square&logo=sparkles&logoColor=white" alt="Vibe Coding">
<img src="https://img.shields.io/github/stars/brokermr810/QuantDinger?style=flat-square&logo=github" alt="Stars">
</p>
<p>
<a href="https://t.me/quantdinger"><img src="https://img.shields.io/badge/Telegram-群组-26A5E4?style=for-the-badge&logo=telegram" alt="Telegram"></a>
<a href="https://discord.gg/tyx5B6TChr"><img src="https://img.shields.io/badge/Discord-服务器-5865F2?style=for-the-badge&logo=discord" alt="Discord"></a>
<a href="https://x.com/HenryCryption"><img src="https://img.shields.io/badge/X-关注-000000?style=for-the-badge&logo=x" alt="X"></a>
</p>
<sub>🇺🇸 English · 🇨🇳 简体中文 · 🇹🇼 繁體中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇩🇪 Deutsch · 🇫🇷 Français · 🇹🇭 ไทย · 🇻🇳 Tiếng Việt · 🇸🇦 العربية</sub>
</div>
---
## 📑 目录
- [🚀 快速开始(2分钟)](#-快速开始-2分钟)
- [🎯 为什么选择QuantDinger](#-为什么选择quantdinger)
- [📸 视觉导览](#-视觉导览--观看视频演示)
- [✨ 核心功能](#-核心功能)
- [🔌 支持的交易所和经纪商](#-支持的交易所和经纪商)
- [🏗️ 架构与配置](#-架构与配置)
- [📚 文档索引](#-文档索引)
- [💼 许可与商业](#-许可与商业)
- [🤝 社区与支持](#-社区与支持)
---
## 🚀 快速开始(2分钟)
> **只需**:安装 [Docker](https://docs.docker.com/get-docker/)。无需其他。
```bash
# 1. 克隆
git clone https://github.com/brokermr810/QuantDinger.git
cd QuantDinger
# 2. 配置(编辑管理员密码和AI API密钥)
cp backend_api_python/env.example backend_api_python/.env
# 3. 启动!
docker-compose up -d --build
```
> **Windows PowerShell**: 使用 `Copy-Item backend_api_python\env.example -Destination backend_api_python\.env`
🎉 **完成!** 打开 **http://localhost:8888** | 登录:`quantdinger` / `123456`
<details>
<summary><b>📝 backend_api_python/.env 中的关键设置</b></summary>
```ini
# 必需 — 生产环境请修改!
ADMIN_USER=quantdinger
ADMIN_PASSWORD=your_secure_password
SECRET_KEY=your_random_secret_key
# 可选 — 启用AI功能(选择一个)
OPENROUTER_API_KEY=your_key # 推荐:100+模型
OPENAI_API_KEY=your_key # GPT-4o
DEEPSEEK_API_KEY=your_key # 性价比高
GOOGLE_GEMINI_API_KEY=your_key # Gemini
```
</details>
<details>
<summary><b>🔧 常用Docker命令</b></summary>
```bash
docker-compose ps # 查看服务状态
docker-compose logs -f backend # 查看后端日志(实时)
docker-compose restart backend # 仅重启后端
docker-compose up -d --build # 重建并重启所有服务
docker-compose down # 停止所有服务
```
**更新到最新版本:**
```bash
git pull && docker-compose up -d --build
```
**备份和恢复数据库:**
```bash
docker exec quantdinger-db pg_dump -U quantdinger quantdinger > backup.sql
cat backup.sql | docker exec -i quantdinger-db psql -U quantdinger quantdinger
```
**自定义端口** — 在项目根目录创建 `.env`
```ini
FRONTEND_PORT=3000 # 默认:8888
BACKEND_PORT=127.0.0.1:5001 # 默认:5000
```
</details>
---
## 🎯 为什么选择QuantDinger
> **Vibe Coding交易** — 用纯英文(或任何语言)描述您的交易想法。AI编写Python策略,回测它,并将其部署到实盘市场。无需手动编码。无SaaS锁定。一切都在您自己的服务器上运行。
| | |
|---|---|
| 🎵 **Vibe Coding** | 用自然语言描述想法 → AI生成生产就绪的Python策略 |
| 🔒 **100%自托管** | API密钥和策略永远不会离开您的服务器 — 隐私设计 |
| 🤖 **7个AI智能体** | 多智能体研究团队:并行分析 → 辩论 → 交易决策 |
| 🐍 **Python原生** | 完整生态系统(Pandas、NumPy、TA-Lib、scikit-learn)— 无专有语言限制 |
| 📊 **专业图表** | 带Python指标 K线图,实时可视化 |
| 🌍 **加密货币+股票+外汇** | 10+交易所、IBKR、MT5 — 一体化平台 |
| 📊 **预测市场** | Polymarket按需AI分析 — 概率差异、机会评分 |
| 💰 **变现就绪** | 会员、积分、USDT链上支付 — 内置 |
| ⚡ **2分钟部署** | `docker-compose up -d` — 生产就绪,零构建 |
---
## 📸 视觉导览 &nbsp;|&nbsp; [📺 观看视频演示](https://youtu.be/HPTVpqL7knM)
<table align="center" width="100%">
<tr>
<td colspan="2" align="center">
<a href="https://youtu.be/HPTVpqL7knM"><img src="docs/screenshots/video_demo.png" alt="Video Demo" width="80%" style="border-radius: 8px;"></a>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<img src="docs/screenshots/tuopu.png" alt="System Topology" width="90%" style="border-radius: 8px;">
<br/><sub>🗺️ 系统架构概览</sub>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<img src="docs/screenshots/dashboard.png" alt="Dashboard" width="90%" style="border-radius: 8px;">
<br/><sub>📊 专业量化仪表板</sub>
</td>
</tr>
<tr>
<td width="50%" align="center"><img src="docs/screenshots/ai_analysis1.png" alt="AI Analysis" style="border-radius: 6px;"><br/><sub>🤖 AI深度研究</sub></td>
<td width="50%" align="center"><img src="docs/screenshots/trading_assistant.png" alt="Trading Assistant" style="border-radius: 6px;"><br/><sub>💬 智能交易助手</sub></td>
</tr>
<tr>
<td align="center"><img src="docs/screenshots/indicator_analysis.png" alt="Indicator Analysis" style="border-radius: 6px;"><br/><sub>📈 指标分析</sub></td>
<td align="center"><img src="docs/screenshots/indicator_creat_python_code.png" alt="Code Generation" style="border-radius: 6px;"><br/><sub>🐍 AI策略编码</sub></td>
</tr>
<tr>
<td colspan="2" align="center"><img src="docs/screenshots/portfolio.jpg" alt="Portfolio Monitor" style="border-radius: 6px; max-width: 80%;"><br/><sub>📊 投资组合监控</sub></td>
</tr>
<tr>
<td colspan="2" align="center"><img src="docs/screenshots/polymarket_analysis.png" alt="Polymarket Analysis" style="border-radius: 6px; max-width: 80%;"><br/><sub>📊 Polymarket预测市场分析</sub></td>
</tr>
</table>
---
## ✨ 核心功能
### 🎵 Vibe Coding策略工作台
> **无需编程。** 用自然语言告诉AI您想要什么 — 它生成生产就绪的Python策略。或者使用完整的Python生态系统(Pandas、NumPy、TA-Lib、scikit-learn)编写您自己的策略。在专业K线图上可视化一切。
```
💬 "我想要一个MACD交叉策略,在BTC 15分钟图上使用RSI过滤器"
↓ AI生成Python代码
↓ 📈 在K线图上可视化
↓ 🔄 使用丰富指标回测
↓ 🤖 AI建议优化
↓ 🚀 一键部署到实盘交易
```
### 🤖 7智能体AI分析引擎
> 不仅仅是一次AI调用。QuantDinger部署了**7个专业智能体**,它们像研究团队一样协作 — 分析、辩论并达成共识:
```
阶段1(并行): 📊 技术 · 📑 基本面 · 📰 新闻 · 💭 情绪 · ⚠️ 风险
阶段2(辩论): 🐂 多头 vs 🐻 空头 — 结构化论证
阶段3(决策): 🎯 交易智能体 → 买入 / 卖出 / 持有(带置信度%)
```
- **🎵 自然语言分析** — 询问"分析BTC下周趋势" → 7个智能体提供完整报告
- **📡 AI交易雷达** — 每小时自动扫描加密货币/股票/外汇,发现机会
- **⚡ 快速交易面板** — 看到信号?一键执行。无需切换页面。
- **🧠 记忆增强** — 智能体从过去的分析中学习(本地RAG,非云端)
- **🔌 5+ LLM提供商**OpenRouter100+模型)、OpenAI、Gemini、DeepSeek、Grok
- **📊 Polymarket预测市场** — 预测市场按需AI分析。输入市场链接或标题 → AI分析概率差异、机会评分和交易建议。完整历史跟踪和计费集成。
### 📈 完整交易生命周期
| 步骤 | 发生什么 |
|------|-------------|
| **1. 💬 描述** | 用自然语言告诉AI您的交易想法 — 或直接编写Python |
| **2. 🤖 生成** | AI为您创建指标和策略代码 |
| **3. 📊 可视化** | 在专业K线图上即时查看信号 |
| **4. 🔄 回测** | 丰富指标 + **AI分析结果并建议改进** |
| **5. 🚀 执行** | 在10+加密货币交易所、IBKR(股票)、MT5(外汇)实盘交易 |
| **6. 📡 监控** | 投资组合跟踪器,通过Telegram/Discord/Email/SMS/Webhook提醒 |
### 📊 Polymarket预测市场分析
> **预测市场按需AI分析。** 输入Polymarket链接或市场标题 → AI分析概率差异、机会评分并提供交易建议。
**功能:**
- **🔍 智能搜索** — 支持市场链接、slug或自然语言标题
- **🤖 AI概率预测** — 比较AI预测概率与市场概率
- **📈 机会评分** — 基于差异和置信度计算机会评分
- **💡 交易建议** — YES/NO/HOLD,带详细推理和关键因素
- **📚 历史跟踪** — 在专用历史标签页中查看所有过去的分析
- **💰 计费集成** — 每次分析可配置积分消耗(通过`BILLING_COST_POLYMARKET_DEEP_ANALYSIS`设置)
- **🌍 多语言** — AI响应匹配您的前端语言(英文/中文)
- **📊 管理员统计** — 所有分析在用户管理仪表板中跟踪
**使用方法:**
```
1. 导航到AI资产分析 → 预测市场标签
2. 输入Polymarket链接或市场标题
3. AI分析并返回:
- 市场概率 vs AI预测概率
- 差异分析
- 机会评分(0-100
- 交易建议(YES/NO/HOLD
- 详细推理和关键因素
4. 随时查看分析历史
```
### 💰 内置变现
> 大多数开源项目需要数月的自定义计费工作。QuantDinger开箱即用,提供**完整的变现系统**:
- **💳 会员计划** — 月度/年度/终身层级,可配置定价和积分
- **₿ USDT链上支付** — TRC20扫码支付,每订单地址的HD钱包(xpub),通过TronGrid自动对账
- **🏪 指标市场** — 用户发布和销售Python指标,您收取佣金
- **⚙️ 管理员仪表板** — 订单管理、AI使用统计、用户分析
### 🔐 企业级安全
- **多用户** — 基于PostgreSQL的账户,带基于角色的权限
- **OAuth** — Google和GitHub一键登录
- **保护** — Cloudflare Turnstile、IP/账户速率限制、邮箱验证
- **演示模式** — 公共展示的只读模式
<details>
<summary><b>🧠 AI智能体架构图(点击展开)</b></summary>
```mermaid
flowchart TB
subgraph Entry["🌐 API入口"]
A["📡 POST /api/analysis/multi"]
A2["🔄 POST /api/analysis/reflect"]
end
subgraph Service["⚙️ 服务编排"]
B[AnalysisService]
C[AgentCoordinator]
D["📊 构建上下文<br/>价格 · K线 · 新闻 · 指标"]
end
subgraph Agents["🤖 7智能体工作流"]
subgraph P1["📈 阶段1 · 并行分析"]
E1["🔍 MarketAnalyst"]
E2["📑 FundamentalAnalyst"]
E3["📰 NewsAnalyst"]
E4["💭 SentimentAnalyst"]
E5["⚠️ RiskAnalyst"]
end
subgraph P2["🎯 阶段2 · 多头vs空头辩论"]
F1["🐂 BullResearcher"]
F2["🐻 BearResearcher"]
end
subgraph P3["💹 阶段3 · 最终决策"]
G["🎰 TraderAgent → 买入 / 卖出 / 持有"]
end
end
subgraph Memory["🧠 本地记忆存储"]
M1[("智能体记忆(PostgreSQL")]
end
subgraph Reflect["🔄 反思循环"]
R[ReflectionService]
W["⏰ ReflectionWorker → 验证 + 学习"]
end
A --> B --> C --> D
D --> P1 --> P2 --> P3
Agents <-.->|"RAG检索"| M1
C --> R
W -.->|"更新记忆"| M1
```
</details>
---
## 🔌 支持的交易所和经纪商
### 加密货币(直接API交易)
| 交易所 | 市场 |
|:--------:|:---------|
| Binance | 现货、期货、杠杆 |
| OKX | 现货、永续、期权 |
| Bitget | 现货、期货、跟单交易 |
| Bybit | 现货、线性期货 |
| Coinbase | 现货 |
| Kraken | 现货、期货 |
| KuCoin | 现货、期货 |
| Gate.io | 现货、期货 |
| Bitfinex | 现货、衍生品 |
### 传统经纪商和市场
| 市场 | 经纪商/数据源 | 交易 |
|--------|--------------|---------|
| **美股** | Interactive Brokers (IBKR)、Yahoo Finance、Finnhub | ✅ 通过IBKR |
| **外汇** | MetaTrader 5 (MT5)、OANDA | ✅ 通过MT5 |
| **期货** | 交易所API | ⚡ 数据 + 通知 |
---
## 🏗️ 架构与配置
### 技术栈
| 层级 | 技术 |
|-------|-----------|
| **AI引擎** | 7智能体多智能体系统 · RAG记忆 · 5+ LLM提供商 · Vibe Coding(自然语言→Python |
| **后端** | Python 3.10+ · Flask · PostgreSQL 16 · Redis(可选) |
| **前端** | Vue.js · Ant Design · KlineCharts · ECharts |
| **支付** | USDT TRC20链上 · HD钱包(BIP-32/44 · TronGrid API |
| **移动端** | Vue 3 + CapacitorAndroid / iOS |
| **部署** | Docker Compose · Nginx · 零构建一键部署 |
```text
┌─────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌───────────────────────────────┐ │
│ │ frontend (Nginx) → :8888 │ │
│ └──────────────┬────────────────┘ │
│ │ /api/* 代理 │
│ ┌──────────────▼────────────────┐ │
│ │ backend (Flask) → :5000 │ │
│ └──────────────┬────────────────┘ │
│ ┌──────────────▼────────────────┐ │
│ │ postgres (PG 16) → :5432 │ │
│ └───────────────────────────────┘ │
│ │
│ 外部:LLM API · 交易所 · │
│ TronGrid · 数据提供商 │
└─────────────────────────────────────┘
```
### 仓库结构
```text
QuantDinger/
├── backend_api_python/ # 🐍 后端(开源,Apache 2.0
│ ├── app/routes/ # API端点
│ ├── app/services/ # 业务逻辑(AI、交易、支付)
│ ├── migrations/init.sql # 数据库架构
│ ├── env.example # ⚙️ 配置模板 → 复制到.env
│ └── Dockerfile
├── frontend/ # 🎨 前端(预构建)
│ ├── dist/ # 静态文件(HTML/JS/CSS
│ ├── Dockerfile # Nginx镜像
│ └── nginx.conf # SPA路由 + API代理
├── docs/ # 📚 指南和教程
├── docker-compose.yml # 🐳 一键部署
└── LICENSE # Apache 2.0
```
<details>
<summary><b>⚙️ 配置参考(.env</b></summary>
使用 `backend_api_python/env.example` 作为模板:
| 类别 | 关键变量 |
|----------|-----------|
| **认证** | `SECRET_KEY``ADMIN_USER``ADMIN_PASSWORD` |
| **数据库** | `DATABASE_URL`PostgreSQL连接字符串) |
| **AI / LLM** | `LLM_PROVIDER``OPENROUTER_API_KEY``OPENAI_API_KEY` |
| **OAuth** | `GOOGLE_CLIENT_ID``GITHUB_CLIENT_ID` |
| **安全** | `TURNSTILE_SITE_KEY``ENABLE_REGISTRATION` |
| **会员** | `MEMBERSHIP_MONTHLY_PRICE_USD``MEMBERSHIP_MONTHLY_CREDITS` |
| **USDT支付** | `USDT_PAY_ENABLED``USDT_TRC20_XPUB``TRONGRID_API_KEY` |
| **代理** | `PROXY_PORT``PROXY_URL` |
| **工作器** | `ENABLE_PENDING_ORDER_WORKER``ENABLE_PORTFOLIO_MONITOR` |
</details>
<details>
<summary><b>🔌 API端点</b></summary>
| 端点 | 描述 |
|----------|-------------|
| `GET /api/health` | 健康检查 |
| `POST /api/user/login` | 用户认证 |
| `GET /api/user/info` | 当前用户信息 |
| `GET /api/billing/plans` | 会员计划 |
| `POST /api/billing/usdt/create-order` | 创建USDT支付订单 |
完整路由列表,请参见 `backend_api_python/app/routes/`
</details>
---
## 📚 文档索引
所有详细指南都在 [`docs/`](docs/) 文件夹中:
### 入门指南
| 文档 | 描述 |
|----------|-------------|
| [更新日志](docs/CHANGELOG.md) | 版本历史和迁移说明 |
| [多用户设置](docs/multi-user-setup.md) | PostgreSQL多用户部署 |
### 策略开发
| 指南 | 🇺🇸 EN | 🇨🇳 CN | 🇹🇼 TW | 🇯🇵 JA | 🇰🇷 KO |
|-------|--------|--------|--------|--------|--------|
| **策略开发** | [EN](docs/STRATEGY_DEV_GUIDE.md) | [CN](docs/STRATEGY_DEV_GUIDE_CN.md) | [TW](docs/STRATEGY_DEV_GUIDE_TW.md) | [JA](docs/STRATEGY_DEV_GUIDE_JA.md) | [KO](docs/STRATEGY_DEV_GUIDE_KO.md) |
| **跨品种** | [EN](docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md) | [CN](docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md) | | | |
| **代码示例** | [examples/](docs/examples/) | | | | |
### 经纪商和集成
| 指南 | English | 中文 |
|-------|---------|------|
| **IBKR(美股)** | [指南](docs/IBKR_TRADING_GUIDE_EN.md) | — |
| **MT5(外汇)** | [指南](docs/MT5_TRADING_GUIDE_EN.md) | [指南](docs/MT5_TRADING_GUIDE_CN.md) |
| **OAuthGoogle/GitHub** | [指南](docs/OAUTH_CONFIG_EN.md) | [指南](docs/OAUTH_CONFIG_CN.md) |
### 通知
| 渠道 | English | 中文 |
|---------|---------|------|
| **Telegram** | [设置](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md) | [配置](docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md) |
| **EmailSMTP** | [设置](docs/NOTIFICATION_EMAIL_CONFIG_EN.md) | [配置](docs/NOTIFICATION_EMAIL_CONFIG_CH.md) |
| **SMSTwilio** | [设置](docs/NOTIFICATION_SMS_CONFIG_EN.md) | [配置](docs/NOTIFICATION_SMS_CONFIG_CH.md) |
---
## 💼 许可与商业
### 开源许可
后端源代码根据 **Apache License 2.0** 许可。参见 `LICENSE`
前端UI作为**预构建文件**提供。商标权(名称/徽标/品牌)单独管理 — 参见 `TRADEMARKS.md`
### 🎓 非营利和教育免费源代码
如果您是**大学**、**研究机构**、**非营利组织**、**社区团体**或**教育项目**,可以申请**免费授权和完整前端源代码**:
- 🏫 大学和学术研究
- 🌍 开源社区和开发者团体
- 🤝 非营利和公益组织
- 📚 教育项目和学生黑客马拉松
### 💼 商业许可
对于**商业用途**,购买许可可获得:
- **完整前端源代码** + 未来更新
- **品牌授权** — 按约定修改名称/徽标/版权
- **运营支持** — 部署、升级、事件响应
- **咨询** — 架构审查、性能调优
### 📬 联系方式
| 渠道 | 链接 |
|---------|---------|
| **Telegram** | [t.me/worldinbroker](https://t.me/worldinbroker) |
| **Email** | [brokermr810@gmail.com](mailto:brokermr810@gmail.com) |
---
## 🤝 社区与支持
<p>
<a href="https://t.me/quantdinger"><img src="https://img.shields.io/badge/Telegram-群组-26A5E4?style=for-the-badge&logo=telegram" alt="Telegram"></a>
<a href="https://discord.gg/tyx5B6TChr"><img src="https://img.shields.io/badge/Discord-服务器-5865F2?style=for-the-badge&logo=discord" alt="Discord"></a>
<a href="https://youtube.com/@quantdinger"><img src="https://img.shields.io/badge/YouTube-频道-FF0000?style=for-the-badge&logo=youtube" alt="YouTube"></a>
</p>
- [贡献指南](CONTRIBUTING.md) · [贡献者](CONTRIBUTORS.md)
- [报告Bug / 请求功能](https://github.com/brokermr810/QuantDinger/issues)
- 邮箱:[brokermr810@gmail.com](mailto:brokermr810@gmail.com)
---
### 💝 支持项目
**加密货币捐赠(ERC-20 / BEP-20 / Polygon / Arbitrum**
```
0x96fa4962181bea077f8c7240efe46afbe73641a7
```
<p>
<img src="https://img.shields.io/badge/USDT-接受-26A17B?style=for-the-badge&logo=tether&logoColor=white" alt="USDT">
<img src="https://img.shields.io/badge/ETH-接受-3C3C3D?style=for-the-badge&logo=ethereum&logoColor=white" alt="ETH">
</p>
---
### 🎓 支持合作伙伴
<div align="center">
<table>
<tr>
<td align="center" width="50%">
<a href="https://beinvolved.indiana.edu/organization/quantfiniu" target="_blank">
<img src="docs/screenshots/qfs_logo.png" alt="Indiana University QFS" width="280" style="border-radius: 8px;">
</a>
<br/><br/>
<strong>Quantitative Finance Society (QFS)</strong><br/>
<small>Indiana University Bloomington</small>
</td>
</tr>
</table>
</div>
> 💡 **想成为合作伙伴?** 联系 [brokermr810@gmail.com](mailto:brokermr810@gmail.com) 或 [Telegram](https://t.me/worldinbroker)。
---
### 致谢
在以下项目的肩膀上用 ❤️ 构建:[Flask](https://flask.palletsprojects.com/) · [Pandas](https://pandas.pydata.org/) · [CCXT](https://github.com/ccxt/ccxt) · [yfinance](https://github.com/ranaroussi/yfinance) · [Vue.js](https://vuejs.org/) · [Ant Design Vue](https://antdv.com/) · [KlineCharts](https://github.com/klinecharts/KLineChart) · [ECharts](https://echarts.apache.org/) · [Capacitor](https://capacitorjs.com/) · [bip-utils](https://github.com/ebellocchia/bip_utils)
<p align="center"><sub>如果QuantDinger对您有帮助,请考虑 ⭐ 给仓库加星 — 这对我们意义重大!</sub></p>
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
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
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 -1
View File
@@ -421,4 +421,4 @@
.brand-text {
font-size: 20px;
}
}</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>
}</style><script defer="defer" src="/js/chunk-vendors.ba8f72a0.js" type="module"></script><script defer="defer" src="/js/app.7e78ef2e.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.2873ce19.js" nomodule></script><script defer="defer" src="/js/app-legacy.17b20dc1.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>
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
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
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
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