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:
TIANHE
2026-02-27 01:57:04 +08:00
parent ae82cc0d4e
commit ffdd2ffbae
71 changed files with 4067 additions and 6073 deletions
@@ -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()
+3 -1
View File
@@ -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')
+105
View File
@@ -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),
+255 -112
View File
@@ -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
+2 -2
View File
@@ -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
}
+22 -6
View File
@@ -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()
+1 -1
View File
@@ -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)
+3 -14
View File
@@ -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
+173 -464
View File
@@ -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',
+65 -7
View File
@@ -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
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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