V2.2.1: Membership & Billing, USDT TRC20 payment, VIP free indicators, AI Trading Radar, simplified strategy creation, system settings simplification, bug fixes and UI improvements
This commit is contained in:
@@ -28,7 +28,7 @@ def get_hot_symbols(market: str, limit: int = 10) -> List[Dict]:
|
||||
Get hot symbols for a market.
|
||||
|
||||
Args:
|
||||
market: Market name (e.g., 'Crypto', 'USStock', 'AShare')
|
||||
market: Market name (e.g., 'Crypto', 'USStock', 'Forex')
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
@@ -100,18 +100,12 @@ def search_symbols(market: str, keyword: str, limit: int = 20) -> List[Dict]:
|
||||
|
||||
|
||||
def _normalize_for_match(market: str, symbol: str) -> str:
|
||||
"""Normalize symbol for matching (padding digits for A-Share/H-Share)."""
|
||||
"""Normalize symbol for matching."""
|
||||
m = (market or '').strip()
|
||||
s = (symbol or '').strip().upper()
|
||||
if not m or not s:
|
||||
return s
|
||||
|
||||
# A-Share codes are usually 6 digits
|
||||
if m == 'AShare' and s.isdigit() and len(s) < 6:
|
||||
s = s.zfill(6)
|
||||
# H-Share codes are often 5 digits
|
||||
if m == 'HShare' and s.isdigit() and len(s) < 5:
|
||||
s = s.zfill(5)
|
||||
return s
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
- 熔断器保护 (circuit_breaker)
|
||||
- 数据缓存 (cache_manager)
|
||||
- 防封禁策略 (rate_limiter)
|
||||
- 多数据源自动切换 (data_manager)
|
||||
"""
|
||||
from app.data_sources.factory import DataSourceFactory
|
||||
from app.data_sources.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
get_ashare_circuit_breaker,
|
||||
get_realtime_circuit_breaker
|
||||
)
|
||||
from app.data_sources.cache_manager import (
|
||||
@@ -22,24 +20,16 @@ from app.data_sources.cache_manager import (
|
||||
)
|
||||
from app.data_sources.rate_limiter import (
|
||||
RateLimiter,
|
||||
get_eastmoney_limiter,
|
||||
get_tencent_limiter,
|
||||
get_akshare_limiter,
|
||||
get_random_user_agent,
|
||||
random_sleep,
|
||||
retry_with_backoff
|
||||
)
|
||||
from app.data_sources.data_manager import (
|
||||
AShareDataManager,
|
||||
get_ashare_data_manager
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 工厂
|
||||
'DataSourceFactory',
|
||||
# 熔断器
|
||||
'CircuitBreaker',
|
||||
'get_ashare_circuit_breaker',
|
||||
'get_realtime_circuit_breaker',
|
||||
# 缓存
|
||||
'DataCache',
|
||||
@@ -48,14 +38,7 @@ __all__ = [
|
||||
'get_stock_info_cache',
|
||||
# 限流器
|
||||
'RateLimiter',
|
||||
'get_eastmoney_limiter',
|
||||
'get_tencent_limiter',
|
||||
'get_akshare_limiter',
|
||||
'get_random_user_agent',
|
||||
'random_sleep',
|
||||
'retry_with_backoff',
|
||||
# 数据管理器
|
||||
'AShareDataManager',
|
||||
'get_ashare_data_manager',
|
||||
]
|
||||
|
||||
|
||||
@@ -178,11 +178,11 @@ class DataCache:
|
||||
# 全局缓存实例
|
||||
# ============================================
|
||||
|
||||
# A股实时行情缓存(20分钟TTL,全市场数据量大)
|
||||
_ashare_realtime_cache = DataCache(
|
||||
name="ashare_realtime",
|
||||
# 实时行情缓存(20分钟TTL)
|
||||
_realtime_cache = DataCache(
|
||||
name="realtime",
|
||||
default_ttl=1200.0, # 20分钟
|
||||
max_size=6000 # 约5000+股票
|
||||
max_size=6000
|
||||
)
|
||||
|
||||
# K线数据缓存(5分钟TTL,按需缓存)
|
||||
@@ -202,7 +202,7 @@ _stock_info_cache = DataCache(
|
||||
|
||||
def get_realtime_cache() -> DataCache:
|
||||
"""获取实时行情缓存"""
|
||||
return _ashare_realtime_cache
|
||||
return _realtime_cache
|
||||
|
||||
|
||||
def get_kline_cache() -> DataCache:
|
||||
|
||||
@@ -161,13 +161,6 @@ class CircuitBreaker:
|
||||
# 全局熔断器实例
|
||||
# ============================================
|
||||
|
||||
# A股数据源熔断器(标准策略)
|
||||
_ashare_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=3, # 连续失败3次熔断
|
||||
cooldown_seconds=300.0, # 冷却5分钟
|
||||
half_open_max_calls=1
|
||||
)
|
||||
|
||||
# 实时行情熔断器(更严格的策略)
|
||||
_realtime_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=2, # 连续失败2次熔断
|
||||
@@ -176,11 +169,6 @@ _realtime_circuit_breaker = CircuitBreaker(
|
||||
)
|
||||
|
||||
|
||||
def get_ashare_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取A股数据源熔断器"""
|
||||
return _ashare_circuit_breaker
|
||||
|
||||
|
||||
def get_realtime_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取实时行情熔断器"""
|
||||
return _realtime_circuit_breaker
|
||||
|
||||
@@ -1,877 +0,0 @@
|
||||
"""
|
||||
CN/HK stock data source.
|
||||
Supports A-Share and H-Share with multiple public sources.
|
||||
|
||||
改进版本(参考 daily_stock_analysis 项目):
|
||||
- 多数据源自动切换(按优先级)
|
||||
- 熔断器保护
|
||||
- 数据缓存
|
||||
- 防封禁策略(随机休眠+UA轮换)
|
||||
|
||||
Priority (AShare): Eastmoney > Tencent > Sina > Akshare > yfinance
|
||||
Priority (HShare): Tencent > Eastmoney > yfinance > akshare
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import requests
|
||||
|
||||
import yfinance as yf
|
||||
|
||||
from app.data_sources.base import BaseDataSource
|
||||
from app.data_sources.us_stock import USStockDataSource
|
||||
from app.data_sources.data_manager import get_ashare_data_manager, AShareDataManager
|
||||
from app.data_sources.circuit_breaker import get_ashare_circuit_breaker, get_realtime_circuit_breaker
|
||||
from app.data_sources.rate_limiter import get_request_headers, get_tencent_limiter, get_eastmoney_limiter
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.http import get_retry_session
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Optional dependency: akshare
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
HAS_AKSHARE = True
|
||||
logger.debug("akshare is available")
|
||||
except ImportError:
|
||||
HAS_AKSHARE = False
|
||||
# Keep it quiet to avoid noisy startup logs on Windows.
|
||||
logger.debug("akshare is not installed; akshare-based features are disabled")
|
||||
|
||||
|
||||
class TencentDataMixin:
|
||||
"""Tencent quote API mixin (mostly for H-Share and legacy fallback)."""
|
||||
|
||||
# 腾讯 K 线周期映射(注意:腾讯分钟级接口不支持240分钟,4H需要特殊处理)
|
||||
TENCENT_PERIOD_MAP = {
|
||||
'1m': 1,
|
||||
'5m': 5,
|
||||
'15m': 15,
|
||||
'30m': 30,
|
||||
'1H': 60,
|
||||
'1D': 'day',
|
||||
'1W': 'week'
|
||||
}
|
||||
|
||||
def _fetch_tencent_kline(
|
||||
self,
|
||||
symbol_code: str,
|
||||
timeframe: str,
|
||||
limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
使用腾讯财经接口获取K线数据
|
||||
|
||||
Args:
|
||||
symbol_code: 腾讯格式的代码 (sh600000, sz000001, hk00700)
|
||||
timeframe: 时间周期
|
||||
limit: 数据条数
|
||||
"""
|
||||
klines = []
|
||||
|
||||
# 4H 需要特殊处理:获取1H数据然后聚合
|
||||
if timeframe == '4H':
|
||||
return self._fetch_and_aggregate_4h(symbol_code, limit)
|
||||
|
||||
try:
|
||||
period = self.TENCENT_PERIOD_MAP.get(timeframe)
|
||||
if period is None:
|
||||
logger.warning(f"Unsupported timeframe: {timeframe}")
|
||||
return []
|
||||
|
||||
# 构建请求URL
|
||||
if isinstance(period, int):
|
||||
# 分钟级数据
|
||||
url = f"http://ifzq.gtimg.cn/appstock/app/kline/mkline?param={symbol_code},m{period},,{limit}"
|
||||
else:
|
||||
# 日线/周线数据
|
||||
url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={symbol_code},{period},,,{limit},qfq"
|
||||
|
||||
# logger.info(f"腾讯财经请求: {symbol_code}, 周期: {timeframe}, URL: {url[:80]}...")
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Tencent quote returned status: {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 解析响应数据
|
||||
if data.get('code') == 0 and 'data' in data:
|
||||
stock_data = data['data'].get(symbol_code)
|
||||
if stock_data:
|
||||
# 分钟级数据格式
|
||||
if isinstance(period, int):
|
||||
candles = stock_data.get(f'm{period}', [])
|
||||
else:
|
||||
# 日线/周线数据格式
|
||||
candles = stock_data.get('qfqday', stock_data.get('day', []))
|
||||
|
||||
for candle in candles:
|
||||
if len(candle) >= 5:
|
||||
# 解析时间
|
||||
time_str = str(candle[0])
|
||||
try:
|
||||
if len(time_str) == 12: # 分钟级: 202411301430
|
||||
dt = datetime.strptime(time_str, '%Y%m%d%H%M')
|
||||
elif len(time_str) == 10: # 日线: 2024-11-30
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
else:
|
||||
continue
|
||||
|
||||
klines.append(self.format_kline(
|
||||
timestamp=int(dt.timestamp()),
|
||||
open_price=float(candle[1]),
|
||||
high=float(candle[3]),
|
||||
low=float(candle[4]),
|
||||
close=float(candle[2]),
|
||||
volume=float(candle[5]) if len(candle) > 5 else 0
|
||||
))
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"Failed to parse kline candle: {candle}, error: {e}")
|
||||
continue
|
||||
|
||||
# logger.info(f"腾讯财经返回 {len(klines)} 条数据")
|
||||
else:
|
||||
logger.warning(f"Tencent quote returned unexpected data: code={data.get('code')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tencent quote fetch failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_and_aggregate_4h(self, symbol_code: str, limit: int) -> List[Dict[str, Any]]:
|
||||
"""获取1H数据并聚合为4H"""
|
||||
# 获取足够多的1H数据
|
||||
hour_klines = self._fetch_tencent_kline(symbol_code, '1H', limit * 4 + 10)
|
||||
|
||||
if not hour_klines:
|
||||
return []
|
||||
|
||||
# 按4小时聚合
|
||||
aggregated = []
|
||||
i = 0
|
||||
while i < len(hour_klines):
|
||||
# 取4根K线
|
||||
batch = hour_klines[i:i+4]
|
||||
if len(batch) < 4:
|
||||
break
|
||||
|
||||
aggregated.append(self.format_kline(
|
||||
timestamp=batch[0]['time'],
|
||||
open_price=batch[0]['open'],
|
||||
high=max(k['high'] for k in batch),
|
||||
low=min(k['low'] for k in batch),
|
||||
close=batch[-1]['close'],
|
||||
volume=sum(k['volume'] for k in batch)
|
||||
))
|
||||
i += 4
|
||||
|
||||
# logger.info(f"聚合生成 {len(aggregated)} 条 4H 数据")
|
||||
return aggregated[-limit:] if len(aggregated) > limit else aggregated
|
||||
|
||||
|
||||
class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
"""
|
||||
A-Share data source.
|
||||
|
||||
改进版本:使用 AShareDataManager 实现多数据源自动切换
|
||||
- 熔断器保护
|
||||
- 数据缓存
|
||||
- 防封禁策略
|
||||
"""
|
||||
|
||||
name = "AShare"
|
||||
|
||||
# akshare 时间周期映射
|
||||
AKSHARE_PERIOD_MAP = {
|
||||
'1D': 'daily',
|
||||
'1W': 'weekly'
|
||||
}
|
||||
|
||||
# 东方财富 K 线周期映射
|
||||
EM_PERIOD_MAP = {
|
||||
'1m': '1',
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'1H': '60',
|
||||
'4H': '240',
|
||||
'1D': '101',
|
||||
'1W': '102',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.us_stock_source = USStockDataSource()
|
||||
# 使用新的数据管理器
|
||||
self._data_manager = get_ashare_data_manager()
|
||||
# 熔断器和限流器
|
||||
self._circuit_breaker = get_ashare_circuit_breaker()
|
||||
self._em_limiter = get_eastmoney_limiter()
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch A-Share Kline data.
|
||||
|
||||
改进版本:使用数据管理器自动切换数据源
|
||||
"""
|
||||
# 使用新的数据管理器获取数据(自动切换数据源)
|
||||
klines, source = self._data_manager.get_kline(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
limit=limit,
|
||||
before_time=before_time
|
||||
)
|
||||
|
||||
if klines:
|
||||
self.log_result(symbol, klines, timeframe)
|
||||
return klines
|
||||
|
||||
# 如果数据管理器失败,使用传统方式作为最后备选
|
||||
logger.warning(f"[AShare] 数据管理器获取 {symbol} 失败,尝试传统方式")
|
||||
|
||||
# 传统方式:直接调用东方财富
|
||||
klines = self._fetch_eastmoney_ashare_legacy(symbol, timeframe, limit)
|
||||
if klines:
|
||||
klines = self.filter_and_limit(klines, limit, before_time)
|
||||
self.log_result(symbol, klines, timeframe)
|
||||
return klines
|
||||
|
||||
# Fallback: yfinance (daily/weekly)
|
||||
if timeframe in ('1D', '1W'):
|
||||
yahoo_symbol = self._to_yahoo_symbol(symbol)
|
||||
if yahoo_symbol:
|
||||
klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time)
|
||||
if klines:
|
||||
return klines
|
||||
|
||||
logger.warning(f"AShare {symbol} data fetch failed")
|
||||
return []
|
||||
|
||||
def _fetch_eastmoney_ashare_legacy(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
传统方式获取东方财富数据(兜底用)
|
||||
不使用新的熔断器和限流器,保持原有逻辑
|
||||
"""
|
||||
return self._fetch_eastmoney_ashare(symbol, timeframe, limit)
|
||||
|
||||
def _to_tencent_symbol(self, symbol: str) -> Optional[str]:
|
||||
"""转换为腾讯财经格式"""
|
||||
if symbol.startswith('6'):
|
||||
return f"sh{symbol}"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
return f"sz{symbol}"
|
||||
elif symbol.startswith('4') or symbol.startswith('8'):
|
||||
return f"bj{symbol}" # 北交所
|
||||
return None
|
||||
|
||||
def _to_yahoo_symbol(self, symbol: str) -> Optional[str]:
|
||||
"""转换为 Yahoo Finance 格式"""
|
||||
if symbol.startswith('6'):
|
||||
return f"{symbol}.SS"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
return f"{symbol}.SZ"
|
||||
elif symbol.startswith('4') or symbol.startswith('8'):
|
||||
return f"{symbol}.BJ"
|
||||
return None
|
||||
|
||||
def _fetch_eastmoney_ashare(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用东方财富获取A股数据"""
|
||||
klines = []
|
||||
|
||||
period = self.EM_PERIOD_MAP.get(timeframe)
|
||||
if not period:
|
||||
logger.warning(f"Eastmoney unsupported timeframe: {timeframe}")
|
||||
return []
|
||||
|
||||
try:
|
||||
# 确定市场代码: 上海=1, 深圳=0, 北交所=0
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}"
|
||||
else:
|
||||
secid = f"0.{symbol}"
|
||||
|
||||
# 东方财富K线接口
|
||||
url = f"https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57',
|
||||
'klt': period,
|
||||
'fqt': '1', # 前复权
|
||||
'end': '20500101',
|
||||
'lmt': limit,
|
||||
}
|
||||
|
||||
# logger.info(f"东方财富A股请求: {symbol}, 周期: {timeframe}")
|
||||
|
||||
# 添加浏览器请求头
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://quote.eastmoney.com/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
}
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=headers, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Eastmoney HTTP status: {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 解析响应
|
||||
if data.get('data') and data['data'].get('klines'):
|
||||
for line in data['data']['klines']:
|
||||
try:
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 6:
|
||||
time_str = parts[0]
|
||||
if ' ' in time_str:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M')
|
||||
else:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
|
||||
klines.append(self.format_kline(
|
||||
timestamp=int(dt.timestamp()),
|
||||
open_price=float(parts[1]),
|
||||
high=float(parts[3]),
|
||||
low=float(parts[4]),
|
||||
close=float(parts[2]),
|
||||
volume=float(parts[5])
|
||||
))
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"Failed to parse Eastmoney data line: {line}, error: {e}")
|
||||
continue
|
||||
|
||||
# logger.info(f"东方财富返回 {len(klines)} 条A股数据")
|
||||
else:
|
||||
logger.warning("Eastmoney returned no data")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Eastmoney A-share fetch failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_akshare(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 akshare 获取数据"""
|
||||
klines = []
|
||||
|
||||
try:
|
||||
period = self.AKSHARE_PERIOD_MAP.get(timeframe, 'daily')
|
||||
|
||||
# 计算日期范围
|
||||
if before_time:
|
||||
end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d')
|
||||
else:
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
days = limit * 2 if timeframe == '1D' else limit * 10
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
|
||||
# logger.info(f"使用 akshare 获取A股: {symbol}, 周期: {period}")
|
||||
|
||||
df = ak.stock_zh_a_hist(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
adjust="qfq" # 前复权
|
||||
)
|
||||
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(limit)
|
||||
for _, row in df.iterrows():
|
||||
ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp())
|
||||
klines.append(self.format_kline(
|
||||
timestamp=ts,
|
||||
open_price=row['开盘'],
|
||||
high=row['最高'],
|
||||
low=row['最低'],
|
||||
close=row['收盘'],
|
||||
volume=row['成交量']
|
||||
))
|
||||
# logger.info(f"akshare 返回 {len(klines)} 条A股数据")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Akshare A-share fetch failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return klines
|
||||
|
||||
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取A股实时报价
|
||||
|
||||
改进版本:使用数据管理器自动切换数据源
|
||||
- 熔断器保护
|
||||
- 数据缓存(60秒TTL)
|
||||
- 多数据源自动切换
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'last': 当前价格,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
'high': 最高价,
|
||||
'low': 最低价,
|
||||
'open': 开盘价,
|
||||
'previousClose': 昨收价
|
||||
}
|
||||
"""
|
||||
symbol = (symbol or '').strip()
|
||||
|
||||
# 使用数据管理器获取实时报价(自动切换数据源)
|
||||
quote, source = self._data_manager.get_realtime_quote(symbol)
|
||||
if quote and quote.get('last', 0) > 0:
|
||||
return quote
|
||||
|
||||
# 如果数据管理器失败,使用传统方式作为兜底
|
||||
logger.debug(f"[AShare] 数据管理器获取 {symbol} 实时报价失败,尝试传统方式")
|
||||
return self._get_ticker_legacy(symbol)
|
||||
|
||||
def _get_ticker_legacy(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
传统方式获取实时报价(兜底用)
|
||||
|
||||
保持原有逻辑,不使用熔断器和限流器
|
||||
"""
|
||||
# 优先使用东方财富实时行情 API
|
||||
try:
|
||||
# 判断市场
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}" # 上海
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
secid = f"0.{symbol}" # 深圳
|
||||
elif symbol.startswith('4') or symbol.startswith('8'):
|
||||
secid = f"0.{symbol}" # 北交所
|
||||
else:
|
||||
secid = f"1.{symbol}"
|
||||
|
||||
# 东方财富实时行情接口
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
|
||||
}
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and data.get('data'):
|
||||
d = data['data']
|
||||
last_price = d.get('f43', 0)
|
||||
if last_price and last_price > 0:
|
||||
divisor = 100 if last_price > 1000 else 1
|
||||
return {
|
||||
'last': last_price / divisor,
|
||||
'high': d.get('f44', 0) / divisor,
|
||||
'low': d.get('f45', 0) / divisor,
|
||||
'open': d.get('f46', 0) / divisor,
|
||||
'previousClose': d.get('f60', 0) / divisor,
|
||||
'change': d.get('f169', 0) / divisor,
|
||||
'changePercent': d.get('f170', 0) / 100
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
|
||||
|
||||
# 降级使用腾讯实时报价
|
||||
try:
|
||||
tencent_symbol = self._to_tencent_symbol(symbol)
|
||||
if tencent_symbol:
|
||||
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
|
||||
response = requests.get(url, timeout=10)
|
||||
content = response.content.decode('gbk', errors='ignore')
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
'last': float(parts[3]) if parts[3] else 0,
|
||||
'change': float(parts[31]) if parts[31] else 0,
|
||||
'changePercent': float(parts[32]) if parts[32] else 0,
|
||||
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
'previousClose': float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
|
||||
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
|
||||
|
||||
class HShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
"""港股数据源"""
|
||||
|
||||
name = "HShare"
|
||||
|
||||
def __init__(self):
|
||||
self.us_stock_source = USStockDataSource()
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取港股K线数据"""
|
||||
klines = []
|
||||
|
||||
# 方案1: 腾讯财经 (港股日线/周线首选,稳定可靠)
|
||||
if timeframe in ('1D', '1W'):
|
||||
tencent_symbol = self._to_tencent_symbol(symbol)
|
||||
if tencent_symbol:
|
||||
# logger.info(f"尝试使用腾讯财经获取港股: {tencent_symbol}")
|
||||
klines = self._fetch_tencent_kline(tencent_symbol, timeframe, limit)
|
||||
if klines:
|
||||
klines = self.filter_and_limit(klines, limit, before_time)
|
||||
self.log_result(symbol, klines, timeframe)
|
||||
return klines
|
||||
|
||||
# 方案2: 东方财富 (支持所有周期,但可能有地域限制)
|
||||
klines = self._fetch_eastmoney_kline(symbol, timeframe, limit)
|
||||
if klines:
|
||||
klines = self.filter_and_limit(klines, limit, before_time)
|
||||
self.log_result(symbol, klines, timeframe)
|
||||
return klines
|
||||
|
||||
# 方案3: 尝试 yfinance (日线级别备选)
|
||||
if timeframe in ('1D', '1W'):
|
||||
yahoo_symbol = self._to_yahoo_symbol(symbol)
|
||||
if yahoo_symbol:
|
||||
# logger.info(f"尝试使用 yfinance 获取港股: {yahoo_symbol}")
|
||||
klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time)
|
||||
if klines:
|
||||
# logger.info(f"yfinance 成功获取 {len(klines)} 条港股数据")
|
||||
return klines
|
||||
|
||||
# 方案4: 尝试 akshare (日线级别)
|
||||
if HAS_AKSHARE and timeframe in ('1D', '1W'):
|
||||
klines = self._fetch_akshare(symbol, timeframe, limit, before_time)
|
||||
if klines:
|
||||
return klines
|
||||
|
||||
# 分钟级数据获取失败提示
|
||||
if timeframe not in ('1D', '1W'):
|
||||
logger.warning(f"HK stock {symbol}: minute-level data is not supported (data source limitations)")
|
||||
else:
|
||||
logger.warning(f"HK stock {symbol}: data fetch failed (timeframe: {timeframe})")
|
||||
return klines
|
||||
|
||||
def _to_tencent_symbol(self, symbol: str) -> str:
|
||||
"""转换为腾讯财经格式"""
|
||||
# 港股代码补齐到5位
|
||||
padded = symbol.zfill(5)
|
||||
return f"hk{padded}"
|
||||
|
||||
def _to_yahoo_symbol(self, symbol: str) -> str:
|
||||
"""转换为 Yahoo Finance 格式"""
|
||||
# 港股代码补齐到4位
|
||||
padded = symbol.zfill(4)
|
||||
return f"{padded}.HK"
|
||||
|
||||
def _fetch_eastmoney_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用东方财富获取港股分钟级数据"""
|
||||
klines = []
|
||||
|
||||
# 东方财富 K 线周期映射
|
||||
em_period_map = {
|
||||
'1m': '1',
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'1H': '60',
|
||||
'4H': '240',
|
||||
'1D': '101',
|
||||
'1W': '102',
|
||||
}
|
||||
|
||||
period = em_period_map.get(timeframe)
|
||||
if not period:
|
||||
logger.warning(f"Eastmoney unsupported timeframe: {timeframe}")
|
||||
return []
|
||||
|
||||
try:
|
||||
# 港股代码补齐到5位
|
||||
hk_symbol = symbol.zfill(5)
|
||||
# 东方财富港股代码格式: 116.00700 (116是港股市场代码)
|
||||
secid = f"116.{hk_symbol}"
|
||||
|
||||
# 东方财富K线接口
|
||||
url = f"https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57',
|
||||
'klt': period, # K线类型
|
||||
'fqt': '1', # 前复权
|
||||
'end': '20500101',
|
||||
'lmt': limit,
|
||||
}
|
||||
|
||||
# logger.info(f"东方财富港股请求: {hk_symbol}, 周期: {timeframe}")
|
||||
|
||||
# 添加浏览器请求头,避免被拒绝
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://quote.eastmoney.com/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
}
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=headers, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Eastmoney HTTP status: {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 解析响应
|
||||
if data.get('data') and data['data'].get('klines'):
|
||||
for line in data['data']['klines']:
|
||||
try:
|
||||
# 格式: "2025-11-28 15:00,400.0,401.0,399.0,400.5,1000,100000"
|
||||
# 日期,开盘,收盘,最高,最低,成交量,成交额
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 6:
|
||||
time_str = parts[0]
|
||||
# 解析时间
|
||||
if ' ' in time_str:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M')
|
||||
else:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
|
||||
klines.append(self.format_kline(
|
||||
timestamp=int(dt.timestamp()),
|
||||
open_price=float(parts[1]),
|
||||
high=float(parts[3]),
|
||||
low=float(parts[4]),
|
||||
close=float(parts[2]),
|
||||
volume=float(parts[5])
|
||||
))
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"Failed to parse Eastmoney data line: {line}, error: {e}")
|
||||
continue
|
||||
|
||||
# logger.info(f"东方财富返回 {len(klines)} 条港股数据")
|
||||
else:
|
||||
logger.warning("Eastmoney returned no data")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Eastmoney HK stock fetch failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_akshare(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 akshare 获取港股数据"""
|
||||
klines = []
|
||||
|
||||
try:
|
||||
# 计算日期范围
|
||||
if before_time:
|
||||
end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d')
|
||||
else:
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
days = limit * 2 if timeframe == '1D' else limit * 10
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
|
||||
# 港股代码补齐到5位
|
||||
hk_symbol = symbol.zfill(5)
|
||||
|
||||
# logger.info(f"使用 akshare 获取港股: {hk_symbol}")
|
||||
|
||||
df = ak.stock_hk_hist(
|
||||
symbol=hk_symbol,
|
||||
period="daily",
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
adjust="qfq"
|
||||
)
|
||||
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(limit)
|
||||
for _, row in df.iterrows():
|
||||
ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp())
|
||||
klines.append(self.format_kline(
|
||||
timestamp=ts,
|
||||
open_price=row['开盘'],
|
||||
high=row['最高'],
|
||||
low=row['最低'],
|
||||
close=row['收盘'],
|
||||
volume=row['成交量']
|
||||
))
|
||||
# logger.info(f"akshare 返回 {len(klines)} 条港股数据")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Akshare HK stock fetch failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return klines
|
||||
|
||||
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取港股实时报价
|
||||
|
||||
使用腾讯财经实时行情API获取实时报价
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'last': 当前价格,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
'high': 最高价,
|
||||
'low': 最低价,
|
||||
'open': 开盘价,
|
||||
'previousClose': 昨收价
|
||||
}
|
||||
"""
|
||||
symbol = (symbol or '').strip()
|
||||
|
||||
# 使用腾讯财经实时报价
|
||||
try:
|
||||
tencent_symbol = self._to_tencent_symbol(symbol)
|
||||
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
|
||||
response = requests.get(url, timeout=10)
|
||||
content = response.content.decode('gbk', errors='ignore')
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
'last': float(parts[3]) if parts[3] else 0,
|
||||
'change': float(parts[31]) if parts[31] else 0,
|
||||
'changePercent': float(parts[32]) if parts[32] else 0,
|
||||
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
'previousClose': float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
|
||||
|
||||
# 降级使用东方财富
|
||||
try:
|
||||
hk_symbol = symbol.zfill(5)
|
||||
secid = f"116.{hk_symbol}"
|
||||
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
|
||||
}
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and data.get('data'):
|
||||
d = data['data']
|
||||
last_price = d.get('f43', 0)
|
||||
if last_price and last_price > 0:
|
||||
divisor = 1000 if last_price > 10000 else 100 if last_price > 1000 else 1
|
||||
return {
|
||||
'last': last_price / divisor,
|
||||
'high': d.get('f44', 0) / divisor,
|
||||
'low': d.get('f45', 0) / divisor,
|
||||
'open': d.get('f46', 0) / divisor,
|
||||
'previousClose': d.get('f60', 0) / divisor,
|
||||
'change': d.get('f169', 0) / divisor,
|
||||
'changePercent': d.get('f170', 0) / 100
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
|
||||
|
||||
# 第三备选: yfinance
|
||||
try:
|
||||
import yfinance as yf
|
||||
# 港股在 yfinance 中的格式是 XXXX.HK
|
||||
yf_symbol = f"{symbol.zfill(4)}.HK"
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
|
||||
# Try fast_info first (faster)
|
||||
try:
|
||||
info = ticker.fast_info
|
||||
if hasattr(info, 'last_price') and info.last_price and info.last_price > 0:
|
||||
return {
|
||||
'last': float(info.last_price),
|
||||
'change': float(info.last_price - info.previous_close) if hasattr(info, 'previous_close') and info.previous_close else 0,
|
||||
'changePercent': float((info.last_price - info.previous_close) / info.previous_close * 100) if hasattr(info, 'previous_close') and info.previous_close else 0,
|
||||
'high': float(info.day_high) if hasattr(info, 'day_high') and info.day_high else float(info.last_price),
|
||||
'low': float(info.day_low) if hasattr(info, 'day_low') and info.day_low else float(info.last_price),
|
||||
'open': float(info.open) if hasattr(info, 'open') and info.open else float(info.last_price),
|
||||
'previousClose': float(info.previous_close) if hasattr(info, 'previous_close') and info.previous_close else 0
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to history
|
||||
hist = ticker.history(period="2d")
|
||||
if hist is not None and not hist.empty:
|
||||
current = float(hist['Close'].iloc[-1])
|
||||
prev_close = float(hist['Close'].iloc[-2]) if len(hist) > 1 else current
|
||||
return {
|
||||
'last': current,
|
||||
'change': current - prev_close,
|
||||
'changePercent': (current - prev_close) / prev_close * 100 if prev_close else 0,
|
||||
'high': float(hist['High'].iloc[-1]),
|
||||
'low': float(hist['Low'].iloc[-1]),
|
||||
'open': float(hist['Open'].iloc[-1]),
|
||||
'previousClose': prev_close
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"yfinance ticker failed for {symbol}: {e}")
|
||||
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
@@ -1,629 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
A股数据源管理器 (Data Manager)
|
||||
===================================
|
||||
|
||||
参考 daily_stock_analysis 项目的 DataFetcherManager 实现
|
||||
统一管理多个A股数据源,实现自动故障切换
|
||||
|
||||
数据源优先级:
|
||||
1. 东方财富 (Eastmoney) - 数据最全,首选
|
||||
2. 腾讯财经 (Tencent) - 稳定可靠
|
||||
3. 新浪财经 (Sina) - 轻量级
|
||||
4. Akshare - 功能丰富,但容易被封
|
||||
5. yfinance - 国际数据源,兜底
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import requests
|
||||
|
||||
from app.data_sources.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
get_ashare_circuit_breaker,
|
||||
get_realtime_circuit_breaker
|
||||
)
|
||||
from app.data_sources.cache_manager import (
|
||||
DataCache,
|
||||
get_realtime_cache,
|
||||
get_kline_cache,
|
||||
generate_kline_cache_key
|
||||
)
|
||||
from app.data_sources.rate_limiter import (
|
||||
RateLimiter,
|
||||
get_eastmoney_limiter,
|
||||
get_tencent_limiter,
|
||||
get_akshare_limiter,
|
||||
get_request_headers,
|
||||
retry_with_backoff
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.http import get_retry_session
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 数据源常量
|
||||
# ============================================
|
||||
|
||||
class DataSource:
|
||||
"""数据源标识"""
|
||||
EASTMONEY = "eastmoney"
|
||||
TENCENT = "tencent"
|
||||
SINA = "sina"
|
||||
AKSHARE = "akshare"
|
||||
YFINANCE = "yfinance"
|
||||
|
||||
|
||||
# 数据源优先级(数字越小优先级越高)
|
||||
DATA_SOURCE_PRIORITY = {
|
||||
DataSource.EASTMONEY: 0,
|
||||
DataSource.TENCENT: 1,
|
||||
DataSource.SINA: 2,
|
||||
DataSource.AKSHARE: 3,
|
||||
DataSource.YFINANCE: 4,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# A股数据管理器
|
||||
# ============================================
|
||||
|
||||
class AShareDataManager:
|
||||
"""
|
||||
A股数据源管理器
|
||||
|
||||
功能:
|
||||
1. 多数据源自动切换(按优先级)
|
||||
2. 熔断器保护
|
||||
3. 数据缓存
|
||||
4. 防封禁策略
|
||||
"""
|
||||
|
||||
# 东方财富 K 线周期映射
|
||||
EM_PERIOD_MAP = {
|
||||
'1m': '1',
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'1H': '60',
|
||||
'4H': '240',
|
||||
'1D': '101',
|
||||
'1W': '102',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# 熔断器
|
||||
self._circuit_breaker = get_ashare_circuit_breaker()
|
||||
self._realtime_cb = get_realtime_circuit_breaker()
|
||||
|
||||
# 缓存
|
||||
self._realtime_cache = get_realtime_cache()
|
||||
self._kline_cache = get_kline_cache()
|
||||
|
||||
# 限流器
|
||||
self._em_limiter = get_eastmoney_limiter()
|
||||
self._tencent_limiter = get_tencent_limiter()
|
||||
self._akshare_limiter = get_akshare_limiter()
|
||||
|
||||
# Akshare 可用性检查
|
||||
self._has_akshare = self._check_akshare()
|
||||
|
||||
def _check_akshare(self) -> bool:
|
||||
"""检查 akshare 是否可用"""
|
||||
try:
|
||||
import akshare
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("akshare 未安装,相关功能已禁用")
|
||||
return False
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[List[Dict[str, Any]], str]:
|
||||
"""
|
||||
获取K线数据(自动切换数据源)
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
timeframe: 时间周期
|
||||
limit: 数据条数
|
||||
before_time: 获取此时间之前的数据
|
||||
use_cache: 是否使用缓存
|
||||
|
||||
Returns:
|
||||
(K线数据列表, 成功的数据源名称)
|
||||
"""
|
||||
# 检查缓存
|
||||
if use_cache:
|
||||
cache_key = generate_kline_cache_key(symbol, timeframe, limit, before_time)
|
||||
cached = self._kline_cache.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"[缓存命中] K线数据 {symbol}:{timeframe}")
|
||||
return cached, "cache"
|
||||
|
||||
errors = []
|
||||
|
||||
# 按优先级尝试各个数据源
|
||||
sources = [
|
||||
(DataSource.EASTMONEY, self._fetch_eastmoney_kline),
|
||||
(DataSource.TENCENT, self._fetch_tencent_kline),
|
||||
(DataSource.AKSHARE, self._fetch_akshare_kline),
|
||||
(DataSource.YFINANCE, self._fetch_yfinance_kline),
|
||||
]
|
||||
|
||||
for source_name, fetch_func in sources:
|
||||
# 检查熔断器
|
||||
if not self._circuit_breaker.is_available(source_name):
|
||||
logger.debug(f"[熔断] {source_name} 处于熔断状态,跳过")
|
||||
continue
|
||||
|
||||
# 跳过不可用的 akshare
|
||||
if source_name == DataSource.AKSHARE and not self._has_akshare:
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.debug(f"[数据源] 尝试 {source_name} 获取 {symbol}")
|
||||
klines = fetch_func(symbol, timeframe, limit, before_time)
|
||||
|
||||
if klines:
|
||||
self._circuit_breaker.record_success(source_name)
|
||||
|
||||
# 更新缓存
|
||||
if use_cache:
|
||||
cache_key = generate_kline_cache_key(symbol, timeframe, limit, before_time)
|
||||
self._kline_cache.set(cache_key, klines)
|
||||
|
||||
logger.info(f"[数据源] {source_name} 成功获取 {symbol} {len(klines)} 条数据")
|
||||
return klines, source_name
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{source_name}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
self._circuit_breaker.record_failure(source_name, str(e))
|
||||
logger.warning(f"[数据源] {error_msg}")
|
||||
|
||||
# 所有数据源都失败
|
||||
logger.error(f"[数据源] 所有数据源获取 {symbol} 失败: {errors}")
|
||||
return [], ""
|
||||
|
||||
def _fetch_eastmoney_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用东方财富获取K线数据"""
|
||||
klines = []
|
||||
|
||||
period = self.EM_PERIOD_MAP.get(timeframe)
|
||||
if not period:
|
||||
raise ValueError(f"Eastmoney 不支持时间周期: {timeframe}")
|
||||
|
||||
# 限流
|
||||
self._em_limiter.wait()
|
||||
|
||||
# 确定市场代码
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}" # 上海
|
||||
else:
|
||||
secid = f"0.{symbol}" # 深圳
|
||||
|
||||
url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57',
|
||||
'klt': period,
|
||||
'fqt': '1', # 前复权
|
||||
'end': '20500101',
|
||||
'lmt': limit,
|
||||
}
|
||||
|
||||
headers = get_request_headers(referer='https://quote.eastmoney.com/')
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=headers, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(f"HTTP {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get('data') and data['data'].get('klines'):
|
||||
for line in data['data']['klines']:
|
||||
try:
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 6:
|
||||
time_str = parts[0]
|
||||
if ' ' in time_str:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M')
|
||||
else:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
|
||||
klines.append({
|
||||
'time': int(dt.timestamp()),
|
||||
'open': round(float(parts[1]), 4),
|
||||
'high': round(float(parts[3]), 4),
|
||||
'low': round(float(parts[4]), 4),
|
||||
'close': round(float(parts[2]), 4),
|
||||
'volume': round(float(parts[5]), 2)
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# 过滤和排序
|
||||
klines.sort(key=lambda x: x['time'])
|
||||
if before_time:
|
||||
klines = [k for k in klines if k['time'] < before_time]
|
||||
if len(klines) > limit:
|
||||
klines = klines[-limit:]
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_tencent_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用腾讯财经获取K线数据"""
|
||||
# 腾讯周期映射
|
||||
period_map = {
|
||||
'1m': 1, '5m': 5, '15m': 15, '30m': 30,
|
||||
'1H': 60, '1D': 'day', '1W': 'week'
|
||||
}
|
||||
|
||||
period = period_map.get(timeframe)
|
||||
if period is None:
|
||||
raise ValueError(f"腾讯财经不支持时间周期: {timeframe}")
|
||||
|
||||
# 转换代码格式
|
||||
if symbol.startswith('6'):
|
||||
tencent_symbol = f"sh{symbol}"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
tencent_symbol = f"sz{symbol}"
|
||||
else:
|
||||
tencent_symbol = f"bj{symbol}"
|
||||
|
||||
# 限流
|
||||
self._tencent_limiter.wait()
|
||||
|
||||
# 构建URL
|
||||
if isinstance(period, int):
|
||||
url = f"http://ifzq.gtimg.cn/appstock/app/kline/mkline?param={tencent_symbol},m{period},,{limit}"
|
||||
else:
|
||||
url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={tencent_symbol},{period},,,{limit},qfq"
|
||||
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(f"HTTP {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
klines = []
|
||||
|
||||
if data.get('code') == 0 and 'data' in data:
|
||||
stock_data = data['data'].get(tencent_symbol)
|
||||
if stock_data:
|
||||
if isinstance(period, int):
|
||||
candles = stock_data.get(f'm{period}', [])
|
||||
else:
|
||||
candles = stock_data.get('qfqday', stock_data.get('day', []))
|
||||
|
||||
for candle in candles:
|
||||
if len(candle) >= 5:
|
||||
try:
|
||||
time_str = str(candle[0])
|
||||
if len(time_str) == 12:
|
||||
dt = datetime.strptime(time_str, '%Y%m%d%H%M')
|
||||
elif len(time_str) == 10:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
else:
|
||||
continue
|
||||
|
||||
klines.append({
|
||||
'time': int(dt.timestamp()),
|
||||
'open': round(float(candle[1]), 4),
|
||||
'high': round(float(candle[3]), 4),
|
||||
'low': round(float(candle[4]), 4),
|
||||
'close': round(float(candle[2]), 4),
|
||||
'volume': round(float(candle[5]), 2) if len(candle) > 5 else 0
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# 过滤和排序
|
||||
klines.sort(key=lambda x: x['time'])
|
||||
if before_time:
|
||||
klines = [k for k in klines if k['time'] < before_time]
|
||||
if len(klines) > limit:
|
||||
klines = klines[-limit:]
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_akshare_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 Akshare 获取K线数据"""
|
||||
if not self._has_akshare:
|
||||
raise RuntimeError("akshare 未安装")
|
||||
|
||||
import akshare as ak
|
||||
from datetime import timedelta
|
||||
|
||||
# Akshare 只支持日线/周线
|
||||
period_map = {'1D': 'daily', '1W': 'weekly'}
|
||||
period = period_map.get(timeframe)
|
||||
if not period:
|
||||
raise ValueError(f"Akshare 不支持时间周期: {timeframe}")
|
||||
|
||||
# 限流
|
||||
self._akshare_limiter.wait()
|
||||
|
||||
# 计算日期范围
|
||||
if before_time:
|
||||
end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d')
|
||||
else:
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
days = limit * 2 if timeframe == '1D' else limit * 10
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||||
|
||||
df = ak.stock_zh_a_hist(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
adjust="qfq"
|
||||
)
|
||||
|
||||
klines = []
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(limit)
|
||||
for _, row in df.iterrows():
|
||||
ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp())
|
||||
klines.append({
|
||||
'time': ts,
|
||||
'open': round(float(row['开盘']), 4),
|
||||
'high': round(float(row['最高']), 4),
|
||||
'low': round(float(row['最低']), 4),
|
||||
'close': round(float(row['收盘']), 4),
|
||||
'volume': round(float(row['成交量']), 2)
|
||||
})
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_yfinance_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 yfinance 获取K线数据(兜底)"""
|
||||
import yfinance as yf
|
||||
|
||||
# 转换为 Yahoo 格式
|
||||
if symbol.startswith('6'):
|
||||
yahoo_symbol = f"{symbol}.SS"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
yahoo_symbol = f"{symbol}.SZ"
|
||||
else:
|
||||
yahoo_symbol = f"{symbol}.SS"
|
||||
|
||||
# 周期映射
|
||||
period_map = {
|
||||
'1D': ('1d', f'{limit}d'),
|
||||
'1W': ('1wk', f'{limit * 7}d'),
|
||||
'1H': ('1h', f'{limit}d'),
|
||||
}
|
||||
|
||||
interval, period = period_map.get(timeframe, ('1d', f'{limit}d'))
|
||||
|
||||
ticker = yf.Ticker(yahoo_symbol)
|
||||
df = ticker.history(period=period, interval=interval)
|
||||
|
||||
klines = []
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(limit)
|
||||
for idx, row in df.iterrows():
|
||||
ts = int(idx.timestamp())
|
||||
klines.append({
|
||||
'time': ts,
|
||||
'open': round(float(row['Open']), 4),
|
||||
'high': round(float(row['High']), 4),
|
||||
'low': round(float(row['Low']), 4),
|
||||
'close': round(float(row['Close']), 4),
|
||||
'volume': round(float(row['Volume']), 2)
|
||||
})
|
||||
|
||||
return klines
|
||||
|
||||
def get_realtime_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[Dict[str, Any], str]:
|
||||
"""
|
||||
获取实时报价(自动切换数据源)
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
use_cache: 是否使用缓存
|
||||
|
||||
Returns:
|
||||
(报价数据字典, 成功的数据源名称)
|
||||
"""
|
||||
# 检查缓存
|
||||
if use_cache:
|
||||
cached = self._realtime_cache.get(f"quote:{symbol}")
|
||||
if cached:
|
||||
return cached, "cache"
|
||||
|
||||
errors = []
|
||||
|
||||
# 按优先级尝试各个数据源
|
||||
sources = [
|
||||
(DataSource.EASTMONEY, self._fetch_eastmoney_quote),
|
||||
(DataSource.TENCENT, self._fetch_tencent_quote),
|
||||
(DataSource.AKSHARE, self._fetch_akshare_quote),
|
||||
]
|
||||
|
||||
for source_name, fetch_func in sources:
|
||||
if not self._realtime_cb.is_available(source_name):
|
||||
continue
|
||||
|
||||
if source_name == DataSource.AKSHARE and not self._has_akshare:
|
||||
continue
|
||||
|
||||
try:
|
||||
quote = fetch_func(symbol)
|
||||
if quote and quote.get('last', 0) > 0:
|
||||
self._realtime_cb.record_success(source_name)
|
||||
|
||||
# 更新缓存
|
||||
if use_cache:
|
||||
self._realtime_cache.set(f"quote:{symbol}", quote, ttl=60.0)
|
||||
|
||||
return quote, source_name
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{source_name}: {str(e)}")
|
||||
self._realtime_cb.record_failure(source_name, str(e))
|
||||
|
||||
logger.warning(f"[实时报价] 所有数据源获取 {symbol} 失败")
|
||||
return {'last': 0, 'symbol': symbol}, ""
|
||||
|
||||
def _fetch_eastmoney_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用东方财富获取实时报价"""
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}"
|
||||
else:
|
||||
secid = f"0.{symbol}"
|
||||
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
|
||||
}
|
||||
|
||||
self._em_limiter.wait()
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=get_request_headers(), timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and data.get('data'):
|
||||
d = data['data']
|
||||
last_price = d.get('f43', 0)
|
||||
if last_price and last_price > 0:
|
||||
divisor = 100 if last_price > 1000 else 1
|
||||
return {
|
||||
'last': last_price / divisor,
|
||||
'high': d.get('f44', 0) / divisor,
|
||||
'low': d.get('f45', 0) / divisor,
|
||||
'open': d.get('f46', 0) / divisor,
|
||||
'previousClose': d.get('f60', 0) / divisor,
|
||||
'change': d.get('f169', 0) / divisor,
|
||||
'changePercent': d.get('f170', 0) / 100
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _fetch_tencent_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用腾讯财经获取实时报价"""
|
||||
if symbol.startswith('6'):
|
||||
tencent_symbol = f"sh{symbol}"
|
||||
else:
|
||||
tencent_symbol = f"sz{symbol}"
|
||||
|
||||
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
|
||||
|
||||
self._tencent_limiter.wait()
|
||||
|
||||
response = requests.get(url, timeout=10)
|
||||
content = response.content.decode('gbk', errors='ignore')
|
||||
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
'last': float(parts[3]) if parts[3] else 0,
|
||||
'change': float(parts[31]) if parts[31] else 0,
|
||||
'changePercent': float(parts[32]) if parts[32] else 0,
|
||||
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
'previousClose': float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _fetch_akshare_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用 Akshare 获取实时报价"""
|
||||
if not self._has_akshare:
|
||||
return {}
|
||||
|
||||
import akshare as ak
|
||||
|
||||
self._akshare_limiter.wait()
|
||||
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['代码'] == symbol]
|
||||
if not row.empty:
|
||||
row = row.iloc[0]
|
||||
return {
|
||||
'last': float(row.get('最新价', 0) or 0),
|
||||
'change': float(row.get('涨跌额', 0) or 0),
|
||||
'changePercent': float(row.get('涨跌幅', 0) or 0),
|
||||
'high': float(row.get('最高', 0) or 0),
|
||||
'low': float(row.get('最低', 0) or 0),
|
||||
'open': float(row.get('今开', 0) or 0),
|
||||
'previousClose': float(row.get('昨收', 0) or 0)
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取数据管理器状态"""
|
||||
return {
|
||||
'circuit_breaker': self._circuit_breaker.get_status(),
|
||||
'realtime_circuit_breaker': self._realtime_cb.get_status(),
|
||||
'realtime_cache_stats': self._realtime_cache.stats(),
|
||||
'kline_cache_stats': self._kline_cache.stats(),
|
||||
'has_akshare': self._has_akshare,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 全局实例
|
||||
# ============================================
|
||||
|
||||
_ashare_data_manager: Optional[AShareDataManager] = None
|
||||
|
||||
|
||||
def get_ashare_data_manager() -> AShareDataManager:
|
||||
"""获取A股数据管理器单例"""
|
||||
global _ashare_data_manager
|
||||
if _ashare_data_manager is None:
|
||||
_ashare_data_manager = AShareDataManager()
|
||||
return _ashare_data_manager
|
||||
@@ -21,7 +21,7 @@ class DataSourceFactory:
|
||||
获取指定市场的数据源
|
||||
|
||||
Args:
|
||||
market: 市场类型 (Crypto, USStock, AShare, HShare)
|
||||
market: 市场类型 (Crypto, USStock, Forex, Futures)
|
||||
|
||||
Returns:
|
||||
数据源实例
|
||||
@@ -55,12 +55,6 @@ class DataSourceFactory:
|
||||
elif market == 'USStock':
|
||||
from app.data_sources.us_stock import USStockDataSource
|
||||
return USStockDataSource()
|
||||
elif market == 'AShare':
|
||||
from app.data_sources.cn_stock import AShareDataSource
|
||||
return AShareDataSource()
|
||||
elif market == 'HShare':
|
||||
from app.data_sources.cn_stock import HShareDataSource
|
||||
return HShareDataSource()
|
||||
elif market == 'Forex':
|
||||
from app.data_sources.forex import ForexDataSource
|
||||
return ForexDataSource()
|
||||
|
||||
@@ -24,6 +24,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.global_market import global_market_bp
|
||||
from app.routes.community import community_bp
|
||||
from app.routes.fast_analysis import fast_analysis_bp
|
||||
from app.routes.billing import billing_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
|
||||
@@ -42,4 +43,5 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
|
||||
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
|
||||
app.register_blueprint(community_bp, url_prefix='/api/community')
|
||||
app.register_blueprint(fast_analysis_bp, url_prefix='/api/fast-analysis')
|
||||
app.register_blueprint(fast_analysis_bp, url_prefix='/api/fast-analysis')
|
||||
app.register_blueprint(billing_bp, url_prefix='/api/billing')
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Billing APIs - 会员购买/套餐配置(Mock支付)
|
||||
|
||||
当前版本先实现“快速商业闭环”的最小可用:
|
||||
- 从系统设置(.env)读取 3 档会员(包月/包年/永久)金额与赠送积分配置
|
||||
- 用户在前端购买后立即开通/发放积分(后续可替换为真实支付网关)
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from app.utils.auth import login_required
|
||||
from app.utils.logger import get_logger
|
||||
from app.services.billing_service import get_billing_service
|
||||
from app.services.usdt_payment_service import get_usdt_payment_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
billing_bp = Blueprint("billing", __name__)
|
||||
|
||||
|
||||
@billing_bp.route("/plans", methods=["GET"])
|
||||
@login_required
|
||||
def get_membership_plans():
|
||||
"""Get membership plan configuration + current user's billing snapshot."""
|
||||
try:
|
||||
user_id = getattr(g, "user_id", None)
|
||||
svc = get_billing_service()
|
||||
plans = svc.get_membership_plans()
|
||||
billing_info = svc.get_user_billing_info(user_id) if user_id else {}
|
||||
return jsonify({"code": 1, "msg": "success", "data": {"plans": plans, "billing": billing_info}})
|
||||
except Exception as e:
|
||||
logger.error(f"get_membership_plans failed: {e}", exc_info=True)
|
||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||
|
||||
|
||||
@billing_bp.route("/purchase", methods=["POST"])
|
||||
@login_required
|
||||
def purchase_membership():
|
||||
"""
|
||||
Purchase membership (mock: immediate activation).
|
||||
|
||||
Body:
|
||||
{ plan: "monthly" | "yearly" | "lifetime" }
|
||||
"""
|
||||
try:
|
||||
user_id = getattr(g, "user_id", None)
|
||||
data = request.get_json() or {}
|
||||
plan = (data.get("plan") or "").strip().lower()
|
||||
if not plan:
|
||||
return jsonify({"code": 0, "msg": "missing_plan", "data": None}), 400
|
||||
|
||||
success, msg, out = get_billing_service().purchase_membership(user_id, plan)
|
||||
if success:
|
||||
return jsonify({"code": 1, "msg": msg, "data": out})
|
||||
return jsonify({"code": 0, "msg": msg, "data": out}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"purchase_membership failed: {e}", exc_info=True)
|
||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||
|
||||
|
||||
# =========================
|
||||
# USDT Pay (方案B)
|
||||
# =========================
|
||||
|
||||
|
||||
@billing_bp.route("/usdt/create", methods=["POST"])
|
||||
@login_required
|
||||
def usdt_create_order():
|
||||
"""
|
||||
Create USDT order for membership plan (per-order address).
|
||||
|
||||
Body:
|
||||
{ plan: "monthly"|"yearly"|"lifetime" }
|
||||
"""
|
||||
try:
|
||||
user_id = getattr(g, "user_id", None)
|
||||
data = request.get_json() or {}
|
||||
plan = (data.get("plan") or "").strip().lower()
|
||||
if not plan:
|
||||
return jsonify({"code": 0, "msg": "missing_plan", "data": None}), 400
|
||||
|
||||
ok, msg, out = get_usdt_payment_service().create_order(user_id, plan)
|
||||
if ok:
|
||||
return jsonify({"code": 1, "msg": "success", "data": out})
|
||||
return jsonify({"code": 0, "msg": msg, "data": out}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"usdt_create_order failed: {e}", exc_info=True)
|
||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||
|
||||
|
||||
@billing_bp.route("/usdt/order/<int:order_id>", methods=["GET"])
|
||||
@login_required
|
||||
def usdt_get_order(order_id: int):
|
||||
"""Get my USDT order; refresh chain status by default."""
|
||||
try:
|
||||
user_id = getattr(g, "user_id", None)
|
||||
refresh = str(request.args.get("refresh", "1")).lower() in ("1", "true", "yes")
|
||||
ok, msg, out = get_usdt_payment_service().get_order(user_id, order_id, refresh=refresh)
|
||||
if ok:
|
||||
return jsonify({"code": 1, "msg": "success", "data": out})
|
||||
return jsonify({"code": 0, "msg": msg, "data": out}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"usdt_get_order failed: {e}", exc_info=True)
|
||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||
|
||||
@@ -23,7 +23,7 @@ def analyze():
|
||||
|
||||
POST /api/fast-analysis/analyze
|
||||
Body: {
|
||||
"market": "Crypto" | "USStock" | "AShare" | "Forex" | ...,
|
||||
"market": "Crypto" | "USStock" | "Forex" | ...,
|
||||
"symbol": "BTC/USDT" | "AAPL" | ...,
|
||||
"language": "zh-CN" | "en-US" (optional),
|
||||
"model": "openai/gpt-4o" (optional),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Global Market Dashboard APIs.
|
||||
|
||||
Provides aggregated global market data including:
|
||||
- Major indices (US, China, Hong Kong, Europe, Japan)
|
||||
- Major indices (US, Europe, Japan, Korea, Australia, India)
|
||||
- Forex pairs
|
||||
- Crypto prices
|
||||
- Market heatmap data (crypto, stocks, forex)
|
||||
@@ -53,7 +53,7 @@ CACHE_TTL = {
|
||||
"market_news": 180, # 3分钟 - 新闻
|
||||
"economic_calendar": 3600, # 1小时 - 日历事件
|
||||
"market_sentiment": 21600, # 6小时 - 宏观情绪变化缓慢
|
||||
"trading_opportunities": 60, # 1分钟 - 交易机会需要较新
|
||||
"trading_opportunities": 3600, # 1小时 - 每小时更新一次
|
||||
}
|
||||
|
||||
|
||||
@@ -257,12 +257,6 @@ def _fetch_stock_indices() -> List[Dict[str, Any]]:
|
||||
{"symbol": "^GSPC", "name_cn": "标普500", "name_en": "S&P 500", "region": "US", "flag": "🇺🇸", "lat": 40.7, "lng": -74.0},
|
||||
{"symbol": "^DJI", "name_cn": "道琼斯", "name_en": "Dow Jones", "region": "US", "flag": "🇺🇸", "lat": 38.5, "lng": -77.0},
|
||||
{"symbol": "^IXIC", "name_cn": "纳斯达克", "name_en": "NASDAQ", "region": "US", "flag": "🇺🇸", "lat": 37.5, "lng": -122.4},
|
||||
# China Markets - 坐标错开
|
||||
{"symbol": "000001.SS", "name_cn": "上证指数", "name_en": "SSE Composite", "region": "CN", "flag": "🇨🇳", "lat": 31.2, "lng": 121.5},
|
||||
{"symbol": "399001.SZ", "name_cn": "深证成指", "name_en": "SZSE Component", "region": "CN", "flag": "🇨🇳", "lat": 22.5, "lng": 114.1},
|
||||
{"symbol": "399006.SZ", "name_cn": "创业板指", "name_en": "ChiNext", "region": "CN", "flag": "🇨🇳", "lat": 25.0, "lng": 117.0},
|
||||
# Hong Kong - 只保留恒生指数
|
||||
{"symbol": "^HSI", "name_cn": "恒生指数", "name_en": "Hang Seng", "region": "HK", "flag": "🇭🇰", "lat": 22.3, "lng": 114.2},
|
||||
# Europe
|
||||
{"symbol": "^GDAXI", "name_cn": "德国DAX", "name_en": "DAX", "region": "EU", "flag": "🇩🇪", "lat": 50.1109, "lng": 8.6821},
|
||||
{"symbol": "^FTSE", "name_cn": "英国富时100", "name_en": "FTSE 100", "region": "EU", "flag": "🇬🇧", "lat": 51.5074, "lng": -0.1278},
|
||||
@@ -977,12 +971,12 @@ def _fetch_financial_news(lang: str = "all") -> Dict[str, List[Dict[str, Any]]]:
|
||||
|
||||
# Chinese news queries
|
||||
cn_queries = [
|
||||
"A股市场最新消息",
|
||||
"加密货币新闻",
|
||||
"美联储利率",
|
||||
"中国经济数据",
|
||||
"港股市场动态",
|
||||
"美股市场最新消息",
|
||||
"外汇市场分析",
|
||||
"全球经济数据",
|
||||
"期货市场动态",
|
||||
]
|
||||
|
||||
# English news queries
|
||||
@@ -1106,42 +1100,6 @@ def _get_economic_calendar() -> List[Dict[str, Any]]:
|
||||
"impact_desc": "加息利空欧股,利多欧元",
|
||||
"impact_desc_en": "Rate hike: bearish EU stocks, bullish EUR"
|
||||
},
|
||||
{
|
||||
"name": "中国GDP年率",
|
||||
"name_en": "China GDP y/y",
|
||||
"country": "CN",
|
||||
"importance": "high",
|
||||
"forecast": "5.2%",
|
||||
"previous": "5.0%",
|
||||
"impact_if_above": "bullish",
|
||||
"impact_if_below": "bearish",
|
||||
"impact_desc": "GDP高于预期利多A股和港股",
|
||||
"impact_desc_en": "Above forecast: bullish A-shares and HK stocks"
|
||||
},
|
||||
{
|
||||
"name": "中国CPI年率",
|
||||
"name_en": "China CPI y/y",
|
||||
"country": "CN",
|
||||
"importance": "medium",
|
||||
"forecast": "0.3%",
|
||||
"previous": "0.1%",
|
||||
"impact_if_above": "neutral",
|
||||
"impact_if_below": "bearish",
|
||||
"impact_desc": "通胀过低反映需求不足,利空股市",
|
||||
"impact_desc_en": "Low inflation reflects weak demand, bearish stocks"
|
||||
},
|
||||
{
|
||||
"name": "中国PMI",
|
||||
"name_en": "China Manufacturing PMI",
|
||||
"country": "CN",
|
||||
"importance": "medium",
|
||||
"forecast": "50.2",
|
||||
"previous": "49.8",
|
||||
"impact_if_above": "bullish",
|
||||
"impact_if_below": "bearish",
|
||||
"impact_desc": "PMI>50表示扩张,利多A股和大宗商品",
|
||||
"impact_desc_en": "PMI>50 = expansion, bullish A-shares and commodities"
|
||||
},
|
||||
{
|
||||
"name": "日本央行利率决议",
|
||||
"name_en": "BoJ Interest Rate Decision",
|
||||
@@ -1634,80 +1592,265 @@ def market_sentiment():
|
||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||
|
||||
|
||||
def _fetch_stock_opportunity_prices() -> List[Dict[str, Any]]:
|
||||
"""Fetch popular US stock prices for opportunity scanning."""
|
||||
stocks = [
|
||||
{"symbol": "AAPL", "name": "Apple"},
|
||||
{"symbol": "MSFT", "name": "Microsoft"},
|
||||
{"symbol": "GOOGL", "name": "Alphabet"},
|
||||
{"symbol": "AMZN", "name": "Amazon"},
|
||||
{"symbol": "TSLA", "name": "Tesla"},
|
||||
{"symbol": "NVDA", "name": "NVIDIA"},
|
||||
{"symbol": "META", "name": "Meta"},
|
||||
{"symbol": "NFLX", "name": "Netflix"},
|
||||
{"symbol": "AMD", "name": "AMD"},
|
||||
{"symbol": "CRM", "name": "Salesforce"},
|
||||
{"symbol": "COIN", "name": "Coinbase"},
|
||||
{"symbol": "BABA", "name": "Alibaba"},
|
||||
{"symbol": "NIO", "name": "NIO"},
|
||||
{"symbol": "PLTR", "name": "Palantir"},
|
||||
{"symbol": "INTC", "name": "Intel"},
|
||||
]
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
symbols = [s["symbol"] for s in stocks]
|
||||
tickers = yf.Tickers(" ".join(symbols))
|
||||
|
||||
result = []
|
||||
for stock in stocks:
|
||||
try:
|
||||
ticker = tickers.tickers.get(stock["symbol"])
|
||||
if ticker:
|
||||
hist = ticker.history(period="2d")
|
||||
if len(hist) >= 2:
|
||||
prev_close = float(hist["Close"].iloc[-2])
|
||||
current = float(hist["Close"].iloc[-1])
|
||||
change = ((current - prev_close) / prev_close) * 100
|
||||
elif len(hist) == 1:
|
||||
current = float(hist["Close"].iloc[-1])
|
||||
change = 0
|
||||
else:
|
||||
continue
|
||||
|
||||
result.append({
|
||||
"symbol": stock["symbol"],
|
||||
"name": stock["name"],
|
||||
"price": round(current, 2),
|
||||
"change": round(change, 2)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch stock {stock['symbol']}: {e}")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch stock opportunity prices: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _analyze_opportunities_crypto(opportunities: list):
|
||||
"""Scan crypto market for trading opportunities."""
|
||||
crypto_data = _get_cached("crypto_prices")
|
||||
if not crypto_data:
|
||||
crypto_data = _fetch_crypto_prices()
|
||||
if crypto_data:
|
||||
_set_cached("crypto_prices", crypto_data)
|
||||
|
||||
for coin in (crypto_data or [])[:20]:
|
||||
change = _safe_float(coin.get("change_24h", 0))
|
||||
change_7d = _safe_float(coin.get("change_7d", 0))
|
||||
symbol = coin.get("symbol", "")
|
||||
name = coin.get("name", "")
|
||||
price = _safe_float(coin.get("price", 0))
|
||||
|
||||
signal = None
|
||||
strength = "medium"
|
||||
reason = ""
|
||||
impact = "neutral"
|
||||
|
||||
if change > 15:
|
||||
signal = "overbought"
|
||||
strength = "strong"
|
||||
reason = f"24h涨幅{change:.1f}%,7日涨幅{change_7d:.1f}%,短期超买风险"
|
||||
impact = "bearish"
|
||||
elif change > 8:
|
||||
signal = "bullish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"24h涨幅{change:.1f}%,上涨动能强劲"
|
||||
impact = "bullish"
|
||||
elif change < -15:
|
||||
signal = "oversold"
|
||||
strength = "strong"
|
||||
reason = f"24h跌幅{abs(change):.1f}%,可能超卖反弹"
|
||||
impact = "bullish"
|
||||
elif change < -8:
|
||||
signal = "bearish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"24h跌幅{abs(change):.1f}%,下跌趋势明显"
|
||||
impact = "bearish"
|
||||
|
||||
if signal:
|
||||
opportunities.append({
|
||||
"symbol": symbol,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change_24h": change,
|
||||
"change_7d": change_7d,
|
||||
"signal": signal,
|
||||
"strength": strength,
|
||||
"reason": reason,
|
||||
"impact": impact,
|
||||
"market": "Crypto",
|
||||
"timestamp": int(time.time())
|
||||
})
|
||||
|
||||
|
||||
def _analyze_opportunities_stocks(opportunities: list):
|
||||
"""Scan US stocks for trading opportunities."""
|
||||
stock_data = _get_cached("stock_opportunity_prices")
|
||||
if not stock_data:
|
||||
stock_data = _fetch_stock_opportunity_prices()
|
||||
if stock_data:
|
||||
_set_cached("stock_opportunity_prices", stock_data, 3600)
|
||||
|
||||
for stock in (stock_data or []):
|
||||
change = _safe_float(stock.get("change", 0))
|
||||
symbol = stock.get("symbol", "")
|
||||
name = stock.get("name", "")
|
||||
price = _safe_float(stock.get("price", 0))
|
||||
|
||||
signal = None
|
||||
strength = "medium"
|
||||
reason = ""
|
||||
impact = "neutral"
|
||||
|
||||
# US stocks: smaller thresholds than crypto
|
||||
if change > 5:
|
||||
signal = "overbought"
|
||||
strength = "strong"
|
||||
reason = f"日涨幅{change:.1f}%,短期涨幅较大,注意回调风险"
|
||||
impact = "bearish"
|
||||
elif change > 3:
|
||||
signal = "bullish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"日涨幅{change:.1f}%,上涨动能强劲"
|
||||
impact = "bullish"
|
||||
elif change < -5:
|
||||
signal = "oversold"
|
||||
strength = "strong"
|
||||
reason = f"日跌幅{abs(change):.1f}%,可能超卖反弹"
|
||||
impact = "bullish"
|
||||
elif change < -3:
|
||||
signal = "bearish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"日跌幅{abs(change):.1f}%,下跌趋势明显"
|
||||
impact = "bearish"
|
||||
|
||||
if signal:
|
||||
opportunities.append({
|
||||
"symbol": symbol,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change_24h": change,
|
||||
"signal": signal,
|
||||
"strength": strength,
|
||||
"reason": reason,
|
||||
"impact": impact,
|
||||
"market": "USStock",
|
||||
"timestamp": int(time.time())
|
||||
})
|
||||
|
||||
|
||||
def _analyze_opportunities_forex(opportunities: list):
|
||||
"""Scan forex pairs for trading opportunities."""
|
||||
forex_data = _get_cached("forex_pairs")
|
||||
if not forex_data:
|
||||
forex_data = _fetch_forex_pairs()
|
||||
if forex_data:
|
||||
_set_cached("forex_pairs", forex_data, 3600)
|
||||
|
||||
for pair in (forex_data or []):
|
||||
change = _safe_float(pair.get("change", 0))
|
||||
symbol = pair.get("symbol", pair.get("name", ""))
|
||||
name = pair.get("name_cn", pair.get("name", ""))
|
||||
price = _safe_float(pair.get("price", 0))
|
||||
|
||||
signal = None
|
||||
strength = "medium"
|
||||
reason = ""
|
||||
impact = "neutral"
|
||||
|
||||
# Forex: even smaller thresholds
|
||||
if change > 1.5:
|
||||
signal = "overbought"
|
||||
strength = "strong"
|
||||
reason = f"日涨幅{change:.2f}%,汇率波动剧烈,注意回调"
|
||||
impact = "bearish"
|
||||
elif change > 0.8:
|
||||
signal = "bullish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"日涨幅{change:.2f}%,上涨动能较强"
|
||||
impact = "bullish"
|
||||
elif change < -1.5:
|
||||
signal = "oversold"
|
||||
strength = "strong"
|
||||
reason = f"日跌幅{abs(change):.2f}%,汇率波动剧烈,可能反弹"
|
||||
impact = "bullish"
|
||||
elif change < -0.8:
|
||||
signal = "bearish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"日跌幅{abs(change):.2f}%,下跌趋势明显"
|
||||
impact = "bearish"
|
||||
|
||||
if signal:
|
||||
opportunities.append({
|
||||
"symbol": symbol,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change_24h": change,
|
||||
"signal": signal,
|
||||
"strength": strength,
|
||||
"reason": reason,
|
||||
"impact": impact,
|
||||
"market": "Forex",
|
||||
"timestamp": int(time.time())
|
||||
})
|
||||
|
||||
|
||||
@global_market_bp.route("/opportunities", methods=["GET"])
|
||||
@login_required
|
||||
def trading_opportunities():
|
||||
"""
|
||||
Scan for trading opportunities based on technical indicators.
|
||||
Scan for trading opportunities across Crypto, US Stocks, and Forex.
|
||||
Cached for 1 hour. Pass ?force=true to skip cache.
|
||||
"""
|
||||
try:
|
||||
cached = _get_cached("trading_opportunities", 60)
|
||||
if cached:
|
||||
return jsonify({"code": 1, "msg": "success", "data": cached})
|
||||
|
||||
force = request.args.get("force", "").lower() in ("true", "1")
|
||||
|
||||
if not force:
|
||||
cached = _get_cached("trading_opportunities")
|
||||
if cached:
|
||||
return jsonify({"code": 1, "msg": "success", "data": cached})
|
||||
|
||||
opportunities = []
|
||||
|
||||
# Get crypto data
|
||||
crypto_data = _get_cached("crypto_prices")
|
||||
if not crypto_data:
|
||||
crypto_data = _fetch_crypto_prices()
|
||||
|
||||
# Analyze crypto for opportunities
|
||||
for coin in crypto_data[:15]:
|
||||
change = coin.get("change_24h", 0)
|
||||
change_7d = coin.get("change_7d", 0)
|
||||
symbol = coin.get("symbol", "")
|
||||
name = coin.get("name", "")
|
||||
price = coin.get("price", 0)
|
||||
|
||||
signal = None
|
||||
strength = "medium"
|
||||
reason = ""
|
||||
impact = "neutral"
|
||||
|
||||
if change > 15:
|
||||
signal = "overbought"
|
||||
strength = "strong"
|
||||
reason = f"24h涨幅{change:.1f}%,7日涨幅{change_7d:.1f}%,短期超买风险"
|
||||
impact = "bearish"
|
||||
elif change > 8:
|
||||
signal = "bullish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"24h涨幅{change:.1f}%,上涨动能强劲"
|
||||
impact = "bullish"
|
||||
elif change < -15:
|
||||
signal = "oversold"
|
||||
strength = "strong"
|
||||
reason = f"24h跌幅{abs(change):.1f}%,可能超卖反弹"
|
||||
impact = "bullish"
|
||||
elif change < -8:
|
||||
signal = "bearish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"24h跌幅{abs(change):.1f}%,下跌趋势明显"
|
||||
impact = "bearish"
|
||||
|
||||
if signal:
|
||||
opportunities.append({
|
||||
"symbol": symbol,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change_24h": change,
|
||||
"change_7d": change_7d,
|
||||
"signal": signal,
|
||||
"strength": strength,
|
||||
"reason": reason,
|
||||
"impact": impact,
|
||||
"market": "crypto",
|
||||
"timestamp": int(time.time())
|
||||
})
|
||||
|
||||
# Sort by absolute change
|
||||
|
||||
# 1) Crypto
|
||||
_analyze_opportunities_crypto(opportunities)
|
||||
|
||||
# 2) US Stocks
|
||||
_analyze_opportunities_stocks(opportunities)
|
||||
|
||||
# 3) Forex
|
||||
_analyze_opportunities_forex(opportunities)
|
||||
|
||||
# Sort by absolute change descending
|
||||
opportunities.sort(key=lambda x: abs(x.get("change_24h", 0)), reverse=True)
|
||||
|
||||
_set_cached("trading_opportunities", opportunities, 60)
|
||||
|
||||
|
||||
_set_cached("trading_opportunities", opportunities, 3600)
|
||||
|
||||
return jsonify({"code": 1, "msg": "success", "data": opportunities})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"trading_opportunities failed: {e}", exc_info=True)
|
||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Interactive Brokers API Routes
|
||||
|
||||
Standalone API endpoints for US and Hong Kong stock trading.
|
||||
Standalone API endpoints for US stock trading.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
@@ -235,7 +235,7 @@ def place_order():
|
||||
"symbol": "AAPL", // Required, symbol code
|
||||
"side": "buy", // Required, buy or sell
|
||||
"quantity": 10, // Required, number of shares
|
||||
"marketType": "USStock", // Optional, USStock or HShare, default USStock
|
||||
"marketType": "USStock", // Optional, default USStock
|
||||
"orderType": "market", // Optional, market or limit, default market
|
||||
"price": 150.00 // Required for limit orders
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]:
|
||||
"publish_to_community": row.get("publish_to_community") if row.get("publish_to_community") is not None else 0,
|
||||
"pricing_type": row.get("pricing_type") or "free",
|
||||
"price": row.get("price") if row.get("price") is not None else 0,
|
||||
# VIP-free indicator flag (community publishing)
|
||||
"vip_free": 1 if (row.get("vip_free") or 0) else 0,
|
||||
# Local mode: encryption is not supported; keep field for frontend compatibility (always 0).
|
||||
"is_encrypted": 0,
|
||||
"preview_image": row.get("preview_image") or "",
|
||||
@@ -131,12 +133,17 @@ def get_indicators():
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Best-effort schema upgrade for VIP-free indicators
|
||||
try:
|
||||
cur.execute("ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free BOOLEAN DEFAULT FALSE")
|
||||
except Exception:
|
||||
pass
|
||||
# Get user's own indicators (both purchased and custom).
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id, user_id, is_buy, end_time, name, code, description,
|
||||
publish_to_community, pricing_type, price, is_encrypted, preview_image,
|
||||
publish_to_community, pricing_type, price, is_encrypted, preview_image, vip_free,
|
||||
createtime, updatetime, created_at, updated_at
|
||||
FROM qd_indicator_codes
|
||||
WHERE user_id = ?
|
||||
@@ -178,6 +185,7 @@ def save_indicator():
|
||||
description = (data.get("description") or "").strip()
|
||||
publish_to_community = 1 if data.get("publishToCommunity") or data.get("publish_to_community") else 0
|
||||
pricing_type = (data.get("pricingType") or data.get("pricing_type") or "free").strip() or "free"
|
||||
vip_free = 1 if (data.get("vipFree") or data.get("vip_free")) else 0
|
||||
try:
|
||||
price = float(data.get("price") or 0)
|
||||
except Exception:
|
||||
@@ -206,6 +214,11 @@ def save_indicator():
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Best-effort schema upgrade for VIP-free indicators
|
||||
try:
|
||||
cur.execute("ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free BOOLEAN DEFAULT FALSE")
|
||||
except Exception:
|
||||
pass
|
||||
if indicator_id and indicator_id > 0:
|
||||
# 检查是否从未发布改为发布,需要设置审核状态
|
||||
if publish_to_community:
|
||||
@@ -224,11 +237,12 @@ def save_indicator():
|
||||
UPDATE qd_indicator_codes
|
||||
SET name = ?, code = ?, description = ?,
|
||||
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
|
||||
vip_free = ?,
|
||||
review_status = ?, review_note = '', reviewed_at = NOW(), reviewed_by = ?,
|
||||
updatetime = ?, updated_at = NOW()
|
||||
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
|
||||
""",
|
||||
(name, code, description, publish_to_community, pricing_type, price, preview_image,
|
||||
(name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free,
|
||||
new_review_status, user_id if is_admin else None, now, indicator_id, user_id),
|
||||
)
|
||||
else:
|
||||
@@ -238,10 +252,11 @@ def save_indicator():
|
||||
UPDATE qd_indicator_codes
|
||||
SET name = ?, code = ?, description = ?,
|
||||
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
|
||||
vip_free = ?,
|
||||
updatetime = ?, updated_at = NOW()
|
||||
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
|
||||
""",
|
||||
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
|
||||
(name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, now, indicator_id, user_id),
|
||||
)
|
||||
else:
|
||||
# 取消发布,清除审核状态
|
||||
@@ -250,6 +265,7 @@ def save_indicator():
|
||||
UPDATE qd_indicator_codes
|
||||
SET name = ?, code = ?, description = ?,
|
||||
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
|
||||
vip_free = 0,
|
||||
review_status = NULL, review_note = '', reviewed_at = NULL, reviewed_by = NULL,
|
||||
updatetime = ?, updated_at = NOW()
|
||||
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
|
||||
@@ -265,11 +281,11 @@ def save_indicator():
|
||||
"""
|
||||
INSERT INTO qd_indicator_codes
|
||||
(user_id, is_buy, end_time, name, code, description,
|
||||
publish_to_community, pricing_type, price, preview_image, review_status,
|
||||
publish_to_community, pricing_type, price, preview_image, vip_free, review_status,
|
||||
createtime, updatetime, created_at, updated_at)
|
||||
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
""",
|
||||
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, review_status, now, now),
|
||||
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, review_status, now, now),
|
||||
)
|
||||
indicator_id = int(cur.lastrowid or 0)
|
||||
db.commit()
|
||||
|
||||
@@ -20,7 +20,7 @@ def get_kline():
|
||||
获取K线数据
|
||||
|
||||
参数:
|
||||
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
|
||||
market: 市场类型 (Crypto, USStock, Forex, Futures)
|
||||
symbol: 交易对/股票代码
|
||||
timeframe: 时间周期 (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W)
|
||||
limit: 数据条数 (默认300)
|
||||
|
||||
@@ -83,7 +83,7 @@ def get_public_config():
|
||||
@market_bp.route('/types', methods=['GET'])
|
||||
def get_market_types():
|
||||
"""Return supported market types for the add-watchlist modal."""
|
||||
desired_order = ['USStock', 'Crypto', 'Forex', 'Futures', 'HShare', 'AShare']
|
||||
desired_order = ['USStock', 'Crypto', 'Forex', 'Futures']
|
||||
order_rank = {v: i for i, v in enumerate(desired_order)}
|
||||
|
||||
def _normalize_item(x):
|
||||
@@ -495,22 +495,11 @@ def get_stock_name():
|
||||
stock_name = symbol # 默认使用代码
|
||||
|
||||
try:
|
||||
if market in ['USStock', 'AShare', 'HShare']:
|
||||
if market == 'USStock':
|
||||
# 对于股票,尝试获取基本信息
|
||||
import yfinance as yf
|
||||
|
||||
# 转换symbol格式
|
||||
if market == 'USStock':
|
||||
yf_symbol = symbol
|
||||
elif market == 'AShare':
|
||||
yf_symbol = symbol + '.SS' if symbol.startswith('6') else symbol + '.SZ'
|
||||
elif market == 'HShare':
|
||||
# 港股需要补齐4位数字并添加.HK
|
||||
hk_code = symbol.zfill(4)
|
||||
yf_symbol = hk_code + '.HK'
|
||||
else:
|
||||
yf_symbol = symbol
|
||||
|
||||
yf_symbol = symbol
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
info = ticker.info
|
||||
|
||||
|
||||
@@ -18,42 +18,19 @@ settings_bp = Blueprint('settings', __name__)
|
||||
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
|
||||
|
||||
# 配置项定义(分组)- 按功能模块划分,每个配置项包含描述
|
||||
# ---------------------------------------------------------------
|
||||
# 精简原则:
|
||||
# - 部署级配置(host/port/debug)不在 UI 暴露,用户通过 .env 或 docker-compose 设置
|
||||
# - 内部调优参数(超时/重试/tick间隔/向量维度等)使用默认值即可,不暴露给普通用户
|
||||
# - 只保留用户真正需要配置的功能开关和 API Key
|
||||
# ---------------------------------------------------------------
|
||||
CONFIG_SCHEMA = {
|
||||
# ==================== 1. 服务配置 ====================
|
||||
'server': {
|
||||
'title': 'Server Configuration',
|
||||
'icon': 'cloud-server',
|
||||
'order': 1,
|
||||
'items': [
|
||||
{
|
||||
'key': 'PYTHON_API_HOST',
|
||||
'label': 'Listen Address',
|
||||
'type': 'text',
|
||||
'default': '0.0.0.0',
|
||||
'description': 'Server listen address. 0.0.0.0 allows external access, 127.0.0.1 for local only'
|
||||
},
|
||||
{
|
||||
'key': 'PYTHON_API_PORT',
|
||||
'label': 'Port',
|
||||
'type': 'number',
|
||||
'default': '5000',
|
||||
'description': 'Server listen port, default 5000'
|
||||
},
|
||||
{
|
||||
'key': 'PYTHON_API_DEBUG',
|
||||
'label': 'Debug Mode',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Enable debug mode for development. Disable in production'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 2. 安全认证 ====================
|
||||
# ==================== 1. 安全认证 ====================
|
||||
'auth': {
|
||||
'title': 'Security & Authentication',
|
||||
'icon': 'lock',
|
||||
'order': 2,
|
||||
'order': 1,
|
||||
'items': [
|
||||
{
|
||||
'key': 'SECRET_KEY',
|
||||
@@ -86,11 +63,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 3. AI/LLM 配置 ====================
|
||||
# ==================== 2. AI/LLM 配置 ====================
|
||||
'ai': {
|
||||
'title': 'AI / LLM Configuration',
|
||||
'icon': 'robot',
|
||||
'order': 3,
|
||||
'order': 2,
|
||||
'items': [
|
||||
{
|
||||
'key': 'LLM_PROVIDER',
|
||||
@@ -235,13 +212,6 @@ CONFIG_SCHEMA = {
|
||||
'default': '0.7',
|
||||
'description': 'Model creativity (0-1). Lower = more deterministic'
|
||||
},
|
||||
{
|
||||
'key': 'OPENROUTER_TIMEOUT',
|
||||
'label': 'Request Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '300',
|
||||
'description': 'API request timeout in seconds'
|
||||
},
|
||||
{
|
||||
'key': 'AI_MODELS_JSON',
|
||||
'label': 'Custom Models (JSON)',
|
||||
@@ -253,33 +223,19 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 4. 实盘交易 ====================
|
||||
# ==================== 3. 实盘交易 ====================
|
||||
'trading': {
|
||||
'title': 'Live Trading',
|
||||
'icon': 'stock',
|
||||
'order': 4,
|
||||
'order': 3,
|
||||
'items': [
|
||||
{
|
||||
'key': 'ENABLE_PENDING_ORDER_WORKER',
|
||||
'label': 'Enable Order Worker',
|
||||
'type': 'boolean',
|
||||
'default': 'True',
|
||||
'description': 'Enable background order processing worker for live trading'
|
||||
},
|
||||
{
|
||||
'key': 'PENDING_ORDER_STALE_SEC',
|
||||
'label': 'Order Stale Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '90',
|
||||
'description': 'Mark pending order as stale after this many seconds'
|
||||
},
|
||||
{
|
||||
'key': 'ORDER_MODE',
|
||||
'label': 'Order Execution Mode',
|
||||
'type': 'select',
|
||||
'options': ['maker', 'market'],
|
||||
'default': 'maker',
|
||||
'description': 'maker: Limit order first (lower fees), market: Market order (instant fill)'
|
||||
'options': ['market', 'maker'],
|
||||
'default': 'market',
|
||||
'description': 'market: Market order (instant fill, recommended), maker: Limit order first (lower fees but may not fill)'
|
||||
},
|
||||
{
|
||||
'key': 'MAKER_WAIT_SEC',
|
||||
@@ -288,95 +244,30 @@ CONFIG_SCHEMA = {
|
||||
'default': '10',
|
||||
'description': 'Wait time for limit order fill before switching to market order'
|
||||
},
|
||||
{
|
||||
'key': 'MAKER_OFFSET_BPS',
|
||||
'label': 'Limit Order Offset (bps)',
|
||||
'type': 'number',
|
||||
'default': '2',
|
||||
'description': 'Price offset in basis points (1bps=0.01%). Buy: price*(1-offset), Sell: price*(1+offset)'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 5. 策略执行 ====================
|
||||
'strategy': {
|
||||
'title': 'Strategy Execution',
|
||||
'icon': 'fund',
|
||||
'order': 5,
|
||||
'items': [
|
||||
{
|
||||
'key': 'DISABLE_RESTORE_RUNNING_STRATEGIES',
|
||||
'label': 'Disable Auto Restore',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Disable automatic restore of running strategies on server restart'
|
||||
},
|
||||
{
|
||||
'key': 'STRATEGY_TICK_INTERVAL_SEC',
|
||||
'label': 'Tick Interval (sec)',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Strategy main loop tick interval in seconds'
|
||||
},
|
||||
{
|
||||
'key': 'PRICE_CACHE_TTL_SEC',
|
||||
'label': 'Price Cache TTL (sec)',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Time-to-live for cached price data in seconds'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 6. 数据源配置 ====================
|
||||
# ==================== 4. 数据源配置 ====================
|
||||
'data_source': {
|
||||
'title': 'Data Sources',
|
||||
'icon': 'database',
|
||||
'order': 6,
|
||||
'order': 4,
|
||||
'items': [
|
||||
{
|
||||
'key': 'DATA_SOURCE_TIMEOUT',
|
||||
'label': 'Default Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'Default timeout for all data source requests'
|
||||
},
|
||||
{
|
||||
'key': 'DATA_SOURCE_RETRY',
|
||||
'label': 'Retry Count',
|
||||
'type': 'number',
|
||||
'default': '3',
|
||||
'description': 'Number of retry attempts on data source failure'
|
||||
},
|
||||
{
|
||||
'key': 'DATA_SOURCE_RETRY_BACKOFF',
|
||||
'label': 'Retry Backoff (sec)',
|
||||
'type': 'number',
|
||||
'default': '0.5',
|
||||
'description': 'Backoff time between retry attempts'
|
||||
},
|
||||
{
|
||||
'key': 'CCXT_DEFAULT_EXCHANGE',
|
||||
'label': 'CCXT Default Exchange',
|
||||
'label': 'Default Crypto Exchange',
|
||||
'type': 'text',
|
||||
'default': 'coinbase',
|
||||
'link': 'https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets',
|
||||
'link_text': 'settings.link.supportedExchanges',
|
||||
'description': 'Default exchange for CCXT crypto data (binance, coinbase, okx, etc.)'
|
||||
},
|
||||
{
|
||||
'key': 'CCXT_TIMEOUT',
|
||||
'label': 'CCXT Timeout (ms)',
|
||||
'type': 'number',
|
||||
'default': '10000',
|
||||
'description': 'CCXT request timeout in milliseconds'
|
||||
'description': 'Default exchange for crypto data (binance, coinbase, okx, etc.)'
|
||||
},
|
||||
{
|
||||
'key': 'CCXT_PROXY',
|
||||
'label': 'CCXT Proxy',
|
||||
'label': 'Crypto Data Proxy',
|
||||
'type': 'text',
|
||||
'required': False,
|
||||
'description': 'Proxy URL for CCXT requests (e.g. socks5h://127.0.0.1:1080)'
|
||||
'description': 'Proxy URL for crypto data requests (e.g. socks5h://127.0.0.1:1080)'
|
||||
},
|
||||
{
|
||||
'key': 'FINNHUB_API_KEY',
|
||||
@@ -387,20 +278,6 @@ CONFIG_SCHEMA = {
|
||||
'link_text': 'settings.link.freeRegister',
|
||||
'description': 'Finnhub API key for US stock data (free tier available)'
|
||||
},
|
||||
{
|
||||
'key': 'FINNHUB_TIMEOUT',
|
||||
'label': 'Finnhub Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Finnhub API request timeout'
|
||||
},
|
||||
{
|
||||
'key': 'FINNHUB_RATE_LIMIT',
|
||||
'label': 'Finnhub Rate Limit',
|
||||
'type': 'number',
|
||||
'default': '60',
|
||||
'description': 'Finnhub API rate limit (requests per minute)'
|
||||
},
|
||||
{
|
||||
'key': 'TIINGO_API_KEY',
|
||||
'label': 'Tiingo API Key',
|
||||
@@ -408,37 +285,16 @@ CONFIG_SCHEMA = {
|
||||
'required': False,
|
||||
'link': 'https://www.tiingo.com/account/api/token',
|
||||
'link_text': 'settings.link.getToken',
|
||||
'description': 'Tiingo API key for Forex/Metals data (free tier does not support 1-minute data)'
|
||||
},
|
||||
{
|
||||
'key': 'TIINGO_TIMEOUT',
|
||||
'label': 'Tiingo Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Tiingo API request timeout'
|
||||
},
|
||||
{
|
||||
'key': 'AKSHARE_TIMEOUT',
|
||||
'label': 'Akshare Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'Akshare API timeout for China A-share data'
|
||||
},
|
||||
{
|
||||
'key': 'YFINANCE_TIMEOUT',
|
||||
'label': 'YFinance Timeout (sec)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'Yahoo Finance API timeout'
|
||||
'description': 'Tiingo API key for Forex/Metals data'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 7. 邮件配置 (公共 SMTP) ====================
|
||||
# ==================== 5. 邮件配置 ====================
|
||||
'email': {
|
||||
'title': 'Email (SMTP)',
|
||||
'icon': 'mail',
|
||||
'order': 7,
|
||||
'order': 5,
|
||||
'items': [
|
||||
{
|
||||
'key': 'SMTP_HOST',
|
||||
@@ -452,7 +308,7 @@ CONFIG_SCHEMA = {
|
||||
'label': 'SMTP Port',
|
||||
'type': 'number',
|
||||
'default': '587',
|
||||
'description': 'SMTP port (587 for TLS, 465 for SSL, 25 for plain)'
|
||||
'description': 'SMTP port (587 for TLS, 465 for SSL)'
|
||||
},
|
||||
{
|
||||
'key': 'SMTP_USER',
|
||||
@@ -492,11 +348,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 9. 短信配置 ====================
|
||||
# ==================== 6. 短信配置 ====================
|
||||
'sms': {
|
||||
'title': 'SMS (Twilio)',
|
||||
'icon': 'phone',
|
||||
'order': 8,
|
||||
'order': 6,
|
||||
'items': [
|
||||
{
|
||||
'key': 'TWILIO_ACCOUNT_SID',
|
||||
@@ -524,11 +380,11 @@ CONFIG_SCHEMA = {
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 9. AI Agent 配置 ====================
|
||||
# ==================== 7. AI Agent ====================
|
||||
'agent': {
|
||||
'title': 'AI Agent',
|
||||
'icon': 'experiment',
|
||||
'order': 9,
|
||||
'order': 7,
|
||||
'items': [
|
||||
{
|
||||
'key': 'ENABLE_AGENT_MEMORY',
|
||||
@@ -537,62 +393,6 @@ CONFIG_SCHEMA = {
|
||||
'default': 'True',
|
||||
'description': 'Enable AI agent memory for learning from past trades'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_ENABLE_VECTOR',
|
||||
'label': 'Enable Vector Search',
|
||||
'type': 'boolean',
|
||||
'default': 'True',
|
||||
'description': 'Enable local vector similarity search for memory retrieval'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_EMBEDDING_DIM',
|
||||
'label': 'Embedding Dimension',
|
||||
'type': 'number',
|
||||
'default': '256',
|
||||
'description': 'Vector embedding dimension for memory storage'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_TOP_K',
|
||||
'label': 'Retrieval Top-K',
|
||||
'type': 'number',
|
||||
'default': '5',
|
||||
'description': 'Number of similar memories to retrieve'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_CANDIDATE_LIMIT',
|
||||
'label': 'Candidate Limit',
|
||||
'type': 'number',
|
||||
'default': '500',
|
||||
'description': 'Maximum candidates for similarity search'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_HALF_LIFE_DAYS',
|
||||
'label': 'Recency Half-life (days)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'Time decay half-life for memory recency scoring'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_W_SIM',
|
||||
'label': 'Similarity Weight',
|
||||
'type': 'number',
|
||||
'default': '0.75',
|
||||
'description': 'Weight for similarity score in memory ranking (0-1)'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_W_RECENCY',
|
||||
'label': 'Recency Weight',
|
||||
'type': 'number',
|
||||
'default': '0.20',
|
||||
'description': 'Weight for recency score in memory ranking (0-1)'
|
||||
},
|
||||
{
|
||||
'key': 'AGENT_MEMORY_W_RETURNS',
|
||||
'label': 'Returns Weight',
|
||||
'type': 'number',
|
||||
'default': '0.05',
|
||||
'description': 'Weight for returns score in memory ranking (0-1)'
|
||||
},
|
||||
{
|
||||
'key': 'ENABLE_REFLECTION_WORKER',
|
||||
'label': 'Enable Auto Reflection',
|
||||
@@ -600,59 +400,30 @@ CONFIG_SCHEMA = {
|
||||
'default': 'False',
|
||||
'description': 'Enable background worker for automatic trade reflection'
|
||||
},
|
||||
{
|
||||
'key': 'REFLECTION_WORKER_INTERVAL_SEC',
|
||||
'label': 'Reflection Interval (sec)',
|
||||
'type': 'number',
|
||||
'default': '86400',
|
||||
'description': 'Interval between automatic reflection runs (default: 24h)'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 10. 网络代理 ====================
|
||||
# ==================== 8. 网络代理 ====================
|
||||
'network': {
|
||||
'title': 'Network & Proxy',
|
||||
'icon': 'global',
|
||||
'order': 10,
|
||||
'order': 8,
|
||||
'items': [
|
||||
{
|
||||
'key': 'PROXY_HOST',
|
||||
'label': 'Proxy Host',
|
||||
'type': 'text',
|
||||
'default': '127.0.0.1',
|
||||
'description': 'Proxy server hostname or IP'
|
||||
},
|
||||
{
|
||||
'key': 'PROXY_PORT',
|
||||
'label': 'Proxy Port',
|
||||
'type': 'text',
|
||||
'required': False,
|
||||
'description': 'Proxy server port (leave empty to disable proxy)'
|
||||
},
|
||||
{
|
||||
'key': 'PROXY_SCHEME',
|
||||
'label': 'Proxy Protocol',
|
||||
'type': 'select',
|
||||
'options': ['socks5h', 'socks5', 'http', 'https'],
|
||||
'default': 'socks5h',
|
||||
'description': 'Proxy protocol type. socks5h: SOCKS5 with DNS resolution'
|
||||
},
|
||||
{
|
||||
'key': 'PROXY_URL',
|
||||
'label': 'Full Proxy URL',
|
||||
'label': 'Proxy URL',
|
||||
'type': 'text',
|
||||
'required': False,
|
||||
'description': 'Complete proxy URL (overrides above settings if set)'
|
||||
'description': 'Global proxy URL (e.g. socks5h://127.0.0.1:1080 or http://proxy:8080)'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 11. 搜索配置 ====================
|
||||
# ==================== 9. 搜索配置 ====================
|
||||
'search': {
|
||||
'title': 'Web Search',
|
||||
'icon': 'search',
|
||||
'order': 11,
|
||||
'order': 9,
|
||||
'items': [
|
||||
{
|
||||
'key': 'SEARCH_PROVIDER',
|
||||
@@ -660,16 +431,8 @@ CONFIG_SCHEMA = {
|
||||
'type': 'select',
|
||||
'options': ['bocha', 'tavily', 'google', 'bing', 'none'],
|
||||
'default': 'bocha',
|
||||
'description': 'Web search provider for AI research features. Bocha recommended for A-share news'
|
||||
'description': 'Web search provider for AI research features'
|
||||
},
|
||||
{
|
||||
'key': 'SEARCH_MAX_RESULTS',
|
||||
'label': 'Max Results',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Maximum search results to return'
|
||||
},
|
||||
# Tavily Search API
|
||||
{
|
||||
'key': 'TAVILY_API_KEYS',
|
||||
'label': 'Tavily API Keys',
|
||||
@@ -677,9 +440,8 @@ CONFIG_SCHEMA = {
|
||||
'required': False,
|
||||
'link': 'https://tavily.com/',
|
||||
'link_text': 'settings.link.getApiKey',
|
||||
'description': 'Tavily Search API keys, comma-separated for rotation. Free 1000 requests/month'
|
||||
'description': 'Tavily Search API keys (comma-separated). Free 1000 req/month'
|
||||
},
|
||||
# Bocha Search API
|
||||
{
|
||||
'key': 'BOCHA_API_KEYS',
|
||||
'label': 'Bocha API Keys',
|
||||
@@ -687,60 +449,16 @@ CONFIG_SCHEMA = {
|
||||
'required': False,
|
||||
'link': 'https://bochaai.com/',
|
||||
'link_text': 'settings.link.getApiKey',
|
||||
'description': 'Bocha Search API keys, comma-separated for rotation. Best for A-share news'
|
||||
},
|
||||
# SerpAPI
|
||||
{
|
||||
'key': 'SERPAPI_KEYS',
|
||||
'label': 'SerpAPI Keys',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://serpapi.com/',
|
||||
'link_text': 'settings.link.getApiKey',
|
||||
'description': 'SerpAPI keys for Google/Bing search, comma-separated for rotation'
|
||||
},
|
||||
{
|
||||
'key': 'SEARCH_GOOGLE_API_KEY',
|
||||
'label': 'Google API Key',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://developers.google.com/custom-search/v1/introduction',
|
||||
'link_text': 'settings.link.applyApi',
|
||||
'description': 'Google Custom Search JSON API key'
|
||||
},
|
||||
{
|
||||
'key': 'SEARCH_GOOGLE_CX',
|
||||
'label': 'Google Search Engine ID',
|
||||
'type': 'text',
|
||||
'required': False,
|
||||
'link': 'https://programmablesearchengine.google.com/controlpanel/all',
|
||||
'link_text': 'settings.link.createSearchEngine',
|
||||
'description': 'Google Programmable Search Engine ID (CX)'
|
||||
},
|
||||
{
|
||||
'key': 'SEARCH_BING_API_KEY',
|
||||
'label': 'Bing API Key',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://www.microsoft.com/en-us/bing/apis/bing-web-search-api',
|
||||
'link_text': 'settings.link.applyApi',
|
||||
'description': 'Microsoft Bing Web Search API key'
|
||||
},
|
||||
{
|
||||
'key': 'INTERNAL_API_KEY',
|
||||
'label': 'Internal API Key',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'description': 'Internal API authentication key for service-to-service calls'
|
||||
'description': 'Bocha Search API keys (comma-separated)'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 12. 注册与安全 ====================
|
||||
# ==================== 10. 注册与 OAuth ====================
|
||||
'security': {
|
||||
'title': 'Registration & Security',
|
||||
'title': 'Registration & OAuth',
|
||||
'icon': 'safety',
|
||||
'order': 12,
|
||||
'order': 10,
|
||||
'items': [
|
||||
{
|
||||
'key': 'ENABLE_REGISTRATION',
|
||||
@@ -749,6 +467,13 @@ CONFIG_SCHEMA = {
|
||||
'default': 'True',
|
||||
'description': 'Allow new users to register accounts'
|
||||
},
|
||||
{
|
||||
'key': 'FRONTEND_URL',
|
||||
'label': 'Frontend URL',
|
||||
'type': 'text',
|
||||
'default': 'http://localhost:8080',
|
||||
'description': 'Frontend URL for OAuth redirects'
|
||||
},
|
||||
{
|
||||
'key': 'TURNSTILE_SITE_KEY',
|
||||
'label': 'Turnstile Site Key',
|
||||
@@ -756,7 +481,7 @@ CONFIG_SCHEMA = {
|
||||
'required': False,
|
||||
'link': 'https://dash.cloudflare.com/?to=/:account/turnstile',
|
||||
'link_text': 'settings.link.getTurnstileKey',
|
||||
'description': 'Cloudflare Turnstile site key for CAPTCHA verification'
|
||||
'description': 'Cloudflare Turnstile site key for CAPTCHA'
|
||||
},
|
||||
{
|
||||
'key': 'TURNSTILE_SECRET_KEY',
|
||||
@@ -765,193 +490,198 @@ CONFIG_SCHEMA = {
|
||||
'required': False,
|
||||
'description': 'Cloudflare Turnstile secret key'
|
||||
},
|
||||
{
|
||||
'key': 'FRONTEND_URL',
|
||||
'label': 'Frontend URL',
|
||||
'type': 'text',
|
||||
'default': 'http://localhost:8080',
|
||||
'description': 'Frontend URL for OAuth redirects'
|
||||
},
|
||||
{
|
||||
'key': 'GOOGLE_CLIENT_ID',
|
||||
'label': 'Google Client ID',
|
||||
'label': 'Google OAuth Client ID',
|
||||
'type': 'text',
|
||||
'required': False,
|
||||
'link': 'https://console.cloud.google.com/apis/credentials',
|
||||
'link_text': 'settings.link.getGoogleCredentials',
|
||||
'description': 'Google OAuth Client ID'
|
||||
'description': 'Google OAuth Client ID for Google login'
|
||||
},
|
||||
{
|
||||
'key': 'GOOGLE_CLIENT_SECRET',
|
||||
'label': 'Google Client Secret',
|
||||
'label': 'Google OAuth Secret',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'description': 'Google OAuth Client Secret'
|
||||
},
|
||||
{
|
||||
'key': 'GOOGLE_REDIRECT_URI',
|
||||
'label': 'Google Redirect URI',
|
||||
'type': 'text',
|
||||
'default': 'http://localhost:5000/api/auth/oauth/google/callback',
|
||||
'description': 'Google OAuth callback URL'
|
||||
},
|
||||
{
|
||||
'key': 'GITHUB_CLIENT_ID',
|
||||
'label': 'GitHub Client ID',
|
||||
'label': 'GitHub OAuth Client ID',
|
||||
'type': 'text',
|
||||
'required': False,
|
||||
'link': 'https://github.com/settings/developers',
|
||||
'link_text': 'settings.link.getGithubCredentials',
|
||||
'description': 'GitHub OAuth Client ID'
|
||||
'description': 'GitHub OAuth Client ID for GitHub login'
|
||||
},
|
||||
{
|
||||
'key': 'GITHUB_CLIENT_SECRET',
|
||||
'label': 'GitHub Client Secret',
|
||||
'label': 'GitHub OAuth Secret',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'description': 'GitHub OAuth Client Secret'
|
||||
},
|
||||
{
|
||||
'key': 'GITHUB_REDIRECT_URI',
|
||||
'label': 'GitHub Redirect URI',
|
||||
'type': 'text',
|
||||
'default': 'http://localhost:5000/api/auth/oauth/github/callback',
|
||||
'description': 'GitHub OAuth callback URL'
|
||||
},
|
||||
{
|
||||
'key': 'SECURITY_IP_MAX_ATTEMPTS',
|
||||
'label': 'IP Max Failed Attempts',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Block IP after this many failed login attempts'
|
||||
},
|
||||
{
|
||||
'key': 'SECURITY_IP_WINDOW_MINUTES',
|
||||
'label': 'IP Window (minutes)',
|
||||
'type': 'number',
|
||||
'default': '5',
|
||||
'description': 'Time window for counting IP failed attempts'
|
||||
},
|
||||
{
|
||||
'key': 'SECURITY_IP_BLOCK_MINUTES',
|
||||
'label': 'IP Block Duration (minutes)',
|
||||
'type': 'number',
|
||||
'default': '15',
|
||||
'description': 'How long to block IP after exceeding limit'
|
||||
},
|
||||
{
|
||||
'key': 'SECURITY_ACCOUNT_MAX_ATTEMPTS',
|
||||
'label': 'Account Max Failed Attempts',
|
||||
'type': 'number',
|
||||
'default': '5',
|
||||
'description': 'Lock account after this many failed login attempts'
|
||||
},
|
||||
{
|
||||
'key': 'SECURITY_ACCOUNT_WINDOW_MINUTES',
|
||||
'label': 'Account Window (minutes)',
|
||||
'type': 'number',
|
||||
'default': '60',
|
||||
'description': 'Time window for counting account failed attempts'
|
||||
},
|
||||
{
|
||||
'key': 'SECURITY_ACCOUNT_BLOCK_MINUTES',
|
||||
'label': 'Account Block Duration (minutes)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'How long to lock account after exceeding limit'
|
||||
},
|
||||
{
|
||||
'key': 'VERIFICATION_CODE_EXPIRE_MINUTES',
|
||||
'label': 'Verification Code Expiry (minutes)',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Email verification code validity period'
|
||||
},
|
||||
{
|
||||
'key': 'VERIFICATION_CODE_RATE_LIMIT',
|
||||
'label': 'Code Rate Limit (seconds)',
|
||||
'type': 'number',
|
||||
'default': '60',
|
||||
'description': 'Minimum time between verification code requests per email'
|
||||
},
|
||||
{
|
||||
'key': 'VERIFICATION_CODE_IP_HOURLY_LIMIT',
|
||||
'label': 'Code Hourly Limit per IP',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Maximum verification codes per IP per hour'
|
||||
},
|
||||
{
|
||||
'key': 'VERIFICATION_CODE_MAX_ATTEMPTS',
|
||||
'label': 'Code Max Attempts',
|
||||
'type': 'number',
|
||||
'default': '5',
|
||||
'description': 'Maximum attempts to verify a code before lockout'
|
||||
},
|
||||
{
|
||||
'key': 'VERIFICATION_CODE_LOCK_MINUTES',
|
||||
'label': 'Code Lock Minutes',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'Lockout duration after exceeding max attempts'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 13. 计费配置 ====================
|
||||
# ==================== 11. 计费配置 ====================
|
||||
'billing': {
|
||||
'title': 'Billing & Credits',
|
||||
'icon': 'dollar',
|
||||
'order': 13,
|
||||
'order': 11,
|
||||
'items': [
|
||||
{
|
||||
'key': 'BILLING_ENABLED',
|
||||
'label': 'Enable Billing',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Enable billing system. When enabled, users need credits to use certain features'
|
||||
'description': 'Enable billing system. Users need credits to use certain features'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_VIP_BYPASS',
|
||||
'label': 'VIP Free',
|
||||
'label': 'VIP Bypass (Legacy)',
|
||||
'type': 'boolean',
|
||||
'default': 'True',
|
||||
'description': 'VIP users can use all paid features for free during VIP period'
|
||||
'default': 'False',
|
||||
'description': 'Legacy switch. If enabled, VIP users bypass ALL feature credit costs. Recommended OFF: VIP should only unlock VIP-free indicators.'
|
||||
},
|
||||
|
||||
# ===== Membership Plans (3 tiers) =====
|
||||
{
|
||||
'key': 'MEMBERSHIP_MONTHLY_PRICE_USD',
|
||||
'label': 'Monthly Membership Price (USD)',
|
||||
'type': 'number',
|
||||
'default': '19.9',
|
||||
'description': 'Monthly membership price in USD (mock payment in current version)'
|
||||
},
|
||||
{
|
||||
'key': 'MEMBERSHIP_MONTHLY_CREDITS',
|
||||
'label': 'Monthly Membership Bonus Credits',
|
||||
'type': 'number',
|
||||
'default': '500',
|
||||
'description': 'Credits granted immediately after purchasing monthly membership'
|
||||
},
|
||||
{
|
||||
'key': 'MEMBERSHIP_YEARLY_PRICE_USD',
|
||||
'label': 'Yearly Membership Price (USD)',
|
||||
'type': 'number',
|
||||
'default': '199',
|
||||
'description': 'Yearly membership price in USD (mock payment in current version)'
|
||||
},
|
||||
{
|
||||
'key': 'MEMBERSHIP_YEARLY_CREDITS',
|
||||
'label': 'Yearly Membership Bonus Credits',
|
||||
'type': 'number',
|
||||
'default': '8000',
|
||||
'description': 'Credits granted immediately after purchasing yearly membership'
|
||||
},
|
||||
{
|
||||
'key': 'MEMBERSHIP_LIFETIME_PRICE_USD',
|
||||
'label': 'Lifetime Membership Price (USD)',
|
||||
'type': 'number',
|
||||
'default': '499',
|
||||
'description': 'Lifetime membership price in USD (mock payment in current version)'
|
||||
},
|
||||
{
|
||||
'key': 'MEMBERSHIP_LIFETIME_MONTHLY_CREDITS',
|
||||
'label': 'Lifetime Membership Monthly Credits',
|
||||
'type': 'number',
|
||||
'default': '800',
|
||||
'description': 'Credits granted every 30 days for lifetime members'
|
||||
},
|
||||
|
||||
# ===== USDT Pay (方案B:每单独立地址) =====
|
||||
{
|
||||
'key': 'USDT_PAY_ENABLED',
|
||||
'label': 'Enable USDT Pay',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Enable USDT scan-to-pay flow (per-order unique address)'
|
||||
},
|
||||
{
|
||||
'key': 'USDT_PAY_CHAIN',
|
||||
'label': 'USDT Chain',
|
||||
'type': 'select',
|
||||
'default': 'TRC20',
|
||||
'options': ['TRC20'],
|
||||
'description': 'Currently only TRC20 is supported'
|
||||
},
|
||||
{
|
||||
'key': 'USDT_TRC20_XPUB',
|
||||
'label': 'TRC20 XPUB (Watch-only)',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'description': 'Watch-only xpub used to derive per-order deposit addresses. Do NOT paste private key.'
|
||||
},
|
||||
{
|
||||
'key': 'USDT_TRC20_CONTRACT',
|
||||
'label': 'USDT TRC20 Contract',
|
||||
'type': 'text',
|
||||
'default': 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj',
|
||||
'description': 'USDT contract address on TRON'
|
||||
},
|
||||
{
|
||||
'key': 'TRONGRID_BASE_URL',
|
||||
'label': 'TronGrid Base URL',
|
||||
'type': 'text',
|
||||
'default': 'https://api.trongrid.io',
|
||||
'description': 'TronGrid API base URL'
|
||||
},
|
||||
{
|
||||
'key': 'TRONGRID_API_KEY',
|
||||
'label': 'TronGrid API Key',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'description': 'Optional TronGrid API key for higher rate limits'
|
||||
},
|
||||
{
|
||||
'key': 'USDT_PAY_CONFIRM_SECONDS',
|
||||
'label': 'Confirm Delay (sec)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'Delay before marking a paid transaction as confirmed (TRC20)'
|
||||
},
|
||||
{
|
||||
'key': 'USDT_PAY_EXPIRE_MINUTES',
|
||||
'label': 'Order Expire (min)',
|
||||
'type': 'number',
|
||||
'default': '30',
|
||||
'description': 'USDT payment order expiration time in minutes'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_AI_ANALYSIS',
|
||||
'label': 'AI Analysis Cost',
|
||||
'type': 'number',
|
||||
'default': '10',
|
||||
'description': 'Credits consumed per AI analysis request'
|
||||
'description': 'Credits per AI analysis request'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_STRATEGY_RUN',
|
||||
'label': 'Strategy Run Cost',
|
||||
'type': 'number',
|
||||
'default': '5',
|
||||
'description': 'Credits consumed when starting a strategy'
|
||||
'description': 'Credits per strategy start'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_BACKTEST',
|
||||
'label': 'Backtest Cost',
|
||||
'type': 'number',
|
||||
'default': '3',
|
||||
'description': 'Credits consumed per backtest run'
|
||||
'description': 'Credits per backtest run'
|
||||
},
|
||||
{
|
||||
'key': 'BILLING_COST_PORTFOLIO_MONITOR',
|
||||
'label': 'Portfolio Monitor Cost',
|
||||
'type': 'number',
|
||||
'default': '8',
|
||||
'description': 'Credits consumed per portfolio AI monitoring run'
|
||||
'description': 'Credits per portfolio AI monitoring run'
|
||||
},
|
||||
{
|
||||
'key': 'RECHARGE_TELEGRAM_URL',
|
||||
'label': 'Recharge Telegram URL',
|
||||
'type': 'text',
|
||||
'default': 'https://t.me/your_support_bot',
|
||||
'description': 'Telegram customer service URL for recharge inquiries'
|
||||
'description': 'Telegram URL for recharge inquiries'
|
||||
},
|
||||
{
|
||||
'key': 'CREDITS_REGISTER_BONUS',
|
||||
@@ -965,44 +695,23 @@ CONFIG_SCHEMA = {
|
||||
'label': 'Referral Bonus',
|
||||
'type': 'number',
|
||||
'default': '50',
|
||||
'description': 'Credits awarded to referrer when someone signs up with their code'
|
||||
'description': 'Credits awarded to referrer for each signup'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
# ==================== 14. 应用配置 ====================
|
||||
# ==================== 12. 应用功能 ====================
|
||||
'app': {
|
||||
'title': 'Application',
|
||||
'icon': 'appstore',
|
||||
'order': 14,
|
||||
'order': 12,
|
||||
'items': [
|
||||
{
|
||||
'key': 'CORS_ORIGINS',
|
||||
'label': 'CORS Origins',
|
||||
'type': 'text',
|
||||
'default': '*',
|
||||
'description': 'Allowed CORS origins (* for all, or comma-separated list)'
|
||||
},
|
||||
{
|
||||
'key': 'RATE_LIMIT',
|
||||
'label': 'Rate Limit (req/min)',
|
||||
'type': 'number',
|
||||
'default': '100',
|
||||
'description': 'API rate limit per IP per minute'
|
||||
},
|
||||
{
|
||||
'key': 'ENABLE_CACHE',
|
||||
'label': 'Enable Cache',
|
||||
'type': 'boolean',
|
||||
'default': 'False',
|
||||
'description': 'Enable response caching for improved performance'
|
||||
},
|
||||
{
|
||||
'key': 'ENABLE_REQUEST_LOG',
|
||||
'label': 'Enable Request Log',
|
||||
'type': 'boolean',
|
||||
'default': 'True',
|
||||
'description': 'Log all API requests for debugging'
|
||||
'description': 'Allowed CORS origins (* for all, or comma-separated URLs)'
|
||||
},
|
||||
{
|
||||
'key': 'ENABLE_AI_ANALYSIS',
|
||||
|
||||
@@ -985,11 +985,61 @@ def get_system_strategies():
|
||||
'updated_at': updated_at
|
||||
})
|
||||
|
||||
# Compute summary stats
|
||||
all_running = [i for i in items if i['status'] == 'running']
|
||||
total_capital = sum(i['initial_capital'] for i in items)
|
||||
total_system_pnl = sum(i['total_pnl'] for i in items)
|
||||
total_running = len(all_running)
|
||||
# Compute summary stats from all matched strategies (not just current page items).
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# Aggregate strategy counts/capital by execution mode and running status.
|
||||
agg_sql = f"""
|
||||
SELECT
|
||||
COUNT(*) AS total_strategies,
|
||||
COALESCE(SUM(s.initial_capital), 0) AS total_capital,
|
||||
COALESCE(SUM(CASE WHEN s.status = 'running' THEN 1 ELSE 0 END), 0) AS running_strategies,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN 1 ELSE 0 END), 0) AS live_strategies,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN 1 ELSE 0 END), 0) AS signal_strategies,
|
||||
COALESCE(SUM(CASE WHEN s.status = 'running' AND s.execution_mode = 'live' THEN 1 ELSE 0 END), 0) AS running_live_strategies,
|
||||
COALESCE(SUM(CASE WHEN s.status = 'running' AND s.execution_mode = 'signal' THEN 1 ELSE 0 END), 0) AS running_signal_strategies,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN s.initial_capital ELSE 0 END), 0) AS live_capital,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN s.initial_capital ELSE 0 END), 0) AS signal_capital
|
||||
FROM qd_strategies_trading s
|
||||
LEFT JOIN qd_users u ON u.id = s.user_id
|
||||
{where_clause}
|
||||
"""
|
||||
cur.execute(agg_sql, tuple(params))
|
||||
agg_row = cur.fetchone() or {}
|
||||
|
||||
# Aggregate unrealized pnl from current positions.
|
||||
unreal_sql = f"""
|
||||
SELECT COALESCE(SUM(p.unrealized_pnl), 0) AS total_unrealized,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN p.unrealized_pnl ELSE 0 END), 0) AS live_unrealized,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN p.unrealized_pnl ELSE 0 END), 0) AS signal_unrealized
|
||||
FROM qd_strategy_positions p
|
||||
JOIN qd_strategies_trading s ON s.id = p.strategy_id
|
||||
LEFT JOIN qd_users u ON u.id = s.user_id
|
||||
{where_clause}
|
||||
"""
|
||||
cur.execute(unreal_sql, tuple(params))
|
||||
unreal_row = cur.fetchone() or {}
|
||||
|
||||
# Aggregate realized pnl from trade history.
|
||||
realized_sql = f"""
|
||||
SELECT COALESCE(SUM(t.profit), 0) AS total_realized,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN t.profit ELSE 0 END), 0) AS live_realized,
|
||||
COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN t.profit ELSE 0 END), 0) AS signal_realized
|
||||
FROM qd_strategy_trades t
|
||||
JOIN qd_strategies_trading s ON s.id = t.strategy_id
|
||||
LEFT JOIN qd_users u ON u.id = s.user_id
|
||||
{where_clause}
|
||||
"""
|
||||
cur.execute(realized_sql, tuple(params))
|
||||
realized_row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
|
||||
total_capital = float(agg_row.get('total_capital') or 0)
|
||||
total_running = int(agg_row.get('running_strategies') or 0)
|
||||
total_system_pnl = float(unreal_row.get('total_unrealized') or 0) + float(realized_row.get('total_realized') or 0)
|
||||
live_pnl = float(unreal_row.get('live_unrealized') or 0) + float(realized_row.get('live_realized') or 0)
|
||||
signal_pnl = float(unreal_row.get('signal_unrealized') or 0) + float(realized_row.get('signal_realized') or 0)
|
||||
|
||||
return jsonify({
|
||||
'code': 1,
|
||||
@@ -1000,11 +1050,19 @@ def get_system_strategies():
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'summary': {
|
||||
'total_strategies': total,
|
||||
'total_strategies': int(agg_row.get('total_strategies') or total),
|
||||
'running_strategies': total_running,
|
||||
'total_capital': round(total_capital, 2),
|
||||
'total_pnl': round(total_system_pnl, 4),
|
||||
'total_roi': round((total_system_pnl / total_capital * 100) if total_capital > 0 else 0, 2)
|
||||
'total_roi': round((total_system_pnl / total_capital * 100) if total_capital > 0 else 0, 2),
|
||||
'live_strategies': int(agg_row.get('live_strategies') or 0),
|
||||
'signal_strategies': int(agg_row.get('signal_strategies') or 0),
|
||||
'running_live_strategies': int(agg_row.get('running_live_strategies') or 0),
|
||||
'running_signal_strategies': int(agg_row.get('running_signal_strategies') or 0),
|
||||
'live_capital': round(float(agg_row.get('live_capital') or 0), 2),
|
||||
'signal_capital': round(float(agg_row.get('signal_capital') or 0), 2),
|
||||
'live_pnl': round(live_pnl, 4),
|
||||
'signal_pnl': round(signal_pnl, 4)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ Billing Service - 统一计费服务
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
@@ -27,7 +27,8 @@ BILLING_CONFIG_PREFIX = 'BILLING_'
|
||||
DEFAULT_BILLING_CONFIG = {
|
||||
# 全局开关
|
||||
'enabled': False, # 是否启用计费
|
||||
'vip_bypass': True, # VIP用户是否免费
|
||||
# IMPORTANT: VIP 不再默认免扣积分(VIP 仅对“VIP免费指标”生效)
|
||||
'vip_bypass': False, # VIP用户是否免费(功能计费层面的旁路,默认关闭)
|
||||
|
||||
# 各功能积分消耗(0表示免费)
|
||||
'cost_ai_analysis': 10, # AI分析 每次消耗积分
|
||||
@@ -127,10 +128,15 @@ class BillingService:
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT vip_expires_at FROM qd_users WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
# Ensure lifetime membership monthly credits are granted (best-effort, silent on failure).
|
||||
self._ensure_membership_schema_best_effort(cur)
|
||||
self._grant_lifetime_monthly_credits_best_effort(cur, user_id)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute("SELECT vip_expires_at FROM qd_users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
@@ -152,6 +158,298 @@ class BillingService:
|
||||
except Exception as e:
|
||||
logger.error(f"get_user_vip_status failed: {e}")
|
||||
return False, None
|
||||
|
||||
# ==================== Membership Plans (VIP) ====================
|
||||
|
||||
def get_membership_plans(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get membership plans from .env (configured via Settings UI).
|
||||
|
||||
Plan keys:
|
||||
- monthly: price_usd, credits_once, duration_days
|
||||
- yearly: price_usd, credits_once, duration_days
|
||||
- lifetime: price_usd, credits_monthly (granted every 30 days)
|
||||
"""
|
||||
def _f(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(key, str(default)).strip())
|
||||
except Exception:
|
||||
return float(default)
|
||||
|
||||
def _i(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(float(os.getenv(key, str(default)).strip()))
|
||||
except Exception:
|
||||
return int(default)
|
||||
|
||||
return {
|
||||
"monthly": {
|
||||
"plan": "monthly",
|
||||
"price_usd": _f("MEMBERSHIP_MONTHLY_PRICE_USD", 19.9),
|
||||
"credits_once": _i("MEMBERSHIP_MONTHLY_CREDITS", 500),
|
||||
"duration_days": 30,
|
||||
},
|
||||
"yearly": {
|
||||
"plan": "yearly",
|
||||
"price_usd": _f("MEMBERSHIP_YEARLY_PRICE_USD", 199.0),
|
||||
"credits_once": _i("MEMBERSHIP_YEARLY_CREDITS", 8000),
|
||||
"duration_days": 365,
|
||||
},
|
||||
"lifetime": {
|
||||
"plan": "lifetime",
|
||||
"price_usd": _f("MEMBERSHIP_LIFETIME_PRICE_USD", 499.0),
|
||||
# Lifetime: monthly credits granted periodically
|
||||
"credits_monthly": _i("MEMBERSHIP_LIFETIME_MONTHLY_CREDITS", 800),
|
||||
},
|
||||
}
|
||||
|
||||
def purchase_membership(self, user_id: int, plan: str) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
"""
|
||||
Purchase membership plan (mock payment: immediately activates).
|
||||
|
||||
NOTE: Real payment gateway can be integrated later; this function is the single activation point.
|
||||
"""
|
||||
plan = (plan or "").strip().lower()
|
||||
plans = self.get_membership_plans()
|
||||
if plan not in plans:
|
||||
return False, "invalid_plan", {}
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
self._ensure_membership_schema_best_effort(cur)
|
||||
self._ensure_membership_orders_table_best_effort(cur)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Read current VIP expiry to support stacking for monthly/yearly.
|
||||
cur.execute("SELECT vip_expires_at FROM qd_users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone() or {}
|
||||
current_expires = row.get("vip_expires_at")
|
||||
if isinstance(current_expires, str) and current_expires:
|
||||
try:
|
||||
current_expires = datetime.fromisoformat(current_expires.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
current_expires = None
|
||||
if current_expires and current_expires.tzinfo is None:
|
||||
current_expires = current_expires.replace(tzinfo=timezone.utc)
|
||||
|
||||
base_time = current_expires if (current_expires and current_expires > now) else now
|
||||
|
||||
vip_expires_at = None
|
||||
vip_plan = plan
|
||||
vip_is_lifetime = False
|
||||
|
||||
if plan in ("monthly", "yearly"):
|
||||
days = int(plans[plan].get("duration_days") or (30 if plan == "monthly" else 365))
|
||||
vip_expires_at = base_time + timedelta(days=days)
|
||||
else:
|
||||
# Lifetime: set very long expiry + mark lifetime flag
|
||||
vip_expires_at = now + timedelta(days=365 * 100)
|
||||
vip_is_lifetime = True
|
||||
|
||||
# Create order record (mock paid)
|
||||
order_plan = plan
|
||||
order_price_usd = float(plans[plan].get("price_usd") or 0)
|
||||
order_id = None
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_membership_orders
|
||||
(user_id, plan, price_usd, status, created_at, paid_at)
|
||||
VALUES (?, ?, ?, 'paid', NOW(), NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(user_id, order_plan, order_price_usd),
|
||||
)
|
||||
row2 = cur.fetchone() or {}
|
||||
order_id = row2.get("id")
|
||||
except Exception:
|
||||
# Fallback for DB drivers without RETURNING support
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_membership_orders
|
||||
(user_id, plan, price_usd, status, created_at, paid_at)
|
||||
VALUES (?, ?, ?, 'paid', NOW(), NOW())
|
||||
""",
|
||||
(user_id, order_plan, order_price_usd),
|
||||
)
|
||||
order_id = getattr(cur, "lastrowid", None)
|
||||
order_ref = str(order_id or "")
|
||||
|
||||
# Update user VIP fields
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_users
|
||||
SET vip_expires_at = ?,
|
||||
vip_plan = ?,
|
||||
vip_is_lifetime = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(vip_expires_at, vip_plan, 1 if vip_is_lifetime else 0, user_id),
|
||||
)
|
||||
|
||||
# Credits grants
|
||||
if plan in ("monthly", "yearly"):
|
||||
credits_once = int(plans[plan].get("credits_once") or 0)
|
||||
if credits_once > 0:
|
||||
# Use add_credits to update balance and log
|
||||
# NOTE: add_credits opens its own connection, so we do a direct update here for atomicity.
|
||||
self._add_credits_in_tx(cur, user_id, credits_once, action="membership_bonus",
|
||||
remark=f"Membership bonus ({plan})", reference_id=order_ref)
|
||||
else:
|
||||
# Lifetime: grant first month's credits immediately and set last grant time
|
||||
monthly_credits = int(plans["lifetime"].get("credits_monthly") or 0)
|
||||
if monthly_credits > 0:
|
||||
self._add_credits_in_tx(cur, user_id, monthly_credits, action="membership_monthly",
|
||||
remark="Lifetime membership monthly credits", reference_id=order_ref)
|
||||
try:
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?",
|
||||
(now, user_id),
|
||||
)
|
||||
except Exception:
|
||||
# Column may not exist; ignore
|
||||
pass
|
||||
|
||||
# VIP log entry (for audit)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, remark, operator_id, reference_id, created_at)
|
||||
VALUES (?, 'membership_purchase', 0,
|
||||
(SELECT credits FROM qd_users WHERE id = ?),
|
||||
?, NULL, ?, NOW())
|
||||
""",
|
||||
(user_id, user_id, f"Membership purchased: {plan}", order_ref),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return True, "success", {
|
||||
"order_id": order_id,
|
||||
"plan": plan,
|
||||
"vip_expires_at": vip_expires_at.isoformat() if vip_expires_at else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"purchase_membership failed: {e}", exc_info=True)
|
||||
return False, f"error:{str(e)}", {}
|
||||
|
||||
def _ensure_membership_schema_best_effort(self, cur):
|
||||
"""Best-effort schema upgrade for membership fields on qd_users."""
|
||||
try:
|
||||
# vip_plan / vip_is_lifetime / vip_monthly_credits_last_grant
|
||||
cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_plan VARCHAR(20) DEFAULT ''")
|
||||
cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_is_lifetime BOOLEAN DEFAULT FALSE")
|
||||
cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_monthly_credits_last_grant TIMESTAMP")
|
||||
except Exception:
|
||||
# Ignore schema upgrade failures (e.g., insufficient privileges)
|
||||
pass
|
||||
|
||||
def _ensure_membership_orders_table_best_effort(self, cur):
|
||||
"""Best-effort create membership orders table (mock payment)."""
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS qd_membership_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
plan VARCHAR(20) NOT NULL,
|
||||
price_usd DECIMAL(10,2) DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'paid',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
paid_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _add_credits_in_tx(self, cur, user_id: int, amount: int, action: str, remark: str, reference_id: str = ''):
|
||||
"""Add credits within an existing DB transaction and write qd_credits_log."""
|
||||
try:
|
||||
cur.execute("SELECT credits FROM qd_users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone() or {}
|
||||
credits = Decimal(str(row.get("credits", 0) or 0))
|
||||
new_balance = credits + Decimal(str(amount))
|
||||
|
||||
cur.execute("UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", (float(new_balance), user_id))
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, remark, operator_id, reference_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, NOW())
|
||||
""",
|
||||
(user_id, action, amount, float(new_balance), remark, reference_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"_add_credits_in_tx failed: {e}", exc_info=True)
|
||||
|
||||
def _grant_lifetime_monthly_credits_best_effort(self, cur, user_id: int):
|
||||
"""Grant lifetime monthly credits if due (best-effort)."""
|
||||
try:
|
||||
plans = self.get_membership_plans()
|
||||
monthly_credits = int(plans.get("lifetime", {}).get("credits_monthly") or 0)
|
||||
if monthly_credits <= 0:
|
||||
return
|
||||
|
||||
cur.execute(
|
||||
"SELECT vip_is_lifetime, vip_expires_at, vip_monthly_credits_last_grant FROM qd_users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
if not row.get("vip_is_lifetime"):
|
||||
return
|
||||
|
||||
expires_at = row.get("vip_expires_at")
|
||||
if isinstance(expires_at, str) and expires_at:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
expires_at = None
|
||||
if expires_at and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_at and expires_at <= now:
|
||||
return
|
||||
|
||||
last = row.get("vip_monthly_credits_last_grant")
|
||||
if isinstance(last, str) and last:
|
||||
try:
|
||||
last = datetime.fromisoformat(last.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
last = None
|
||||
if last and last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
|
||||
# First time: do nothing (purchase flow already grants), but set last to now if missing
|
||||
if not last:
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?",
|
||||
(now, user_id),
|
||||
)
|
||||
return
|
||||
|
||||
# Use 30-day periods. Catch up up to 6 periods max to avoid abuse.
|
||||
delta_days = int((now - last).total_seconds() // 86400)
|
||||
periods = delta_days // 30
|
||||
if periods <= 0:
|
||||
return
|
||||
if periods > 6:
|
||||
periods = 6
|
||||
|
||||
total = monthly_credits * periods
|
||||
self._add_credits_in_tx(cur, user_id, total, action="membership_monthly",
|
||||
remark=f"Lifetime membership monthly credits x{periods}", reference_id="")
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?",
|
||||
(now, user_id),
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort; never break caller
|
||||
pass
|
||||
|
||||
def check_and_consume(self, user_id: int, feature: str, reference_id: str = '') -> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -420,7 +718,7 @@ class BillingService:
|
||||
'is_vip': is_vip,
|
||||
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
|
||||
'billing_enabled': config.get('enabled', False),
|
||||
'vip_bypass': config.get('vip_bypass', True),
|
||||
'vip_bypass': config.get('vip_bypass', False),
|
||||
# Public support link for credits recharge / VIP purchase
|
||||
'recharge_telegram_url': os.getenv('RECHARGE_TELEGRAM_URL', '').strip() or 'https://t.me/your_support_bot',
|
||||
# 功能费用(供前端显示)
|
||||
|
||||
@@ -3,6 +3,7 @@ Community Service - 指标社区服务
|
||||
|
||||
处理指标市场、购买、评论等功能。
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
@@ -19,6 +20,15 @@ class CommunityService:
|
||||
|
||||
def __init__(self):
|
||||
self.billing = get_billing_service()
|
||||
# Best-effort: ensure vip_free column exists (for old databases)
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free BOOLEAN DEFAULT FALSE")
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ==========================================
|
||||
# 指标市场
|
||||
@@ -78,7 +88,7 @@ class CommunityService:
|
||||
# 获取列表(联表查询作者信息)
|
||||
query_sql = f"""
|
||||
SELECT
|
||||
i.id, i.name, i.description, i.pricing_type, i.price,
|
||||
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
|
||||
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
|
||||
i.view_count, i.created_at, i.updated_at,
|
||||
u.id as author_id, u.username as author_username,
|
||||
@@ -115,6 +125,7 @@ class CommunityService:
|
||||
'description': row['description'][:200] if row['description'] else '',
|
||||
'pricing_type': row['pricing_type'] or 'free',
|
||||
'price': float(row['price'] or 0),
|
||||
'vip_free': bool(row.get('vip_free') or False),
|
||||
'preview_image': row['preview_image'] or '',
|
||||
'purchase_count': row['purchase_count'] or 0,
|
||||
'avg_rating': float(row['avg_rating'] or 0),
|
||||
@@ -152,7 +163,7 @@ class CommunityService:
|
||||
# 获取指标信息
|
||||
cur.execute("""
|
||||
SELECT
|
||||
i.id, i.name, i.description, i.pricing_type, i.price,
|
||||
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
|
||||
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
|
||||
i.view_count, i.publish_to_community, i.created_at, i.updated_at,
|
||||
i.user_id,
|
||||
@@ -196,6 +207,7 @@ class CommunityService:
|
||||
'description': row['description'] or '',
|
||||
'pricing_type': row['pricing_type'] or 'free',
|
||||
'price': float(row['price'] or 0),
|
||||
'vip_free': bool(row.get('vip_free') or False),
|
||||
'preview_image': row['preview_image'] or '',
|
||||
'purchase_count': row['purchase_count'] or 0,
|
||||
'avg_rating': float(row['avg_rating'] or 0),
|
||||
@@ -234,7 +246,7 @@ class CommunityService:
|
||||
|
||||
# 1. 获取指标信息
|
||||
cur.execute("""
|
||||
SELECT id, user_id, name, code, description, pricing_type, price,
|
||||
SELECT id, user_id, name, code, description, pricing_type, price, COALESCE(vip_free, FALSE) as vip_free,
|
||||
preview_image, is_encrypted
|
||||
FROM qd_indicator_codes
|
||||
WHERE id = ? AND publish_to_community = 1
|
||||
@@ -248,6 +260,11 @@ class CommunityService:
|
||||
seller_id = indicator['user_id']
|
||||
price = float(indicator['price'] or 0)
|
||||
pricing_type = indicator['pricing_type'] or 'free'
|
||||
vip_free = bool(indicator.get('vip_free') or False)
|
||||
is_vip, _ = self.billing.get_user_vip_status(buyer_id)
|
||||
|
||||
# VIP-free indicator: VIP users can get it without credits charge
|
||||
effective_price = 0.0 if (vip_free and is_vip) else price
|
||||
|
||||
# 2. 检查是否购买自己的指标
|
||||
if seller_id == buyer_id:
|
||||
@@ -264,17 +281,17 @@ class CommunityService:
|
||||
return False, 'already_purchased', {}
|
||||
|
||||
# 4. 如果是付费指标,检查并扣除积分
|
||||
if pricing_type != 'free' and price > 0:
|
||||
if pricing_type != 'free' and effective_price > 0:
|
||||
buyer_credits = self.billing.get_user_credits(buyer_id)
|
||||
if buyer_credits < price:
|
||||
if buyer_credits < effective_price:
|
||||
cur.close()
|
||||
return False, 'insufficient_credits', {
|
||||
'required': price,
|
||||
'required': effective_price,
|
||||
'current': float(buyer_credits)
|
||||
}
|
||||
|
||||
# 扣除买家积分
|
||||
new_buyer_balance = buyer_credits - Decimal(str(price))
|
||||
new_buyer_balance = buyer_credits - Decimal(str(effective_price))
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
|
||||
(float(new_buyer_balance), buyer_id)
|
||||
@@ -285,12 +302,12 @@ class CommunityService:
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
|
||||
VALUES (?, 'indicator_purchase', ?, ?, 'indicator_purchase', ?, ?, NOW())
|
||||
""", (buyer_id, -price, float(new_buyer_balance), str(indicator_id),
|
||||
""", (buyer_id, -effective_price, float(new_buyer_balance), str(indicator_id),
|
||||
f"购买指标: {indicator['name']}"))
|
||||
|
||||
# 给卖家增加积分(可配置抽成比例,这里先100%给卖家)
|
||||
seller_credits = self.billing.get_user_credits(seller_id)
|
||||
new_seller_balance = seller_credits + Decimal(str(price))
|
||||
new_seller_balance = seller_credits + Decimal(str(effective_price))
|
||||
cur.execute(
|
||||
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
|
||||
(float(new_seller_balance), seller_id)
|
||||
@@ -301,7 +318,7 @@ class CommunityService:
|
||||
INSERT INTO qd_credits_log
|
||||
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
|
||||
VALUES (?, 'indicator_sale', ?, ?, 'indicator_sale', ?, ?, NOW())
|
||||
""", (seller_id, price, float(new_seller_balance), str(indicator_id),
|
||||
""", (seller_id, effective_price, float(new_seller_balance), str(indicator_id),
|
||||
f"出售指标: {indicator['name']}"))
|
||||
|
||||
# 5. 创建购买记录
|
||||
@@ -309,16 +326,16 @@ class CommunityService:
|
||||
INSERT INTO qd_indicator_purchases
|
||||
(indicator_id, buyer_id, seller_id, price, created_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
""", (indicator_id, buyer_id, seller_id, price))
|
||||
""", (indicator_id, buyer_id, seller_id, effective_price))
|
||||
|
||||
# 6. 复制指标到买家账户
|
||||
now_ts = int(time.time())
|
||||
cur.execute("""
|
||||
INSERT INTO qd_indicator_codes
|
||||
(user_id, is_buy, end_time, name, code, description,
|
||||
publish_to_community, pricing_type, price, is_encrypted, preview_image,
|
||||
publish_to_community, pricing_type, price, is_encrypted, preview_image, vip_free,
|
||||
createtime, updatetime, created_at, updated_at)
|
||||
VALUES (?, 1, 0, ?, ?, ?, 0, 'free', 0, ?, ?, ?, ?, NOW(), NOW())
|
||||
VALUES (?, 1, 0, ?, ?, ?, 0, 'free', 0, ?, ?, 0, ?, ?, NOW(), NOW())
|
||||
""", (
|
||||
buyer_id,
|
||||
indicator['name'],
|
||||
@@ -339,8 +356,8 @@ class CommunityService:
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"User {buyer_id} purchased indicator {indicator_id} for {price} credits")
|
||||
return True, 'success', {'indicator_name': indicator['name'], 'price': price}
|
||||
logger.info(f"User {buyer_id} purchased indicator {indicator_id} for {effective_price} credits (vip_free={vip_free}, is_vip={is_vip})")
|
||||
return True, 'success', {'indicator_name': indicator['name'], 'price': price, 'charged': effective_price, 'vip_free': vip_free}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"purchase_indicator failed: {e}")
|
||||
@@ -864,14 +881,16 @@ class CommunityService:
|
||||
return {'pending': 0, 'approved': 0, 'rejected': 0}
|
||||
|
||||
# ==========================================
|
||||
# 实盘表现(聚合回测数据)
|
||||
# 实盘表现(聚合回测 + 实盘交易数据)
|
||||
# ==========================================
|
||||
|
||||
|
||||
def get_indicator_performance(self, indicator_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指标的实盘表现统计
|
||||
|
||||
目前基于回测数据统计,未来可扩展为实盘交易数据
|
||||
|
||||
数据来源:
|
||||
1. qd_backtest_runs - 回测记录(result_json 内含 totalReturn / winRate 等)
|
||||
2. qd_strategy_trades + qd_strategies_trading - 真实实盘交易记录
|
||||
"""
|
||||
default_result = {
|
||||
'strategy_count': 0,
|
||||
@@ -881,49 +900,125 @@ class CommunityService:
|
||||
'avg_return': 0,
|
||||
'max_drawdown': 0
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
|
||||
# 首先检查回测记录表是否存在
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as cnt FROM information_schema.tables
|
||||
WHERE table_name = 'qd_backtest_runs'
|
||||
""")
|
||||
table_exists = cur.fetchone()
|
||||
if not table_exists or table_exists['cnt'] == 0:
|
||||
cur.close()
|
||||
return default_result
|
||||
|
||||
# 从回测记录中统计该指标的表现
|
||||
# 使用 indicator_id 字段匹配
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(*) as run_count,
|
||||
AVG(CASE WHEN total_return IS NOT NULL THEN total_return ELSE 0 END) as avg_return,
|
||||
AVG(CASE WHEN win_rate IS NOT NULL THEN win_rate ELSE 0 END) as avg_win_rate,
|
||||
AVG(CASE WHEN max_drawdown IS NOT NULL THEN max_drawdown ELSE 0 END) as avg_drawdown,
|
||||
SUM(CASE WHEN trade_count IS NOT NULL THEN trade_count ELSE 0 END) as total_trades
|
||||
FROM qd_backtest_runs
|
||||
WHERE indicator_id = ?
|
||||
""", (indicator_id,))
|
||||
|
||||
row = cur.fetchone()
|
||||
|
||||
# ---------- Part 1: 回测数据(从 result_json 解析) ----------
|
||||
bt_returns = []
|
||||
bt_win_rates = []
|
||||
bt_drawdowns = []
|
||||
bt_trade_counts = []
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT result_json
|
||||
FROM qd_backtest_runs
|
||||
WHERE indicator_id = %s AND status = 'success'
|
||||
AND result_json IS NOT NULL AND result_json != ''
|
||||
""", (indicator_id,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
rj = json.loads(row['result_json']) if isinstance(row['result_json'], str) else {}
|
||||
tr = float(rj.get('totalReturn', 0) or 0)
|
||||
wr = float(rj.get('winRate', 0) or 0)
|
||||
md = float(rj.get('maxDrawdown', 0) or 0)
|
||||
tc = int(rj.get('totalTrades', 0) or 0)
|
||||
bt_returns.append(tr)
|
||||
bt_win_rates.append(wr)
|
||||
bt_drawdowns.append(md)
|
||||
bt_trade_counts.append(tc)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
logger.debug("Backtest runs query skipped or failed", exc_info=True)
|
||||
|
||||
bt_run_count = len(bt_returns)
|
||||
|
||||
# ---------- Part 2: 实盘交易数据 ----------
|
||||
live_strategy_count = 0
|
||||
live_trade_count = 0
|
||||
live_win_rate = 0.0
|
||||
live_total_profit = 0.0
|
||||
|
||||
try:
|
||||
# 找出使用该指标的策略(indicator_config JSON 中 indicator_id 匹配)
|
||||
cur.execute("""
|
||||
SELECT id FROM qd_strategies_trading
|
||||
WHERE indicator_config::text LIKE %s
|
||||
""", (f'%"indicator_id": {indicator_id}%',))
|
||||
strategy_rows = cur.fetchall()
|
||||
|
||||
# 也尝试匹配无空格的格式
|
||||
if not strategy_rows:
|
||||
cur.execute("""
|
||||
SELECT id FROM qd_strategies_trading
|
||||
WHERE indicator_config::text LIKE %s
|
||||
""", (f'%"indicator_id":{indicator_id}%',))
|
||||
strategy_rows = cur.fetchall()
|
||||
|
||||
if strategy_rows:
|
||||
strategy_ids = [r['id'] for r in strategy_rows]
|
||||
live_strategy_count = len(strategy_ids)
|
||||
|
||||
placeholders = ','.join(['%s'] * len(strategy_ids))
|
||||
cur.execute(f"""
|
||||
SELECT
|
||||
COUNT(*) as trade_count,
|
||||
SUM(CASE WHEN profit > 0 THEN 1 ELSE 0 END) as win_count,
|
||||
SUM(profit) as total_profit
|
||||
FROM qd_strategy_trades
|
||||
WHERE strategy_id IN ({placeholders})
|
||||
AND profit != 0
|
||||
""", tuple(strategy_ids))
|
||||
trade_row = cur.fetchone()
|
||||
|
||||
if trade_row and (trade_row['trade_count'] or 0) > 0:
|
||||
live_trade_count = int(trade_row['trade_count'] or 0)
|
||||
win_count = int(trade_row['win_count'] or 0)
|
||||
live_win_rate = round(win_count / live_trade_count * 100, 2) if live_trade_count > 0 else 0.0
|
||||
live_total_profit = round(float(trade_row['total_profit'] or 0), 2)
|
||||
except Exception:
|
||||
logger.debug("Live trading query skipped or failed", exc_info=True)
|
||||
|
||||
cur.close()
|
||||
|
||||
if not row or row['run_count'] == 0:
|
||||
|
||||
# ---------- Combine results ----------
|
||||
total_strategy_count = bt_run_count + live_strategy_count
|
||||
total_trade_count = sum(bt_trade_counts) + live_trade_count
|
||||
|
||||
# 综合胜率:优先实盘 > 回测平均
|
||||
if live_trade_count > 0:
|
||||
combined_win_rate = live_win_rate
|
||||
elif bt_win_rates:
|
||||
combined_win_rate = round(sum(bt_win_rates) / len(bt_win_rates), 2)
|
||||
else:
|
||||
combined_win_rate = 0.0
|
||||
|
||||
# 平均收益率(回测 totalReturn %)
|
||||
avg_return = round(sum(bt_returns) / len(bt_returns), 2) if bt_returns else 0.0
|
||||
|
||||
# 总利润:优先用实盘绝对利润,无实盘则显示回测平均收益率
|
||||
combined_profit = live_total_profit if live_trade_count > 0 else avg_return
|
||||
|
||||
# 最大回撤取回测中最差的(maxDrawdown 是负数,取最小即最差)
|
||||
avg_drawdown = round(min(bt_drawdowns), 2) if bt_drawdowns else 0.0
|
||||
|
||||
if total_strategy_count == 0 and total_trade_count == 0:
|
||||
return default_result
|
||||
|
||||
|
||||
return {
|
||||
'strategy_count': row['run_count'] or 0,
|
||||
'trade_count': row['total_trades'] or 0,
|
||||
'win_rate': round(float(row['avg_win_rate'] or 0), 2),
|
||||
'total_profit': round(float(row['avg_return'] or 0), 2),
|
||||
'avg_return': round(float(row['avg_return'] or 0), 2),
|
||||
'max_drawdown': round(float(row['avg_drawdown'] or 0), 2)
|
||||
'strategy_count': total_strategy_count,
|
||||
'trade_count': total_trade_count,
|
||||
'win_rate': combined_win_rate,
|
||||
'total_profit': round(combined_profit, 2),
|
||||
'avg_return': avg_return,
|
||||
'max_drawdown': avg_drawdown
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_indicator_performance failed: {e}")
|
||||
return default_result
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Interactive Brokers Trading Module
|
||||
|
||||
Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway.
|
||||
Supports US stocks trading via TWS or IB Gateway.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -76,10 +76,10 @@ curl -X POST http://localhost:5000/api/ibkr/order \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol": "AAPL", "side": "buy", "quantity": 10, "marketType": "USStock"}'
|
||||
|
||||
# Limit order: sell 100 shares of Tencent
|
||||
# Limit order: sell 100 shares of MSFT
|
||||
curl -X POST http://localhost:5000/api/ibkr/order \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol": "0700.HK", "side": "sell", "quantity": 100, "marketType": "HShare", "orderType": "limit", "price": 300}'
|
||||
-d '{"symbol": "MSFT", "side": "sell", "quantity": 100, "marketType": "USStock", "orderType": "limit", "price": 400}'
|
||||
```
|
||||
|
||||
### Get Positions
|
||||
@@ -93,7 +93,6 @@ curl http://localhost:5000/api/ibkr/positions
|
||||
| Market | Format | Examples |
|
||||
|--------|--------|----------|
|
||||
| US Stock | Ticker symbol | `AAPL`, `TSLA`, `GOOGL` |
|
||||
| HK Stock | `XXXX.HK` or digits | `0700.HK`, `00700`, `700` |
|
||||
|
||||
## Important Notes
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Interactive Brokers (IBKR) Trading Module
|
||||
|
||||
Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway.
|
||||
Supports US stocks trading via TWS or IB Gateway.
|
||||
|
||||
Port Reference:
|
||||
- TWS Live: 7497, TWS Paper: 7496
|
||||
|
||||
@@ -180,7 +180,7 @@ class IBKRClient:
|
||||
|
||||
Args:
|
||||
symbol: Symbol code
|
||||
market_type: Market type (USStock, HShare)
|
||||
market_type: Market type (USStock)
|
||||
"""
|
||||
_ensure_ib_insync()
|
||||
|
||||
@@ -219,7 +219,7 @@ class IBKRClient:
|
||||
symbol: Symbol code (e.g., AAPL, 0700.HK)
|
||||
side: Direction ("buy" or "sell")
|
||||
quantity: Number of shares
|
||||
market_type: Market type ("USStock" or "HShare")
|
||||
market_type: Market type ("USStock")
|
||||
|
||||
Returns:
|
||||
OrderResult
|
||||
|
||||
@@ -13,7 +13,7 @@ def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]:
|
||||
|
||||
Args:
|
||||
symbol: Symbol code in the system
|
||||
market_type: Market type (USStock, HShare)
|
||||
market_type: Market type (USStock)
|
||||
|
||||
Returns:
|
||||
(ib_symbol, exchange, currency)
|
||||
@@ -26,22 +26,6 @@ def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]:
|
||||
# Use SMART routing for best execution
|
||||
return symbol, "SMART", "USD"
|
||||
|
||||
elif market_type == "HShare":
|
||||
# Hong Kong stock formats:
|
||||
# - 0700.HK -> 700
|
||||
# - 00700 -> 700
|
||||
# - 700 -> 700
|
||||
ib_symbol = symbol
|
||||
|
||||
# Remove .HK suffix
|
||||
if ib_symbol.endswith(".HK"):
|
||||
ib_symbol = ib_symbol[:-3]
|
||||
|
||||
# Remove leading zeros
|
||||
ib_symbol = ib_symbol.lstrip("0") or "0"
|
||||
|
||||
return ib_symbol, "SEHK", "HKD"
|
||||
|
||||
else:
|
||||
# Default to US stock
|
||||
return symbol, "SMART", "USD"
|
||||
@@ -59,15 +43,6 @@ def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
symbol = (symbol or "").strip().upper()
|
||||
|
||||
# HK stock: ends with .HK or all digits
|
||||
if symbol.endswith(".HK"):
|
||||
return symbol, "HShare"
|
||||
|
||||
# All digits (likely HK stock code)
|
||||
clean = symbol.lstrip("0")
|
||||
if clean.isdigit() and len(clean) <= 5:
|
||||
return symbol, "HShare"
|
||||
|
||||
# Default to US stock
|
||||
return symbol, "USStock"
|
||||
|
||||
@@ -83,8 +58,4 @@ def format_display_symbol(ib_symbol: str, exchange: str) -> str:
|
||||
Returns:
|
||||
Display symbol
|
||||
"""
|
||||
if exchange == "SEHK":
|
||||
# HK stock: pad to 4 digits, add .HK
|
||||
padded = ib_symbol.zfill(4)
|
||||
return f"{padded}.HK"
|
||||
return ib_symbol
|
||||
|
||||
@@ -30,7 +30,7 @@ class KlineService:
|
||||
获取K线数据
|
||||
|
||||
Args:
|
||||
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
|
||||
market: 市场类型 (Crypto, USStock, Forex, Futures)
|
||||
symbol: 交易对/股票代码
|
||||
timeframe: 时间周期
|
||||
limit: 数据条数
|
||||
@@ -76,7 +76,7 @@ class KlineService:
|
||||
获取实时价格(优先使用 ticker API,降级使用分钟 K 线)
|
||||
|
||||
Args:
|
||||
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
|
||||
market: 市场类型 (Crypto, USStock, Forex, Futures)
|
||||
symbol: 交易对/股票代码
|
||||
force_refresh: 是否强制刷新(跳过缓存)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Translate a strategy signal into a direct-exchange order call.
|
||||
|
||||
Supports:
|
||||
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
|
||||
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
|
||||
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
|
||||
- Forex brokers: MetaTrader 5 (MT5)
|
||||
"""
|
||||
|
||||
@@ -203,7 +203,7 @@ def _place_ibkr_order(
|
||||
exchange_config: Optional[Dict[str, Any]] = None,
|
||||
) -> LiveOrderResult:
|
||||
"""
|
||||
Place order via IBKR for US/HK stocks.
|
||||
Place order via IBKR for US stocks.
|
||||
|
||||
Signal mapping for stocks (no short selling in this implementation):
|
||||
- open_long / add_long -> BUY
|
||||
|
||||
@@ -3,7 +3,7 @@ Factory for direct exchange clients.
|
||||
|
||||
Supports:
|
||||
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
|
||||
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
|
||||
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
|
||||
- Forex brokers: MetaTrader 5 (MT5)
|
||||
"""
|
||||
|
||||
@@ -131,7 +131,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
market_type=mt,
|
||||
)
|
||||
|
||||
# Traditional brokers (IBKR for US/HK stocks only)
|
||||
# Traditional brokers (IBKR for US stocks only)
|
||||
if exchange_id == "ibkr":
|
||||
# Note: Market category validation should be done at the caller level
|
||||
# This factory only creates clients based on exchange_id
|
||||
@@ -148,7 +148,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
|
||||
def create_ibkr_client(exchange_config: Dict[str, Any]):
|
||||
"""
|
||||
Create IBKR client for US/HK stock trading.
|
||||
Create IBKR client for US stock trading.
|
||||
|
||||
exchange_config should contain:
|
||||
- ibkr_host: TWS/Gateway host (default: 127.0.0.1)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 价格/K线: DataSourceFactory (已验证,与K线模块、自选列表一致)
|
||||
- 宏观数据: 复用 global_market.py (VIX, DXY, TNX, Fear&Greed等,带缓存)
|
||||
- 新闻: Finnhub API (结构化数据,无需深度阅读)
|
||||
- 基本面: Finnhub (美股) / akshare (A股) / 固定描述 (加密)
|
||||
- 基本面: Finnhub (美股) / 固定描述 (加密)
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -59,12 +59,12 @@ class MarketDataCollector:
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub client init failed: {e}")
|
||||
|
||||
# akshare
|
||||
# akshare (optional, for supplementary data)
|
||||
try:
|
||||
import akshare as ak
|
||||
self._ak = ak
|
||||
except ImportError:
|
||||
logger.info("akshare not installed, A-share data will be limited")
|
||||
logger.info("akshare not installed")
|
||||
|
||||
def collect_all(
|
||||
self,
|
||||
@@ -79,7 +79,7 @@ class MarketDataCollector:
|
||||
采集所有市场数据
|
||||
|
||||
Args:
|
||||
market: 市场类型 (USStock, Crypto, AShare, HShare, Forex, Futures)
|
||||
market: 市场类型 (USStock, Crypto, Forex, Futures)
|
||||
symbol: 标的代码
|
||||
timeframe: K线周期
|
||||
include_macro: 是否包含宏观数据
|
||||
@@ -124,7 +124,7 @@ class MarketDataCollector:
|
||||
}
|
||||
|
||||
# 如果需要基本面,也并行获取
|
||||
if market in ('USStock', 'AShare', 'HShare'):
|
||||
if market == 'USStock':
|
||||
core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental"
|
||||
core_futures[executor.submit(self._get_company, market, symbol)] = "company"
|
||||
elif market == 'Crypto':
|
||||
@@ -547,10 +547,6 @@ class MarketDataCollector:
|
||||
try:
|
||||
if market == 'USStock':
|
||||
return self._get_us_fundamental(symbol)
|
||||
elif market == 'AShare':
|
||||
return self._get_ashare_fundamental(symbol)
|
||||
elif market == 'HShare':
|
||||
return self._get_hshare_fundamental(symbol)
|
||||
except Exception as e:
|
||||
logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}")
|
||||
return None
|
||||
@@ -602,56 +598,6 @@ class MarketDataCollector:
|
||||
|
||||
return result if result else None
|
||||
|
||||
def _get_ashare_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""A股基本面 - akshare"""
|
||||
if not self._ak:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 个股指标
|
||||
df = self._ak.stock_individual_info_em(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
result = {}
|
||||
for _, row in df.iterrows():
|
||||
item = row.get('item', '')
|
||||
value = row.get('value', '')
|
||||
if '市盈率' in item:
|
||||
result['pe_ratio'] = value
|
||||
elif '市净率' in item:
|
||||
result['pb_ratio'] = value
|
||||
elif '总市值' in item:
|
||||
result['market_cap'] = value
|
||||
elif 'ROE' in item or '净资产收益率' in item:
|
||||
result['roe'] = value
|
||||
elif '每股收益' in item:
|
||||
result['eps'] = value
|
||||
return result if result else None
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare fundamental failed for {symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_hshare_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""港股基本面 - yfinance"""
|
||||
try:
|
||||
# 港股在yfinance的格式: 0700.HK, 9988.HK
|
||||
yf_symbol = f"{symbol}.HK"
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
info = ticker.info or {}
|
||||
|
||||
return {
|
||||
'pe_ratio': info.get('trailingPE'),
|
||||
'pb_ratio': info.get('priceToBook'),
|
||||
'market_cap': info.get('marketCap'),
|
||||
'dividend_yield': info.get('dividendYield'),
|
||||
'52w_high': info.get('fiftyTwoWeekHigh'),
|
||||
'52w_low': info.get('fiftyTwoWeekLow'),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"yfinance HShare fundamental failed for {symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_crypto_info(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""加密货币信息 (固定描述为主)"""
|
||||
# 常见加密货币的描述
|
||||
@@ -717,19 +663,6 @@ class MarketDataCollector:
|
||||
'website': profile.get('weburl'),
|
||||
}
|
||||
|
||||
elif market == 'AShare' and self._ak:
|
||||
df = self._ak.stock_individual_info_em(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
result = {}
|
||||
for _, row in df.iterrows():
|
||||
item = row.get('item', '')
|
||||
value = row.get('value', '')
|
||||
if '名称' in item or '简称' in item:
|
||||
result['name'] = value
|
||||
elif '行业' in item:
|
||||
result['industry'] = value
|
||||
return result if result else None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}")
|
||||
|
||||
@@ -892,9 +825,8 @@ class MarketDataCollector:
|
||||
|
||||
策略(按优先级):
|
||||
1. 结构化API (Finnhub) - 美股首选
|
||||
2. akshare 多源 - A股首选(东方财富/新浪/同花顺/雪球)
|
||||
3. 搜索引擎 (Bocha/Tavily) - 补充搜索
|
||||
4. 情绪分析 - Finnhub 社交媒体情绪
|
||||
2. 搜索引擎 (Bocha/Tavily) - 补充搜索
|
||||
3. 情绪分析 - Finnhub 社交媒体情绪
|
||||
"""
|
||||
news_list = []
|
||||
sentiment = {}
|
||||
@@ -912,7 +844,7 @@ class MarketDataCollector:
|
||||
elif market == 'Crypto':
|
||||
# 加密货币通用新闻
|
||||
raw_news = self._finnhub_client.general_news('crypto', min_id=0)
|
||||
elif market not in ('AShare', 'HShare'):
|
||||
else:
|
||||
# 其他市场通用新闻
|
||||
raw_news = self._finnhub_client.general_news('general', min_id=0)
|
||||
|
||||
@@ -942,17 +874,7 @@ class MarketDataCollector:
|
||||
except Exception as e:
|
||||
logger.debug(f"Finnhub sentiment fetch failed: {e}")
|
||||
|
||||
# === 3) A股多源新闻 (akshare) ===
|
||||
if market == 'AShare' and self._ak:
|
||||
ashare_news = self._get_ashare_news_multi_source(symbol)
|
||||
news_list.extend(ashare_news)
|
||||
|
||||
# === 4) 港股新闻 (akshare) ===
|
||||
if market == 'HShare' and self._ak:
|
||||
hshare_news = self._get_hshare_news(symbol)
|
||||
news_list.extend(hshare_news)
|
||||
|
||||
# === 5) 搜索引擎补充 (如果新闻太少) ===
|
||||
# === 3) 搜索引擎补充 (如果新闻太少) ===
|
||||
if len(news_list) < 5:
|
||||
search_news = self._get_news_from_search(market, symbol, company_name)
|
||||
news_list.extend(search_news)
|
||||
@@ -974,108 +896,6 @@ class MarketDataCollector:
|
||||
"sentiment": sentiment,
|
||||
}
|
||||
|
||||
def _get_ashare_news_multi_source(self, symbol: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
A股多源新闻获取
|
||||
|
||||
来源(按优先级):
|
||||
1. 东方财富个股新闻 (stock_news_em)
|
||||
2. 新浪财经滚动新闻 (stock_news_sina)
|
||||
3. 同花顺个股新闻 (stock_news_ths)
|
||||
4. 雪球热帖 (stock_xuqiu)
|
||||
"""
|
||||
news_list = []
|
||||
|
||||
# 1) 东方财富个股新闻
|
||||
try:
|
||||
df = self._ak.stock_news_em(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(8).iterrows():
|
||||
news_list.append({
|
||||
"datetime": str(row.get('发布时间', ''))[:16],
|
||||
"headline": row.get('新闻标题', ''),
|
||||
"summary": row.get('新闻内容', '')[:200] if row.get('新闻内容') else '',
|
||||
"source": "东方财富",
|
||||
"url": row.get('新闻链接', ''),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"东方财富新闻: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"东方财富新闻获取失败: {e}")
|
||||
|
||||
# 2) 新浪财经个股新闻
|
||||
try:
|
||||
# 注意:akshare 的新浪新闻接口可能需要股票名称而非代码
|
||||
df = self._ak.stock_news_sina(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(5).iterrows():
|
||||
title = row.get('title', '') or row.get('新闻标题', '')
|
||||
if title and title not in [n.get('headline') for n in news_list]:
|
||||
news_list.append({
|
||||
"datetime": str(row.get('time', row.get('发布时间', '')))[:16],
|
||||
"headline": title,
|
||||
"summary": row.get('content', row.get('新闻内容', ''))[:200] if row.get('content') or row.get('新闻内容') else '',
|
||||
"source": "新浪财经",
|
||||
"url": row.get('url', row.get('新闻链接', '')),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"新浪财经新闻: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"新浪财经新闻获取失败: {e}")
|
||||
|
||||
# 3) 同花顺个股新闻
|
||||
try:
|
||||
df = self._ak.stock_news_ths(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(5).iterrows():
|
||||
title = row.get('标题', '') or row.get('title', '')
|
||||
if title and title not in [n.get('headline') for n in news_list]:
|
||||
news_list.append({
|
||||
"datetime": str(row.get('发布时间', row.get('time', '')))[:16],
|
||||
"headline": title,
|
||||
"summary": row.get('内容', row.get('content', ''))[:200] if row.get('内容') or row.get('content') else '',
|
||||
"source": "同花顺",
|
||||
"url": row.get('链接', row.get('url', '')),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"同花顺新闻: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"同花顺新闻获取失败: {e}")
|
||||
|
||||
# 4) 雪球热帖(社区讨论)
|
||||
try:
|
||||
df = self._ak.stock_xuqiu(symbol=symbol)
|
||||
if df is not None and not df.empty:
|
||||
for _, row in df.head(3).iterrows():
|
||||
title = row.get('标题', '') or row.get('title', '')
|
||||
if title and title not in [n.get('headline') for n in news_list]:
|
||||
news_list.append({
|
||||
"datetime": str(row.get('发布时间', row.get('time', '')))[:16],
|
||||
"headline": title,
|
||||
"summary": row.get('内容摘要', row.get('content', ''))[:200] if row.get('内容摘要') or row.get('content') else '',
|
||||
"source": "雪球",
|
||||
"url": row.get('链接', row.get('url', '')),
|
||||
"sentiment": 'neutral',
|
||||
})
|
||||
logger.debug(f"雪球热帖: {len(df)} 条")
|
||||
except Exception as e:
|
||||
logger.debug(f"雪球热帖获取失败: {e}")
|
||||
|
||||
return news_list
|
||||
|
||||
def _get_hshare_news(self, symbol: str) -> List[Dict[str, Any]]:
|
||||
"""港股新闻获取"""
|
||||
news_list = []
|
||||
|
||||
try:
|
||||
# 港股新闻 (如果 akshare 支持)
|
||||
df = self._ak.stock_hk_spot_em()
|
||||
# 港股一般没有专门的新闻接口,可以通过搜索补充
|
||||
except Exception as e:
|
||||
logger.debug(f"港股新闻获取失败: {e}")
|
||||
|
||||
return news_list
|
||||
|
||||
def _get_news_from_search(
|
||||
self, market: str, symbol: str, company_name: str = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -838,19 +838,19 @@ class PendingOrderWorker:
|
||||
market_category = str(cfg.get("market_category") or "Crypto").strip()
|
||||
|
||||
# Validate market category and exchange_id combination for live trading
|
||||
# AShare and Futures do not support live trading
|
||||
if market_category in ("AShare", "Futures"):
|
||||
# Futures does not support live trading
|
||||
if market_category in ("Futures",):
|
||||
self._mark_failed(order_id=order_id, error=f"live_trading_not_supported_for_{market_category.lower()}")
|
||||
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {market_category} does not support live trading")
|
||||
_notify_live_best_effort(status="failed", error=f"live_trading_not_supported_for_{market_category.lower()}")
|
||||
return
|
||||
|
||||
# Validate IBKR only for USStock/HShare
|
||||
# Validate IBKR only for USStock
|
||||
if exchange_id == "ibkr":
|
||||
if market_category not in ("USStock", "HShare"):
|
||||
self._mark_failed(order_id=order_id, error=f"ibkr_only_supports_usstock_hshare_got_{market_category.lower()}")
|
||||
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} IBKR only supports USStock/HShare, got {market_category}")
|
||||
_notify_live_best_effort(status="failed", error=f"ibkr_only_supports_usstock_hshare_got_{market_category.lower()}")
|
||||
if market_category not in ("USStock",):
|
||||
self._mark_failed(order_id=order_id, error=f"ibkr_only_supports_usstock_got_{market_category.lower()}")
|
||||
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} IBKR only supports USStock, got {market_category}")
|
||||
_notify_live_best_effort(status="failed", error=f"ibkr_only_supports_usstock_got_{market_category.lower()}")
|
||||
return
|
||||
|
||||
# Validate MT5 only for Forex
|
||||
@@ -884,7 +884,7 @@ class PendingOrderWorker:
|
||||
_notify_live_best_effort(status="failed", error=f"create_client_failed:{e}")
|
||||
return
|
||||
|
||||
# Check if this is an IBKR client (US/HK stocks)
|
||||
# Check if this is an IBKR client (US stocks)
|
||||
global IBKRClient
|
||||
if IBKRClient is None:
|
||||
try:
|
||||
@@ -961,7 +961,7 @@ class PendingOrderWorker:
|
||||
|
||||
# Unified maker->market fallback settings
|
||||
# Priority: payload config > environment variable > default value
|
||||
_default_order_mode = os.getenv("ORDER_MODE", "maker").strip().lower()
|
||||
_default_order_mode = os.getenv("ORDER_MODE", "market").strip().lower()
|
||||
_default_maker_wait_sec = float(os.getenv("MAKER_WAIT_SEC", "10"))
|
||||
_default_maker_offset_bps = float(os.getenv("MAKER_OFFSET_BPS", "2"))
|
||||
|
||||
@@ -1925,7 +1925,7 @@ class PendingOrderWorker:
|
||||
_console_print,
|
||||
) -> None:
|
||||
"""
|
||||
Execute order via Interactive Brokers for US/HK stocks.
|
||||
Execute order via Interactive Brokers for US stocks.
|
||||
|
||||
Simplified flow compared to crypto (no maker->market fallback):
|
||||
- Place market order directly
|
||||
@@ -1957,7 +1957,7 @@ class PendingOrderWorker:
|
||||
_notify_live_best_effort(status="failed", error=f"ibkr_unsupported_signal:{signal_type}")
|
||||
return
|
||||
|
||||
# Get market type (USStock or HShare)
|
||||
# Get market type (USStock)
|
||||
market_type = str(
|
||||
payload.get("market_type") or
|
||||
payload.get("market_category") or
|
||||
|
||||
@@ -3,7 +3,7 @@ Search service v2.0 - 增强版搜索服务
|
||||
整合多个搜索引擎,支持 API Key 轮换和故障转移
|
||||
|
||||
支持的搜索引擎(按优先级):
|
||||
1. Bocha (博查) - 国内搜索优化,A股新闻推荐
|
||||
1. Bocha (博查) - 搜索优化
|
||||
2. Tavily - 专为AI设计,免费1000次/月
|
||||
3. SerpAPI - Google/Bing 结果抓取
|
||||
4. Google CSE - 自定义搜索引擎
|
||||
@@ -972,7 +972,7 @@ class SearchService:
|
||||
self,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
market: str = "AShare",
|
||||
market: str = "USStock",
|
||||
max_results: int = 5
|
||||
) -> SearchResponse:
|
||||
"""
|
||||
@@ -997,14 +997,14 @@ class SearchService:
|
||||
search_days = 1
|
||||
|
||||
# 根据市场类型构建搜索查询
|
||||
if market == "AShare":
|
||||
query = f"{stock_name} {stock_code} 股票 最新消息 利好 利空"
|
||||
elif market == "USStock":
|
||||
if market == "USStock":
|
||||
query = f"{stock_name} {stock_code} stock news latest"
|
||||
elif market == "Crypto":
|
||||
query = f"{stock_name} crypto news price analysis"
|
||||
elif market == "Forex":
|
||||
query = f"{stock_name} {stock_code} forex news analysis"
|
||||
else:
|
||||
query = f"{stock_name} {stock_code} 最新消息"
|
||||
query = f"{stock_name} {stock_code} latest news"
|
||||
|
||||
logger.info(f"搜索股票新闻: {stock_name}({stock_code}), market={market}, days={search_days}")
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ Goal:
|
||||
from public data sources, then persist it into watchlist records.
|
||||
|
||||
Notes:
|
||||
- For A shares we prefer akshare when available (requested).
|
||||
- For H shares we use Tencent quote API (no key required).
|
||||
- For US stocks we use Finnhub (if configured) or yfinance.
|
||||
- For Crypto/Forex/Futures we provide best-effort fallbacks.
|
||||
"""
|
||||
@@ -26,122 +24,13 @@ from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
HAS_AKSHARE = True
|
||||
except Exception:
|
||||
ak = None
|
||||
HAS_AKSHARE = False
|
||||
|
||||
|
||||
def _normalize_symbol_for_market(market: str, symbol: str) -> str:
|
||||
m = (market or '').strip()
|
||||
s = (symbol or '').strip().upper()
|
||||
if not m or not s:
|
||||
return s
|
||||
|
||||
if m == 'AShare' and s.isdigit():
|
||||
return s.zfill(6)
|
||||
if m == 'HShare' and s.isdigit():
|
||||
return s.zfill(5)
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def _tencent_quote_code(market: str, symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Convert symbol to Tencent quote code, e.g.
|
||||
- AShare: sh600000 / sz000001 / bj430047
|
||||
- HShare: hk00700
|
||||
"""
|
||||
m = (market or '').strip()
|
||||
s = _normalize_symbol_for_market(m, symbol)
|
||||
if not s:
|
||||
return None
|
||||
|
||||
if m == 'AShare':
|
||||
if s.startswith('6'):
|
||||
return f"sh{s}"
|
||||
if s.startswith('0') or s.startswith('3'):
|
||||
return f"sz{s}"
|
||||
if s.startswith('4') or s.startswith('8'):
|
||||
return f"bj{s}"
|
||||
return None
|
||||
|
||||
if m == 'HShare':
|
||||
if s.isdigit():
|
||||
return f"hk{s}"
|
||||
# allow already prefixed
|
||||
if s.startswith('HK') and s[2:].isdigit():
|
||||
return f"hk{s[2:]}"
|
||||
if s.startswith('HK') and len(s) > 2:
|
||||
return f"hk{s[2:]}"
|
||||
return f"hk{s}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_name_from_tencent(market: str, symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Tencent quote endpoint: http://qt.gtimg.cn/q=sz000858
|
||||
Returns:
|
||||
v_sz000858="51~五 粮 液~000858~..."; -> name is the 2nd field split by '~'
|
||||
"""
|
||||
code = _tencent_quote_code(market, symbol)
|
||||
if not code:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"http://qt.gtimg.cn/q={code}"
|
||||
resp = requests.get(url, timeout=5)
|
||||
# Tencent often responds in GBK for Chinese names
|
||||
resp.encoding = 'gbk'
|
||||
text = resp.text or ''
|
||||
|
||||
# Extract quoted payload
|
||||
m = re.search(r'="([^"]*)"', text)
|
||||
payload = m.group(1) if m else ''
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
parts = payload.split('~')
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
name = (parts[1] or '').strip().replace(' ', '')
|
||||
return name if name else None
|
||||
except Exception as e:
|
||||
logger.debug(f"Tencent name resolve failed: {market} {symbol}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_name_from_akshare_ashare(symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve A-share name via akshare (no API key required).
|
||||
"""
|
||||
if not HAS_AKSHARE or ak is None:
|
||||
return None
|
||||
try:
|
||||
# Prefer per-symbol endpoint (avoids fetching the whole market list).
|
||||
if hasattr(ak, "stock_individual_info_em"):
|
||||
df = ak.stock_individual_info_em(symbol=symbol)
|
||||
if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns:
|
||||
info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()}
|
||||
name = str(info.get('股票简称') or info.get('证券简称') or '').strip()
|
||||
return name if name else None
|
||||
|
||||
# Fallback: spot list (may be slow / large)
|
||||
if hasattr(ak, "stock_zh_a_spot_em"):
|
||||
df2 = ak.stock_zh_a_spot_em()
|
||||
if df2 is not None and not df2.empty:
|
||||
row = df2[df2['代码'] == symbol].iloc[0]
|
||||
name = str(row.get('名称') or '').strip()
|
||||
return name if name else None
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare name resolve failed (AShare {symbol}): {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def _resolve_name_from_yfinance(symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Best-effort company name via yfinance.
|
||||
@@ -211,13 +100,6 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
|
||||
return seed
|
||||
|
||||
# 2) Market-specific
|
||||
if m == 'AShare':
|
||||
# Requested: use akshare for A shares, do not depend on Tencent by default.
|
||||
return _resolve_name_from_akshare_ashare(s)
|
||||
|
||||
if m == 'HShare':
|
||||
return _resolve_name_from_tencent(m, s)
|
||||
|
||||
if m == 'USStock':
|
||||
# Prefer Finnhub if configured (more stable for company name),
|
||||
# otherwise fall back to yfinance.
|
||||
@@ -239,5 +121,3 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
|
||||
return s
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -556,7 +556,7 @@ class TradingExecutor:
|
||||
trade_direction = 'long' # 现货只能做多
|
||||
logger.info(f"Strategy {strategy_id} spot trading; force trade_direction=long")
|
||||
|
||||
# 获取市场类别(Crypto, USStock, Forex, Futures, AShare, HShare)
|
||||
# 获取市场类别(Crypto, USStock, Forex, Futures)
|
||||
# 这决定了使用哪个数据源来获取价格和K线数据
|
||||
market_category = (strategy.get('market_category') or 'Crypto').strip()
|
||||
logger.info(f"Strategy {strategy_id} market_category: {market_category}")
|
||||
@@ -1131,7 +1131,7 @@ class TradingExecutor:
|
||||
symbol: 交易对/代码
|
||||
timeframe: 时间周期
|
||||
limit: 数据条数
|
||||
market_category: 市场类型 (Crypto, USStock, Forex, Futures, AShare, HShare)
|
||||
market_category: 市场类型 (Crypto, USStock, Forex, Futures)
|
||||
"""
|
||||
try:
|
||||
# 使用 KlineService 获取K线数据(自动处理缓存)
|
||||
@@ -1153,7 +1153,7 @@ class TradingExecutor:
|
||||
exchange: 交易所实例(信号模式下为 None)
|
||||
symbol: 交易对/代码
|
||||
market_type: 交易类型 (swap/spot)
|
||||
market_category: 市场类型 (Crypto, USStock, Forex, Futures, AShare, HShare)
|
||||
market_category: 市场类型 (Crypto, USStock, Forex, Futures)
|
||||
"""
|
||||
# Local in-memory cache first
|
||||
cache_key = f"{market_category}:{(symbol or '').strip().upper()}"
|
||||
@@ -1173,7 +1173,7 @@ class TradingExecutor:
|
||||
|
||||
try:
|
||||
# 根据 market_category 选择正确的数据源
|
||||
# 支持: Crypto, USStock, Forex, Futures, AShare, HShare
|
||||
# 支持: Crypto, USStock, Forex, Futures
|
||||
ticker = DataSourceFactory.get_ticker(market_category, symbol)
|
||||
if ticker:
|
||||
price = float(ticker.get('last') or ticker.get('close') or 0)
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
USDT Payment Service (方案B:每单独立地址 + 自动对账)
|
||||
|
||||
MVP:
|
||||
- 只支持 USDT-TRC20
|
||||
- 使用 XPUB 派生地址(服务端只保存 xpub,不保存私钥)
|
||||
- 通过 TronGrid API 轮询到账(前端轮询订单状态时触发刷新)
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
from app.services.billing_service import get_billing_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UsdtPaymentService:
|
||||
def __init__(self):
|
||||
self.billing = get_billing_service()
|
||||
|
||||
# -------------------- Config --------------------
|
||||
|
||||
def _get_cfg(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": str(os.getenv("USDT_PAY_ENABLED", "False")).lower() in ("1", "true", "yes"),
|
||||
"chain": (os.getenv("USDT_PAY_CHAIN", "TRC20") or "TRC20").upper(),
|
||||
"xpub_trc20": (os.getenv("USDT_TRC20_XPUB", "") or "").strip(),
|
||||
"trongrid_base": (os.getenv("TRONGRID_BASE_URL", "https://api.trongrid.io") or "").strip().rstrip("/"),
|
||||
"trongrid_key": (os.getenv("TRONGRID_API_KEY", "") or "").strip(),
|
||||
"usdt_trc20_contract": (os.getenv("USDT_TRC20_CONTRACT", "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj") or "").strip(),
|
||||
"confirm_seconds": int(float(os.getenv("USDT_PAY_CONFIRM_SECONDS", "30") or 30)),
|
||||
"order_expire_minutes": int(float(os.getenv("USDT_PAY_EXPIRE_MINUTES", "30") or 30)),
|
||||
}
|
||||
|
||||
# -------------------- Schema --------------------
|
||||
|
||||
def _ensure_schema_best_effort(self, cur):
|
||||
"""Best-effort create table/columns for old databases."""
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS qd_usdt_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
|
||||
plan VARCHAR(20) NOT NULL,
|
||||
chain VARCHAR(20) NOT NULL DEFAULT 'TRC20',
|
||||
amount_usdt DECIMAL(20,6) NOT NULL DEFAULT 0,
|
||||
address_index INTEGER NOT NULL DEFAULT 0,
|
||||
address VARCHAR(80) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
tx_hash VARCHAR(120) DEFAULT '',
|
||||
paid_at TIMESTAMP,
|
||||
confirmed_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_address_unique ON qd_usdt_orders(chain, address)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_user_id ON qd_usdt_orders(user_id)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_status ON qd_usdt_orders(status)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------- Address derivation --------------------
|
||||
|
||||
def _derive_trc20_address_from_xpub(self, xpub: str, index: int) -> str:
|
||||
"""
|
||||
Derive TRON address from xpub.
|
||||
|
||||
Requires bip_utils.
|
||||
NOTE:
|
||||
- Some wallets export account-level xpub at m/44'/195'/0' (level=3).
|
||||
- Some export change-level xpub at m/44'/195'/0'/0 (level=4, external chain).
|
||||
This function supports both by normalizing to change-level before AddressIndex().
|
||||
"""
|
||||
try:
|
||||
from bip_utils import Bip44, Bip44Coins, Bip44Changes
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"bip_utils_missing:{e}")
|
||||
|
||||
if not xpub:
|
||||
raise RuntimeError("missing_xpub")
|
||||
if index < 0:
|
||||
raise RuntimeError("invalid_index")
|
||||
|
||||
ctx = Bip44.FromExtendedKey(xpub, Bip44Coins.TRON)
|
||||
lvl = int(ctx.Level())
|
||||
# Normalize to change-level (external chain) so we can derive addresses by index
|
||||
if lvl == 3:
|
||||
# account-level xpub: m/44'/195'/0'
|
||||
ctx = ctx.Change(Bip44Changes.CHAIN_EXT)
|
||||
elif lvl == 4:
|
||||
# change-level xpub: m/44'/195'/0'/0
|
||||
pass
|
||||
elif lvl == 5:
|
||||
# address-level xpub: cannot derive other indexes
|
||||
if index != 0:
|
||||
raise RuntimeError("xpub_is_address_level")
|
||||
return ctx.PublicKey().ToAddress()
|
||||
else:
|
||||
raise RuntimeError(f"unsupported_xpub_level:{lvl}")
|
||||
|
||||
addr = ctx.AddressIndex(index).PublicKey().ToAddress()
|
||||
return addr
|
||||
|
||||
# -------------------- Orders --------------------
|
||||
|
||||
def create_order(self, user_id: int, plan: str) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
cfg = self._get_cfg()
|
||||
if not cfg["enabled"]:
|
||||
return False, "usdt_pay_disabled", {}
|
||||
if cfg["chain"] != "TRC20":
|
||||
return False, "unsupported_chain", {}
|
||||
plan = (plan or "").strip().lower()
|
||||
if plan not in ("monthly", "yearly", "lifetime"):
|
||||
return False, "invalid_plan", {}
|
||||
|
||||
plans = self.billing.get_membership_plans()
|
||||
amount = Decimal(str(plans.get(plan, {}).get("price_usd") or 0))
|
||||
if amount <= 0:
|
||||
return False, "invalid_amount", {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(minutes=cfg["order_expire_minutes"])
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
self._ensure_schema_best_effort(cur)
|
||||
|
||||
# allocate next address index (simple monotonic)
|
||||
cur.execute(
|
||||
"SELECT COALESCE(MAX(address_index), -1) as max_idx FROM qd_usdt_orders WHERE chain = 'TRC20'"
|
||||
)
|
||||
max_idx = cur.fetchone().get("max_idx")
|
||||
next_idx = int(max_idx) + 1
|
||||
|
||||
address = self._derive_trc20_address_from_xpub(cfg["xpub_trc20"], next_idx)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_usdt_orders
|
||||
(user_id, plan, chain, amount_usdt, address_index, address, status, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'TRC20', ?, ?, ?, 'pending', ?, NOW(), NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(user_id, plan, float(amount), next_idx, address, expires_at),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
order_id = row.get("id")
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
return True, "success", {
|
||||
"order_id": order_id,
|
||||
"plan": plan,
|
||||
"chain": "TRC20",
|
||||
"amount_usdt": str(amount),
|
||||
"address": address,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"create_order failed: {e}", exc_info=True)
|
||||
return False, f"error:{str(e)}", {}
|
||||
|
||||
def get_order(self, user_id: int, order_id: int, refresh: bool = True) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
self._ensure_schema_best_effort(cur)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash,
|
||||
paid_at, confirmed_at, expires_at, created_at, updated_at
|
||||
FROM qd_usdt_orders
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(order_id, user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return False, "order_not_found", {}
|
||||
|
||||
if refresh:
|
||||
self._refresh_order_in_tx(cur, row)
|
||||
db.commit()
|
||||
# re-read
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash,
|
||||
paid_at, confirmed_at, expires_at, created_at, updated_at
|
||||
FROM qd_usdt_orders
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(order_id, user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
cur.close()
|
||||
|
||||
return True, "success", self._row_to_dict(row)
|
||||
except Exception as e:
|
||||
logger.error(f"get_order failed: {e}", exc_info=True)
|
||||
return False, f"error:{str(e)}", {}
|
||||
|
||||
def _row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"order_id": row.get("id"),
|
||||
"plan": row.get("plan"),
|
||||
"chain": row.get("chain"),
|
||||
"amount_usdt": str(row.get("amount_usdt") or 0),
|
||||
"address": row.get("address") or "",
|
||||
"status": row.get("status") or "",
|
||||
"tx_hash": row.get("tx_hash") or "",
|
||||
"paid_at": row.get("paid_at").isoformat() if row.get("paid_at") else None,
|
||||
"confirmed_at": row.get("confirmed_at").isoformat() if row.get("confirmed_at") else None,
|
||||
"expires_at": row.get("expires_at").isoformat() if row.get("expires_at") else None,
|
||||
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
||||
}
|
||||
|
||||
# -------------------- Chain check --------------------
|
||||
|
||||
def _refresh_order_in_tx(self, cur, row: Dict[str, Any]) -> None:
|
||||
cfg = self._get_cfg()
|
||||
status = (row.get("status") or "").lower()
|
||||
chain = (row.get("chain") or "").upper()
|
||||
|
||||
expires_at = row.get("expires_at")
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_at and isinstance(expires_at, datetime):
|
||||
exp = expires_at
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if status == "pending" and exp <= now:
|
||||
cur.execute("UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (row["id"],))
|
||||
return
|
||||
|
||||
if chain != "TRC20":
|
||||
return
|
||||
if status not in ("pending", "paid"):
|
||||
return
|
||||
|
||||
address = row.get("address") or ""
|
||||
amount = Decimal(str(row.get("amount_usdt") or 0))
|
||||
if not address or amount <= 0:
|
||||
return
|
||||
|
||||
tx = self._find_trc20_usdt_incoming(address, amount, row.get("created_at"))
|
||||
if not tx:
|
||||
return
|
||||
|
||||
tx_hash = tx.get("transaction_id") or ""
|
||||
paid_at = datetime.now(timezone.utc)
|
||||
cur.execute(
|
||||
"UPDATE qd_usdt_orders SET status = 'paid', tx_hash = ?, paid_at = ?, updated_at = NOW() WHERE id = ? AND status = 'pending'",
|
||||
(tx_hash, paid_at, row["id"]),
|
||||
)
|
||||
|
||||
# Confirm after a short delay to reduce reorg/uncle risk (TRON usually stable)
|
||||
# If already old enough, confirm now.
|
||||
confirm_sec = int(cfg.get("confirm_seconds") or 30)
|
||||
try:
|
||||
if confirm_sec <= 0:
|
||||
confirm_sec = 0
|
||||
# If transaction timestamp is available, use it
|
||||
tx_ts = tx.get("block_timestamp")
|
||||
if tx_ts:
|
||||
tx_time = datetime.fromtimestamp(int(tx_ts) / 1000.0, tz=timezone.utc)
|
||||
if (now - tx_time).total_seconds() >= confirm_sec:
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
|
||||
else:
|
||||
# no timestamp -> confirm immediately
|
||||
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
|
||||
except Exception:
|
||||
# do not block
|
||||
pass
|
||||
|
||||
def _confirm_and_activate_in_tx(self, cur, order_id: int, user_id: int, plan: str, tx_hash: str) -> None:
|
||||
# Mark confirmed if not already
|
||||
cur.execute(
|
||||
"UPDATE qd_usdt_orders SET status='confirmed', confirmed_at = NOW(), updated_at = NOW() WHERE id = ? AND status IN ('paid','pending')",
|
||||
(order_id,),
|
||||
)
|
||||
# Activate membership (idempotent-ish: billing_service stacks vip)
|
||||
try:
|
||||
# We use existing membership activation (writes qd_membership_orders + credits logs).
|
||||
ok, msg, data = self.billing.purchase_membership(int(user_id), str(plan))
|
||||
logger.info(f"USDT activate membership: order={order_id} user={user_id} plan={plan} ok={ok} msg={msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"USDT activate membership failed: order={order_id} err={e}", exc_info=True)
|
||||
|
||||
def _find_trc20_usdt_incoming(self, address: str, amount_usdt: Decimal, created_at: Optional[datetime]) -> Optional[Dict[str, Any]]:
|
||||
cfg = self._get_cfg()
|
||||
base = cfg["trongrid_base"]
|
||||
contract = cfg["usdt_trc20_contract"]
|
||||
|
||||
url = f"{base}/v1/accounts/{address}/transactions/trc20"
|
||||
headers = {}
|
||||
if cfg["trongrid_key"]:
|
||||
headers["TRON-PRO-API-KEY"] = cfg["trongrid_key"]
|
||||
|
||||
params = {
|
||||
"only_to": "true",
|
||||
"limit": 50,
|
||||
"contract_address": contract,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json() or {}
|
||||
items = data.get("data") or []
|
||||
# TRC20 USDT has 6 decimals
|
||||
target = int((amount_usdt * Decimal("1000000")).to_integral_value())
|
||||
|
||||
min_ts = None
|
||||
if created_at and isinstance(created_at, datetime):
|
||||
ct = created_at
|
||||
if ct.tzinfo is None:
|
||||
ct = ct.replace(tzinfo=timezone.utc)
|
||||
min_ts = int(ct.timestamp() * 1000) - 60_000
|
||||
|
||||
for it in items:
|
||||
try:
|
||||
if it.get("to") != address:
|
||||
continue
|
||||
if min_ts and int(it.get("block_timestamp") or 0) < min_ts:
|
||||
continue
|
||||
val = int(it.get("value") or 0)
|
||||
if val != target:
|
||||
continue
|
||||
# basic checks
|
||||
token = it.get("token_info") or {}
|
||||
if str(token.get("symbol") or "").upper() != "USDT":
|
||||
# some APIs omit symbol; contract filter should already ensure
|
||||
pass
|
||||
return it
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_svc = None
|
||||
|
||||
|
||||
def get_usdt_payment_service() -> UsdtPaymentService:
|
||||
global _svc
|
||||
if _svc is None:
|
||||
_svc = UsdtPaymentService()
|
||||
return _svc
|
||||
|
||||
Reference in New Issue
Block a user