Refactor and translate comments and docstrings in utility modules to English for better clarity and maintainability. Update Gunicorn and application startup messages for consistency in language. Enhance documentation with English translations for better accessibility.

This commit is contained in:
dienakdz
2026-04-06 16:47:36 +07:00
parent 3ca291a346
commit 11e2e5aaa6
64 changed files with 2323 additions and 2336 deletions
+2 -3
View File
@@ -34,7 +34,7 @@ def get_pending_order_worker():
def start_polymarket_worker():
"""启动Polymarket后台任务"""
"""Start the Polymarket background task."""
try:
from app.services.polymarket_worker import get_polymarket_worker
get_polymarket_worker().start()
@@ -150,7 +150,7 @@ def restore_running_strategies():
logger.info(f"[OK] {strategy_type_name} {strategy_id} restored")
else:
logger.warning(f"[FAIL] {strategy_type_name} {strategy_id} restore failed (state may be stale)")
# 如果恢复失败,更新数据库状态为stopped,避免策略处于"僵尸"状态
# If recovery fails, update the database status to stopped to prevent the policy from being in a "zombie" state.
try:
strategy_service.update_strategy_status(strategy_id, 'stopped')
logger.info(f"[FIX] Updated strategy {strategy_id} status to 'stopped' after restore failure")
@@ -222,4 +222,3 @@ def create_app(config_name='default'):
restore_running_strategies()
return app
+6 -6
View File
@@ -1,6 +1,6 @@
"""
配置模块
统一导出所有配置
Configuration module
Export all configurations uniformly
"""
from app.config.settings import Config
from app.config.api_keys import APIKeys
@@ -15,17 +15,17 @@ from app.config.data_sources import (
)
__all__ = [
# 主配置
# main configuration
'Config',
# API 密钥
# API key
'APIKeys',
# 数据库/缓存
# Database/cache
'RedisConfig',
'CacheConfig',
# 数据源
# data source
'DataSourceConfig',
'FinnhubConfig',
'TiingoConfig',
+6 -7
View File
@@ -5,8 +5,7 @@ All third-party keys should be provided via environment variables (recommended:
import os
class MetaAPIKeys(type):
"""API Keys 元类,用于支持类属性的动态获取"""
"""API Keys metaclass, used to support dynamic acquisition of class attributes"""
@property
def FINNHUB_API_KEY(cls):
from app.utils.config_loader import load_addon_config
@@ -95,18 +94,18 @@ class MetaAPIKeys(type):
class APIKeys(metaclass=MetaAPIKeys):
"""API 密钥配置类"""
"""API key configuration class"""
@classmethod
def get(cls, key_name: str, default: str = '') -> str:
"""获取 API 密钥"""
# 尝试从类属性获取
"""Get API key"""
# Try to get from class attribute
if hasattr(cls, key_name):
return getattr(cls, key_name)
return default
@classmethod
def is_configured(cls, key_name: str) -> bool:
"""检查 API 密钥是否已配置"""
"""Check if the API key is configured"""
value = cls.get(key_name)
return bool(value and value.strip())
+7 -14
View File
@@ -1,5 +1,5 @@
"""
数据源配置
Data source configuration
"""
import os
@@ -24,7 +24,7 @@ class MetaDataSourceConfig(type):
class DataSourceConfig(metaclass=MetaDataSourceConfig):
"""数据源通用配置"""
"""General data source configuration."""
pass
@@ -51,9 +51,7 @@ class MetaFinnhubConfig(type):
class FinnhubConfig(metaclass=MetaFinnhubConfig):
"""Finnhub 数据源配置"""
pass
"""Finnhub data source configuration"""
class MetaTiingoConfig(type):
@property
@@ -68,9 +66,7 @@ class MetaTiingoConfig(type):
class TiingoConfig(metaclass=MetaTiingoConfig):
"""Tiingo 数据源配置"""
pass
"""Tiingo data source configuration"""
class MetaYFinanceConfig(type):
@property
@@ -94,9 +90,7 @@ class MetaYFinanceConfig(type):
class YFinanceConfig(metaclass=MetaYFinanceConfig):
"""Yahoo Finance 数据源配置"""
pass
"""Yahoo Finance data source configuration"""
class MetaCCXTConfig(type):
@property
@@ -146,7 +140,7 @@ class MetaCCXTConfig(type):
class CCXTConfig(metaclass=MetaCCXTConfig):
"""CCXT 加密货币数据源配置"""
"""CCXT cryptocurrency data source configuration."""
pass
@@ -166,5 +160,4 @@ class MetaAkshareConfig(type):
class AkshareConfig(metaclass=MetaAkshareConfig):
"""Akshare 数据源配置"""
pass
"""Akshare data source configuration"""
+18 -20
View File
@@ -1,11 +1,10 @@
"""
数据库和缓存配置
Database and cache configuration
"""
import os
class MetaRedisConfig(type):
"""Redis 配置"""
"""Redis configuration"""
@property
def HOST(cls):
return os.getenv('REDIS_HOST', 'localhost')
@@ -36,22 +35,21 @@ class MetaRedisConfig(type):
class RedisConfig(metaclass=MetaRedisConfig):
"""Redis 缓存配置"""
"""Redis cache configuration."""
@classmethod
def get_url(cls) -> str:
"""获取 Redis 连接 URL"""
"""Get the Redis connection URL."""
if cls.PASSWORD:
return f"redis://:{cls.PASSWORD}@{cls.HOST}:{cls.PORT}/{cls.DB}"
return f"redis://{cls.HOST}:{cls.PORT}/{cls.DB}"
class MetaCacheConfig(type):
"""缓存业务配置"""
@property
"""Cache business configuration."""
def ENABLED(cls):
# 强制默认关闭,除非环境变量显式开启
# Forced to be off by default unless explicitly enabled by an environment variable
return os.getenv('CACHE_ENABLED', 'False').lower() == 'true'
@property
@@ -61,15 +59,15 @@ class MetaCacheConfig(type):
@property
def KLINE_CACHE_TTL(cls):
return {
'1m': 5, # 1分钟K线缓存5秒
'3m': 30, # 3分钟K线缓存30秒
'5m': 60, # 5分钟K线缓存1分钟
'15m': 300, # 15分钟K线缓存5分钟
'30m': 300, # 30分钟K线缓存5分钟
'1H': 300, # 1小时K线缓存5分钟
'4H': 300, # 4小时K线缓存5分钟
'1D': 300, # 日K线缓存5分钟
# 兼容小写
'1m': 5, # K-line cache for 1 minute and 5 seconds
'3m': 30, # 3 minutes K-line cache 30 seconds
'5m': 60, # 5 minutes K-line cache 1 minute
'15m': 300, # 15 minutes K-line cache 5 minutes
'30m': 300, # 30 minutes K-line cache 5 minutes
'1H': 300, # 1 hour K-line cache for 5 minutes
'4H': 300, # 4 hours K-line cache for 5 minutes
'1D': 300, # Daily K-line cache for 5 minutes
# Compatible with lowercase
'1h': 300,
'4h': 300,
'1d': 300,
@@ -85,5 +83,5 @@ class MetaCacheConfig(type):
class CacheConfig(metaclass=MetaCacheConfig):
"""缓存配置"""
"""Cache configuration."""
pass
+11 -11
View File
@@ -1,11 +1,11 @@
"""
应用主配置
Apply main configuration
"""
import os
class MetaConfig(type):
# ==================== 服务配置 ====================
# 服务启动参数通常由环境变量或命令行参数决定,不建议从数据库读取
# ==================== Service Configuration ====================
# Service startup parameters are usually determined by environment variables or command line parameters. Reading from the database is not recommended.
@property
def HOST(cls):
@@ -27,7 +27,7 @@ class MetaConfig(type):
def VERSION(cls):
return '2.0.0'
# ==================== 认证配置 ====================
# ==================== Authentication configuration ====================
@property
def SECRET_KEY(cls):
return os.getenv('SECRET_KEY', 'quantdinger-secret-key-change-me')
@@ -40,8 +40,8 @@ class MetaConfig(type):
def ADMIN_PASSWORD(cls):
return os.getenv('ADMIN_PASSWORD', '123456')
# ==================== 日志配置 ====================
# 日志配置通常在应用启动最早阶段需要,建议保持环境变量
# ==================== Log configuration ====================
# Log configuration is usually required at the earliest stage of application startup. It is recommended to maintain environment variables.
@property
def LOG_LEVEL(cls):
@@ -63,7 +63,7 @@ class MetaConfig(type):
def LOG_BACKUP_COUNT(cls):
return int(os.getenv('LOG_BACKUP_COUNT', 5))
# ==================== 安全配置 ====================
# ==================== Security Configuration ====================
@property
def RATE_LIMIT(cls):
@@ -71,7 +71,7 @@ class MetaConfig(type):
val = load_addon_config().get('app', {}).get('rate_limit')
return int(val) if val is not None else int(os.getenv('RATE_LIMIT', 100))
# ==================== 功能开关 ====================
# ==================== Function switch ====================
@property
def ENABLE_CACHE(cls):
@@ -90,9 +90,9 @@ class MetaConfig(type):
return os.getenv('ENABLE_REQUEST_LOG', 'True').lower() == 'true'
class Config(metaclass=MetaConfig):
"""应用配置类"""
"""Application configuration class."""
@classmethod
def get_log_path(cls) -> str:
"""获取日志文件完整路径"""
"""Get the full path of the log file."""
return os.path.join(cls.LOG_DIR, cls.LOG_FILE)
+10 -10
View File
@@ -1,11 +1,11 @@
"""
数据源模块
支持多种市场的K线数据获取
data source module
Support K-line data acquisition for multiple markets
改进版本(参考 daily_stock_analysis 项目):
- 熔断器保护 (circuit_breaker)
- 数据缓存 (cache_manager)
- 防封禁策略 (rate_limiter)
Improved version (refer to daily_stock_analysis project):
- Fuse protection (circuit_breaker)
- Data cache (cache_manager)
- Anti-ban strategy (rate_limiter)
"""
from app.data_sources.factory import DataSourceFactory
from app.data_sources.circuit_breaker import (
@@ -26,17 +26,17 @@ from app.data_sources.rate_limiter import (
)
__all__ = [
# 工厂
# factory
'DataSourceFactory',
# 熔断器
# fuse
'CircuitBreaker',
'get_realtime_circuit_breaker',
# 缓存
# cache
'DataCache',
'get_realtime_cache',
'get_kline_cache',
'get_stock_info_cache',
# 限流器
# current limiter
'RateLimiter',
'get_random_user_agent',
'random_sleep',
+33 -34
View File
@@ -1,6 +1,6 @@
"""
数据源基类
定义统一的数据源接口
Data source base class
Define a unified data source interface
"""
from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional
@@ -11,7 +11,7 @@ from app.utils.logger import get_logger
logger = get_logger(__name__)
# K线周期映射(秒数)
# K-line cycle mapping (seconds)
TIMEFRAME_SECONDS = {
'1m': 60,
'5m': 300,
@@ -25,8 +25,8 @@ TIMEFRAME_SECONDS = {
class BaseDataSource(ABC):
"""数据源基类"""
"""Data source base class."""
name: str = "base"
@abstractmethod
@@ -38,16 +38,16 @@ class BaseDataSource(ABC):
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
获取K线数据
Get K-line data
Args:
symbol: 交易对/股票代码
timeframe: 时间周期 (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W)
limit: 数据条数
before_time: 获取此时间之前的数据(Unix时间戳,秒)
symbol: trading pair/stock code
timeframe: time period (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W)
limit: number of data items
before_time: Get data before this time (Unix timestamp, seconds)
Returns:
K线数据列表,格式:
K-line data list, format:
[{"time": int, "open": float, "high": float, "low": float, "close": float, "volume": float}, ...]
"""
pass
@@ -70,7 +70,7 @@ class BaseDataSource(ABC):
close: float,
volume: float
) -> Dict[str, Any]:
"""格式化单条K线数据"""
"""Format a single K-line record."""
return {
'time': timestamp,
'open': round(float(open_price), 4),
@@ -87,15 +87,15 @@ class BaseDataSource(ABC):
buffer_ratio: float = 1.2
) -> int:
"""
计算获取指定数量K线所需的时间范围(秒)
Calculate the time range (seconds) required to obtain the specified number of K-lines
Args:
timeframe: 时间周期
limit: K线数量
buffer_ratio: 缓冲系数
timeframe: time period
limit: number of K-lines
buffer_ratio: buffer coefficient
Returns:
时间范围(秒)
Time range (seconds)
"""
seconds_per_candle = TIMEFRAME_SECONDS.get(timeframe, 86400)
return int(seconds_per_candle * limit * buffer_ratio)
@@ -107,24 +107,24 @@ class BaseDataSource(ABC):
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
过滤和限制K线数据
Filter and limit K-line data
Args:
klines: K线数据列表
limit: 最大数量
before_time: 过滤此时间之后的数据
klines: K-line data list
limit: maximum quantity
before_time: Filter data after this time
Returns:
处理后的K线数据
Processed K-line data
"""
# 按时间排序
# Sort by time
klines.sort(key=lambda x: x['time'])
# 过滤时间
# filter time
if before_time:
klines = [k for k in klines if k['time'] < before_time]
# 限制数量(取最新的)
# Limit quantity (take the latest)
if len(klines) > limit:
klines = klines[-limit:]
@@ -136,12 +136,12 @@ class BaseDataSource(ABC):
klines: List[Dict[str, Any]],
timeframe: str
):
"""记录获取结果日志。
"""Record the result log.
延迟判断:
- K 线 time Unix 秒(UTC),与 datetime.now(UTC) 比较,避免本地时区误差。
- 日线/周线:最后一根通常是「上一交易日收盘」,周末/节假日可达 3~4 天,
原先用 2×86400s48h)会在周一早盘误报;改为日线最多容忍约 5 个自然日,周线更宽。
Delayed judgment:
- K-line time is Unix seconds (UTC), compared with datetime.now(UTC) to avoid local time zone errors.
- Daily/weekly line: The last line is usually the "close of the previous trading day", and it can last 3 to 4 days on weekends/holidays.
Originally, using 2×86400s (48h) would cause false alarms in Monday morning trading; instead, the daily line tolerates up to about 5 natural days, and the weekly line is wider.
"""
if klines:
latest_ts = int(klines[-1]["time"])
@@ -151,13 +151,13 @@ class BaseDataSource(ABC):
tf_sec = TIMEFRAME_SECONDS.get(timeframe, 3600)
if tf_sec < 86400:
# 分钟/小时级:超过约 2 根 K 未更新则告警
# Minute/hour level: If it exceeds about 2 K, an alarm will be issued if it is not updated.
max_diff = tf_sec * 2
elif tf_sec == 86400:
# 日线:覆盖周末 + 短假期(约 5 个自然日)
# Daily line: covering weekends + short holidays (about 5 calendar days)
max_diff = 5 * 86400
else:
# 周线:允许跨多周数据滞后
# Weekly: Allows data lags across multiple weeks
max_diff = max(tf_sec * 2, 21 * 86400)
if time_diff > max_diff:
@@ -167,4 +167,3 @@ class BaseDataSource(ABC):
)
else:
logger.warning(f"{self.name}: no data for {symbol}")
@@ -1,16 +1,16 @@
# -*- coding: utf-8 -*-
"""
===================================
数据缓存管理模块
Data cache management module
===================================
参考 daily_stock_analysis 项目实现
用于缓存实时行情和K线数据,减少重复请求
Refer to daily_stock_analysis project implementation
Used to cache realtime market and K-line data to reduce repeated requests
特性:
1. TTL (Time To Live) 过期机制
2. LRU (Least Recently Used) 淘汰策略
3. 按数据类型分区管理
characteristic:
1. TTL (Time To Live) expiration mechanism
2. LRU (Least Recently Used) elimination strategy
3. Partition management by data type
"""
import time
@@ -26,37 +26,37 @@ logger = logging.getLogger(__name__)
@dataclass
class CacheEntry:
"""缓存条目"""
"""cache entry"""
data: Any
timestamp: float
ttl: float
hit_count: int = 0
def is_expired(self) -> bool:
"""检查是否过期"""
"""Check if expired"""
return time.time() - self.timestamp > self.ttl
def age(self) -> float:
"""返回缓存年龄(秒)"""
"""Return cache age (seconds)"""
return time.time() - self.timestamp
class DataCache:
"""
数据缓存管理器
Data Cache Manager
特性:
- TTL 过期机制
- 最大容量限制
- LRU 淘汰策略
- 线程安全
characteristic:
- TTL expiration mechanism
- Maximum capacity limit
- LRU elimination strategy
- Thread safety
"""
def __init__(
self,
name: str = "default",
default_ttl: float = 600.0, # 默认10分钟
max_size: int = 1000 # 最大缓存条目数
default_ttl: float = 600.0, # Default 10 minutes
max_size: int = 1000 # Maximum number of cache entries
):
self.name = name
self.default_ttl = default_ttl
@@ -64,16 +64,16 @@ class DataCache:
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
self._lock = threading.RLock()
# 统计信息
# Statistics
self._hits = 0
self._misses = 0
def get(self, key: str) -> Optional[Any]:
"""
获取缓存数据
Get cached data
Returns:
缓存的数据,不存在或过期返回 None
Cached data, returns None if it does not exist or has expired.
"""
with self._lock:
if key not in self._cache:
@@ -82,14 +82,14 @@ class DataCache:
entry = self._cache[key]
# 检查是否过期
# Check if expired
if entry.is_expired():
del self._cache[key]
self._misses += 1
logger.debug(f"[缓存] {self.name}:{key} 已过期,删除")
return None
# 更新访问顺序(LRU
# Update access order (LRU)
self._cache.move_to_end(key)
entry.hit_count += 1
self._hits += 1
@@ -104,15 +104,15 @@ class DataCache:
ttl: Optional[float] = None
) -> None:
"""
设置缓存数据
Set cache data
Args:
key: 缓存键
data: 缓存数据
ttl: 过期时间(秒),None 使用默认值
key: cache key
data: cache data
ttl: expiration time (seconds), None uses the default value
"""
with self._lock:
# 检查容量,执行 LRU 淘汰
# Check capacity, perform LRU elimination
while len(self._cache) >= self.max_size:
oldest_key, _ = self._cache.popitem(last=False)
logger.debug(f"[缓存] {self.name} 容量已满,淘汰: {oldest_key}")
@@ -127,7 +127,7 @@ class DataCache:
logger.debug(f"[缓存更新] {self.name}:{key} TTL={actual_ttl}s")
def delete(self, key: str) -> bool:
"""删除缓存条目"""
"""Delete cache entry"""
with self._lock:
if key in self._cache:
del self._cache[key]
@@ -136,7 +136,7 @@ class DataCache:
return False
def clear(self) -> int:
"""清空缓存"""
"""Clear cache"""
with self._lock:
count = len(self._cache)
self._cache.clear()
@@ -144,7 +144,7 @@ class DataCache:
return count
def cleanup_expired(self) -> int:
"""清理过期条目"""
"""Clean up expired entries"""
with self._lock:
expired_keys = [
key for key, entry in self._cache.items()
@@ -158,7 +158,7 @@ class DataCache:
return len(expired_keys)
def stats(self) -> Dict[str, Any]:
"""获取缓存统计信息"""
"""Get cache statistics"""
with self._lock:
total_requests = self._hits + self._misses
hit_rate = self._hits / total_requests if total_requests > 0 else 0
@@ -175,43 +175,43 @@ class DataCache:
# ============================================
# 全局缓存实例
# Global cache instance
# ============================================
# 实时行情缓存(20分钟TTL
# Real-time quotation caching (20 minutes TTL)
_realtime_cache = DataCache(
name="realtime",
default_ttl=1200.0, # 20分钟
default_ttl=1200.0, # 20 minutes
max_size=6000
)
# K线数据缓存(5分钟TTL,按需缓存)
# K-line data caching (5 minutes TTL, caching on demand)
_kline_cache = DataCache(
name="kline",
default_ttl=300.0, # 5分钟
max_size=500 # 最多500个交易对
default_ttl=300.0, # 5 minutes
max_size=500 # Up to 500 trading pairs
)
# 股票基本信息缓存(1天TTL
# Stock basic information cache (1 day TTL)
_stock_info_cache = DataCache(
name="stock_info",
default_ttl=86400.0, # 24小时
default_ttl=86400.0, # 24 hours
max_size=6000
)
def get_realtime_cache() -> DataCache:
"""获取实时行情缓存"""
"""Get real-time quotation cache"""
return _realtime_cache
def get_kline_cache() -> DataCache:
"""获取K线数据缓存"""
"""Get K-line data cache"""
return _kline_cache
def get_stock_info_cache() -> DataCache:
"""获取股票信息缓存"""
"""Get stock information cache"""
return _stock_info_cache
@@ -222,9 +222,9 @@ def generate_kline_cache_key(
before_time: Optional[int] = None
) -> str:
"""
生成K线缓存键
Generate K-line cache key
格式: symbol:timeframe:limit[:before_time]
Format: symbol:timeframe:limit[:before_time]
"""
key = f"{symbol}:{timeframe}:{limit}"
if before_time:
@@ -1,16 +1,16 @@
# -*- coding: utf-8 -*-
"""
===================================
熔断器模块 (Circuit Breaker)
Circuit Breaker
===================================
参考 daily_stock_analysis 项目实现
用于管理数据源的熔断/冷却状态,避免连续失败时反复请求
Refer to daily_stock_analysis project implementation
Used to manage the fuse/cooling status of data sources to avoid repeated requests when continuous failures occur.
状态机:
CLOSED(正常) --失败N次--> OPEN(熔断)--冷却时间到--> HALF_OPEN(半开)
HALF_OPEN --成功--> CLOSED
HALF_OPEN --失败--> OPEN
State machine:
CLOSED (normal) --failed N times --> OPEN (melted) --cooling time expired --> HALF_OPEN (half open)
HALF_OPEN --Success--> CLOSED
HALF_OPEN --Failure--> OPEN
"""
import time
@@ -22,38 +22,38 @@ logger = logging.getLogger(__name__)
class CircuitState(Enum):
"""熔断器状态"""
CLOSED = "closed" # 正常状态
OPEN = "open" # 熔断状态(不可用)
HALF_OPEN = "half_open" # 半开状态(试探性请求)
"""fuse status"""
CLOSED = "closed" # normal state
OPEN = "open" # Fuse status (not available)
HALF_OPEN = "half_open" # Half-open state (exploratory request)
class CircuitBreaker:
"""
熔断器 - 管理数据源的熔断/冷却状态
Circuit Breakers - Manage the blown/cooling status of data sources
策略:
- 连续失败 N 次后进入熔断状态
- 熔断期间跳过该数据源
- 冷却时间后自动恢复半开状态
- 半开状态下单次成功则完全恢复,失败则继续熔断
Strategy:
- Enter the fuse state after N consecutive failures
- Skip this data source during the circuit breaker period
- Automatically returns to half-open state after cooling time
- In the half-open state, if a single success is successful, it will be fully restored, if it fails, the fuse will continue to be broken.
"""
def __init__(
self,
failure_threshold: int = 3, # 连续失败次数阈值
cooldown_seconds: float = 300.0, # 冷却时间(秒),默认5分钟
half_open_max_calls: int = 1 # 半开状态最大尝试次数
failure_threshold: int = 3, # Continuous failure threshold
cooldown_seconds: float = 300.0, # Cooling time (seconds), default 5 minutes
half_open_max_calls: int = 1 # Maximum number of attempts in half-open state
):
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.half_open_max_calls = half_open_max_calls
# 各数据源状态 {source_name: {state, failures, last_failure_time, half_open_calls}}
# Status of each data source {source_name: {state, failures, last_failure_time, half_open_calls}}
self._states: Dict[str, Dict[str, Any]] = {}
def _get_state(self, source: str) -> Dict[str, Any]:
"""获取或初始化数据源状态"""
"""Get or initialize data source status"""
if source not in self._states:
self._states[source] = {
'state': CircuitState.CLOSED,
@@ -66,10 +66,10 @@ class CircuitBreaker:
def is_available(self, source: str) -> bool:
"""
检查数据源是否可用
Check if the data source is available
返回 True 表示可以尝试请求
返回 False 表示应跳过该数据源
Return True to indicate that the request can be attempted
Return False to indicate that the data source should be skipped
"""
state = self._get_state(source)
current_time = time.time()
@@ -78,10 +78,10 @@ class CircuitBreaker:
return True
if state['state'] == CircuitState.OPEN:
# 检查冷却时间
# Check cool down time
time_since_failure = current_time - state['last_failure_time']
if time_since_failure >= self.cooldown_seconds:
# 冷却完成,进入半开状态
# Cooling is completed and enters the half-open state
state['state'] = CircuitState.HALF_OPEN
state['half_open_calls'] = 0
logger.info(f"[熔断器] {source} 冷却完成,进入半开状态")
@@ -92,7 +92,7 @@ class CircuitBreaker:
return False
if state['state'] == CircuitState.HALF_OPEN:
# 半开状态下限制请求次数
# Limit the number of requests in the half-open state
if state['half_open_calls'] < self.half_open_max_calls:
return True
return False
@@ -100,21 +100,21 @@ class CircuitBreaker:
return True
def record_success(self, source: str) -> None:
"""记录成功请求"""
"""Log successful request"""
state = self._get_state(source)
if state['state'] == CircuitState.HALF_OPEN:
# 半开状态下成功,完全恢复
# Successful in half-open state, full recovery
logger.info(f"[熔断器] {source} 半开状态请求成功,恢复正常")
# 重置状态
# reset state
state['state'] = CircuitState.CLOSED
state['failures'] = 0
state['half_open_calls'] = 0
state['last_error'] = None
def record_failure(self, source: str, error: Optional[str] = None) -> None:
"""记录失败请求"""
"""Logging failed requests"""
state = self._get_state(source)
current_time = time.time()
@@ -123,12 +123,12 @@ class CircuitBreaker:
state['last_error'] = error
if state['state'] == CircuitState.HALF_OPEN:
# 半开状态下失败,继续熔断
# Fails in half-open state and continues to fuse
state['state'] = CircuitState.OPEN
state['half_open_calls'] = 0
logger.warning(f"[熔断器] {source} 半开状态请求失败,继续熔断 {self.cooldown_seconds}s")
elif state['failures'] >= self.failure_threshold:
# 达到阈值,进入熔断
# reaches the threshold and enters the circuit breaker
state['state'] = CircuitState.OPEN
logger.warning(f"[熔断器] {source} 连续失败 {state['failures']} 次,进入熔断状态 "
f"(冷却 {self.cooldown_seconds}s)")
@@ -136,7 +136,7 @@ class CircuitBreaker:
logger.warning(f"[熔断器] 最后错误: {error}")
def get_status(self) -> Dict[str, Dict[str, Any]]:
"""获取所有数据源状态"""
"""Get all data source status"""
return {
source: {
'state': info['state'].value,
@@ -147,7 +147,7 @@ class CircuitBreaker:
}
def reset(self, source: Optional[str] = None) -> None:
"""重置熔断器状态"""
"""Reset fuse status"""
if source:
if source in self._states:
del self._states[source]
@@ -158,17 +158,17 @@ class CircuitBreaker:
# ============================================
# 全局熔断器实例
# Global circuit breaker example
# ============================================
# 实时行情熔断器(更严格的策略)
# Real-time market circuit breaker (more stringent strategy)
_realtime_circuit_breaker = CircuitBreaker(
failure_threshold=2, # 连续失败2次熔断
cooldown_seconds=180.0, # 冷却3分钟
failure_threshold=2, # Failed 2 times in a row
cooldown_seconds=180.0, # Cool for 3 minutes
half_open_max_calls=1
)
def get_realtime_circuit_breaker() -> CircuitBreaker:
"""获取实时行情熔断器"""
"""Get realtime market breaker"""
return _realtime_circuit_breaker
+56 -56
View File
@@ -1,6 +1,6 @@
"""
加密货币数据源
使用 CCXT (Coinbase) 获取数据
Cryptocurrency data source
Get data using CCXT (Coinbase)
"""
from typing import Dict, List, Any, Optional, Tuple
from datetime import datetime, timedelta
@@ -14,14 +14,14 @@ logger = get_logger(__name__)
class CryptoDataSource(BaseDataSource):
"""加密货币数据源"""
"""Cryptocurrency data source"""
name = "Crypto/CCXT"
# 时间周期映射
# time period mapping
TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP
# 常见的报价货币列表(按优先级排序)
# List of common quote currencies (sorted by priority)
COMMON_QUOTES = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB', 'EUR', 'GBP']
def __init__(self):
@@ -30,7 +30,7 @@ class CryptoDataSource(BaseDataSource):
'enableRateLimit': CCXTConfig.ENABLE_RATE_LIMIT
}
# 如果配置了代理
# If a proxy is configured
if CCXTConfig.PROXY:
config['proxies'] = {
'http': CCXTConfig.PROXY,
@@ -39,7 +39,7 @@ class CryptoDataSource(BaseDataSource):
exchange_id = CCXTConfig.DEFAULT_EXCHANGE
# 动态加载交易所类
# Dynamically loading exchange classes
if not hasattr(ccxt, exchange_id):
logger.warning(f"CCXT exchange '{exchange_id}' not found, falling back to 'coinbase'")
exchange_id = 'coinbase'
@@ -47,17 +47,17 @@ class CryptoDataSource(BaseDataSource):
exchange_class = getattr(ccxt, exchange_id)
self.exchange = exchange_class(config)
# 延迟加载 markets(首次使用时加载)
# Lazy loading of markets (loaded on first use)
self._markets_loaded = False
self._markets_cache = None
def _ensure_markets_loaded(self) -> bool:
"""确保 markets 已加载(用于符号验证)"""
"""Make sure markets are loaded (for symbol verification)"""
if self._markets_loaded and self._markets_cache is not None:
return True
try:
# 某些交易所需要显式加载 markets
# Some exchanges require explicit loading of markets
if hasattr(self.exchange, 'load_markets'):
self.exchange.load_markets(reload=False)
self._markets_cache = getattr(self.exchange, 'markets', {})
@@ -69,13 +69,13 @@ class CryptoDataSource(BaseDataSource):
def _normalize_symbol(self, symbol: str) -> Tuple[str, str]:
"""
规范化符号格式,返回 (normalized_symbol, base_currency)
Normalized symbol format, returns (normalized_symbol, base_currency)
处理各种输入格式:
Handles various input formats:
- BTC/USDT -> BTC/USDT
- BTCUSDT -> BTC/USDT
- BTC/USDT:USDT -> BTC/USDT
- BTC -> BTC/USDT (默认)
- BTC -> BTC/USDT (default)
- PI, TRX -> PI/USDT, TRX/USDT
"""
if not symbol:
@@ -83,13 +83,13 @@ class CryptoDataSource(BaseDataSource):
sym = symbol.strip()
# 移除 swap/futures 后缀
# Remove swap/futures suffix
if ':' in sym:
sym = sym.split(':', 1)[0]
sym = sym.upper()
# 如果已经有分隔符,直接解析
# If there is already a separator, parse it directly
if '/' in sym:
parts = sym.split('/', 1)
base = parts[0].strip()
@@ -97,26 +97,26 @@ class CryptoDataSource(BaseDataSource):
if base and quote:
return f"{base}/{quote}", base
# 尝试从常见报价货币中识别
# Try to identify from common quote currencies
for quote in self.COMMON_QUOTES:
if sym.endswith(quote) and len(sym) > len(quote):
base = sym[:-len(quote)]
if base:
return f"{base}/{quote}", base
# 如果无法识别,默认使用 USDT
# If not recognized, USDT will be used by default.
return f"{sym}/USDT", sym
def _find_valid_symbol(self, base: str, preferred_quote: str = 'USDT') -> Optional[str]:
"""
在交易所的 markets 中查找有效的符号
Find valid symbols in the exchange's markets
Args:
base: 基础货币(如 'PI', 'TRX'
preferred_quote: 首选的报价货币
base: base currency (e.g. 'PI', 'TRX')
preferred_quote: preferred quote currency
Returns:
找到的有效符号,如果找不到则返回 None
A valid symbol found, or None if not found
"""
if not self._ensure_markets_loaded():
return None
@@ -125,14 +125,14 @@ class CryptoDataSource(BaseDataSource):
if not markets:
return None
# 按优先级尝试不同的报价货币
# Try different quote currencies by priority
quotes_to_try = [preferred_quote] + [q for q in self.COMMON_QUOTES if q != preferred_quote]
for quote in quotes_to_try:
candidate = f"{base}/{quote}"
if candidate in markets:
market = markets[candidate]
# 检查市场是否活跃
# Check if the market is active
if market.get('active', True):
return candidate
@@ -140,14 +140,14 @@ class CryptoDataSource(BaseDataSource):
def _normalize_symbol_for_exchange(self, symbol: str) -> str:
"""
根据交易所特性规范化符号
Standardize symbols based on exchange characteristics
不同交易所的符号格式要求:
- Binance: BTC/USDT (标准格式)
- OKX: BTC/USDT (标准格式,但某些币种可能不支持)
- Coinbase: BTC/USD (通常使用 USD 而不是 USDT)
- Kraken: XBT/USD (BTC 映射为 XBT)
- Bitfinex: tBTCUST (特殊格式)
Symbol format requirements for different exchanges:
- Binance: BTC/USDT (standard format)
- OKX: BTC/USDT (standard format, but some currencies may not be supported)
- Coinbase: BTC/USD (usually use USD instead of USDT)
- Kraken: XBT/USD (BTC is mapped to XBT)
- Bitfinex: tBTCUST (special format)
"""
normalized, base = self._normalize_symbol(symbol)
@@ -156,9 +156,9 @@ class CryptoDataSource(BaseDataSource):
exchange_id = getattr(self.exchange, 'id', '').lower()
# 特殊处理:某些交易所的符号映射
# Special handling: symbol mapping for certain exchanges
if exchange_id == 'coinbase':
# Coinbase 通常使用 USD 而不是 USDT
# Coinbase usually uses USD instead of USDT
if normalized.endswith('/USDT'):
usd_version = normalized.replace('/USDT', '/USD')
if self._ensure_markets_loaded():
@@ -166,7 +166,7 @@ class CryptoDataSource(BaseDataSource):
if usd_version in markets:
return usd_version
# 尝试在交易所中查找有效符号
# Try to find a valid symbol on the exchange
if self._ensure_markets_loaded():
valid_symbol = self._find_valid_symbol(base, normalized.split('/')[1] if '/' in normalized else 'USDT')
if valid_symbol:
@@ -181,19 +181,19 @@ class CryptoDataSource(BaseDataSource):
Accepts common formats:
- BTC/USDT, BTCUSDT, BTC/USDT:USDT
- PI, TRX (will be normalized and searched across exchanges)
- 自动适配不同交易所的符号格式要求
- Automatically adapt to the symbol format requirements of different exchanges
"""
if not symbol or not symbol.strip():
return {'last': 0, 'symbol': symbol}
# 规范化符号
# normalized notation
normalized = self._normalize_symbol_for_exchange(symbol)
if not normalized:
logger.warning(f"Failed to normalize symbol: {symbol}")
return {'last': 0, 'symbol': symbol}
# 尝试获取 ticker
# Try to get ticker
try:
ticker = self.exchange.fetch_ticker(normalized)
if ticker and isinstance(ticker, dict):
@@ -209,7 +209,7 @@ class CryptoDataSource(BaseDataSource):
])
if is_symbol_error:
# 尝试查找替代符号
# Try to find alternative symbols
base = normalized.split('/')[0] if '/' in normalized else normalized
if self._ensure_markets_loaded():
valid_symbol = self._find_valid_symbol(base)
@@ -222,7 +222,7 @@ class CryptoDataSource(BaseDataSource):
except Exception as e2:
logger.debug(f"Alternative symbol {valid_symbol} also failed: {e2}")
# 如果所有尝试都失败,记录警告并返回默认值
# If all attempts fail, log a warning and return a default value
logger.warning(
f"Symbol '{symbol}' (normalized: {normalized}) not found on {self.exchange.id}. "
f"Error: {str(e)[:100]}"
@@ -237,20 +237,20 @@ class CryptoDataSource(BaseDataSource):
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取加密货币K线数据"""
"""Get cryptocurrency K-line data"""
klines = []
try:
ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, '1d')
# 使用统一的符号规范化方法
# Use a unified symbol normalization method
symbol_pair = self._normalize_symbol_for_exchange(symbol)
if not symbol_pair:
logger.warning(f"Failed to normalize symbol for K-line: {symbol}")
return []
# logger.info(f"获取加密货币K线: {symbol_pair}, 周期: {ccxt_timeframe}, 条数: {limit}")
# logger.info(f"Get cryptocurrency K-line: {symbol_pair}, period: {ccxt_timeframe}, number of bars: {limit}")
ohlcv = self._fetch_ohlcv(symbol_pair, ccxt_timeframe, limit, before_time, timeframe)
@@ -258,12 +258,12 @@ class CryptoDataSource(BaseDataSource):
logger.warning(f"CCXT returned no K-lines: {symbol_pair}")
return []
# 转换数据格式
# Convert data format
for candle in ohlcv:
if len(candle) < 6:
continue
klines.append(self.format_kline(
timestamp=int(candle[0] / 1000), # 毫秒转秒
timestamp=int(candle[0] / 1000), # Milliseconds to seconds
open_price=candle[1],
high=candle[2],
low=candle[3],
@@ -271,10 +271,10 @@ class CryptoDataSource(BaseDataSource):
volume=candle[5]
))
# 过滤和限制
# Filter and restrict
klines = self.filter_and_limit(klines, limit, before_time)
# 记录结果
# Record results
self.log_result(symbol, klines, timeframe)
except Exception as e:
@@ -292,19 +292,19 @@ class CryptoDataSource(BaseDataSource):
before_time: Optional[int],
timeframe: str
) -> List:
"""获取OHLCV数据(支持分页获取完整数据)"""
"""Obtain OHLCV data (supports paging to obtain complete data)"""
try:
if before_time:
# 计算时间范围
# Calculation time range
total_seconds = self.calculate_time_range(timeframe, limit)
end_time = datetime.fromtimestamp(before_time)
start_time = end_time - timedelta(seconds=total_seconds)
since = int(start_time.timestamp() * 1000)
end_ms = before_time * 1000
# logger.info(f"历史数据请求: since={since//1000}, end={before_time}, 时间跨度={total_seconds/86400:.1f}")
# logger.info(f"Historical data request: since={since//1000}, end={before_time}, time span={total_seconds/86400:.1f} days")
# 分页获取数据,直到覆盖完整时间范围
# Fetch data in pages until the complete time range is covered
all_ohlcv = []
batch_limit = 300 # Coinbase limit is often 300, safer than 1000
current_since = since
@@ -322,25 +322,25 @@ class CryptoDataSource(BaseDataSource):
all_ohlcv.extend(batch)
# 获取最后一条数据的时间,作为下次请求的起始时间
# The time when the last piece of data is obtained is used as the starting time of the next request
last_timestamp = batch[-1][0]
# 如果最后一条数据时间超过了结束时间,或者返回数据少于请求量,说明已经获取完毕
# If the time of the last data exceeds the end time, or the returned data is less than the requested amount, it means that the acquisition has been completed.
# if last_timestamp >= end_ms or len(batch) < batch_limit:
if last_timestamp >= end_ms:
break
# 下次从最后一条的下一个时间点开始
# Next time, start from the next time point of the last item
timeframe_ms = TIMEFRAME_SECONDS.get(timeframe, 86400) * 1000
current_since = last_timestamp + timeframe_ms
# logger.info(f"分页获取中: 已获取 {len(all_ohlcv)} 条, 继续从 {datetime.fromtimestamp(current_since/1000)}")
# logger.info(f"Getting in paging: {len(all_ohlcv)} items have been obtained, continue from {datetime.fromtimestamp(current_since/1000)}")
ohlcv = all_ohlcv
else:
ohlcv = self.exchange.fetch_ohlcv(symbol_pair, ccxt_timeframe, limit=limit)
# logger.info(f"CCXT 返回 {len(ohlcv) if ohlcv else 0} 条数据")
# logger.info(f"CCXT returns {len(ohlcv) if ohlcv else 0} pieces of data")
return ohlcv
except Exception as e:
@@ -355,7 +355,7 @@ class CryptoDataSource(BaseDataSource):
before_time: Optional[int],
timeframe: str
) -> List:
"""备用获取方法"""
"""Alternate acquisition method"""
try:
total_seconds = self.calculate_time_range(timeframe, limit)
@@ -367,7 +367,7 @@ class CryptoDataSource(BaseDataSource):
since = int((datetime.now() - timedelta(seconds=total_seconds)).timestamp() * 1000)
ohlcv = self.exchange.fetch_ohlcv(symbol_pair, ccxt_timeframe, since=since, limit=limit)
# logger.info(f"CCXT 备用方法返回 {len(ohlcv) if ohlcv else 0} 条数据")
# logger.info(f"CCXT alternative method returns {len(ohlcv) if ohlcv else 0} pieces of data")
return ohlcv
except Exception as e:
logger.error(f"CCXT fallback method also failed: {str(e)}")
+22 -22
View File
@@ -1,6 +1,6 @@
"""
数据源工厂
根据市场类型返回对应的数据源
data source factory
Return the corresponding data source according to the market type
"""
from typing import Dict, List, Any, Optional
@@ -11,20 +11,20 @@ logger = get_logger(__name__)
class DataSourceFactory:
"""数据源工厂"""
"""data source factory"""
_sources: Dict[str, BaseDataSource] = {}
@classmethod
def get_source(cls, market: str) -> BaseDataSource:
"""
获取指定市场的数据源
Get the data source for the specified market
Args:
market: 市场类型 (Crypto, USStock, Forex, Futures)
market: market type (Crypto, USStock, Forex, Futures)
Returns:
数据源实例
Data source instance
"""
if market not in cls._sources:
cls._sources[market] = cls._create_source(market)
@@ -48,7 +48,7 @@ class DataSourceFactory:
@classmethod
def _create_source(cls, market: str) -> BaseDataSource:
"""创建数据源实例"""
"""Create data source instance"""
if market == 'Crypto':
from app.data_sources.crypto import CryptoDataSource
return CryptoDataSource()
@@ -74,23 +74,23 @@ class DataSourceFactory:
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
获取K线数据的便捷方法
A convenient way to obtain K-line data
Args:
market: 市场类型
symbol: 交易对/股票代码
timeframe: 时间周期
limit: 数据条数
before_time: 获取此时间之前的数据
market: market type
symbol: trading pair/stock code
timeframe: time period
limit: number of data items
before_time: Get data before this time
Returns:
K线数据列表
K-line data list
"""
try:
source = cls.get_source(market)
klines = source.get_kline(symbol, timeframe, limit, before_time)
# 确保数据按时间排序
# Make sure the data is sorted by time
klines.sort(key=lambda x: x['time'])
return klines
@@ -101,17 +101,17 @@ class DataSourceFactory:
@classmethod
def get_ticker(cls, market: str, symbol: str) -> Dict[str, Any]:
"""
获取实时报价的便捷方法
The convenient way to get realtime quotes
Args:
market: 市场类型
symbol: 交易对/股票代码
market: market type
symbol: trading pair/stock code
Returns:
实时报价数据: {
'last': 最新价,
'change': 涨跌额,
'changePercent': 涨跌幅,
Real-time quotation data: {
'last': latest price,
'change': change amount,
'changePercent': increase or decrease,
...
}
"""
+85 -85
View File
@@ -1,6 +1,6 @@
"""
外汇数据源
使用 Tiingo 获取外汇数据
Forex data source
Get Forex Data with Tiingo
"""
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
@@ -14,39 +14,39 @@ from app.config import TiingoConfig, APIKeys
logger = get_logger(__name__)
# 全局缓存 - 减少 Tiingo API 调用
# Global Cache - Reduce Tiingo API calls
_forex_cache: Dict[str, Dict[str, Any]] = {}
_forex_cache_lock = threading.Lock()
_FOREX_CACHE_TTL = 60 # 外汇价格缓存 60 秒 (Tiingo 免费 API 限制严格)
_FOREX_CACHE_TTL = 60 # Forex price caching for 60 seconds (Tiingo free API has strict limits)
class ForexDataSource(BaseDataSource):
"""外汇数据源 (Tiingo)"""
"""Forex data source (Tiingo)"""
name = "Forex/Tiingo"
# Tiingo resampleFreq 映射
# Tiingo 免费账户支持: 5min, 15min, 30min, 1hour, 4hour, 1day
# 注意: 1min 需要付费订阅, 1week/1month 不被 Tiingo FX API 支持
# Tiingo resampleFreq mapping
# Tiingo free account support: 5min, 15min, 30min, 1hour, 4hour, 1day
# Note: 1min requires paid subscription, 1week/1month is not supported by Tiingo FX API
TIMEFRAME_MAP = {
'1m': '1min', # 需要付费订阅
'1m': '1min', # Paid subscription required
'5m': '5min',
'15m': '15min',
'30m': '30min',
'1H': '1hour',
'4H': '4hour',
'1D': '1day',
'1W': None, # Tiingo 不支持,需要聚合
'1M': None # Tiingo 不支持,需要聚合
'1W': None, # Tiingo does not support it and needs to be aggregated.
'1M': None # Tiingo does not support it and needs to be aggregated.
}
# 外汇对映射 (Tiingo 使用标准 ticker,如 eurusd, audusd)
# 大写也可以,Tiingo 通常不区分大小写,但建议统一
# Forex pair mapping (Tiingo uses standard tickers such as eurusd, audusd)
# Uppercase letters are also acceptable. Tiingo is usually not case-sensitive, but uniformity is recommended.
SYMBOL_MAP = {
# 贵金属 (Tiingo 不一定支持所有 OANDA 格式的贵金属,通常是 XAUUSD)
# Precious metals (Tiingo does not necessarily support all precious metals in OANDA format, usually XAUUSD)
'XAUUSD': 'xauusd',
'XAGUSD': 'xagusd',
# 主要货币对
# major currency pairs
'EURUSD': 'eurusd',
'GBPUSD': 'gbpusd',
'USDJPY': 'usdjpy',
@@ -63,18 +63,18 @@ class ForexDataSource(BaseDataSource):
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
获取外汇实时报价
Get realtime quotes for foreign exchange
使用 Tiingo FX Top-of-Book API 获取实时报价
带有 60 秒缓存以避免频繁触发 Tiingo 速率限制
Get realtime quotes using the Tiingo FX Top-of-Book API
Comes with 60 second cache to avoid triggering Tiingo rate limit frequently
Returns:
dict: {
'last': 当前价格 (mid price),
'bid': 买价,
'ask': 卖价,
'change': 涨跌额,
'changePercent': 涨跌幅
'last': current price (mid price),
'bid': buying price,
'ask': selling price,
'change': change amount,
'changePercent': increase or decrease
}
"""
api_key = APIKeys.TIINGO_API_KEY
@@ -82,7 +82,7 @@ class ForexDataSource(BaseDataSource):
logger.warning("Tiingo API key not configured")
return {'last': 0, 'symbol': symbol}
# 检查缓存
# Check cache
cache_key = f"ticker_{symbol}"
with _forex_cache_lock:
cached = _forex_cache.get(cache_key)
@@ -93,7 +93,7 @@ class ForexDataSource(BaseDataSource):
return cached
try:
# 解析 symbol
# parse symbol
tiingo_symbol = self.SYMBOL_MAP.get(symbol)
if not tiingo_symbol:
tiingo_symbol = symbol.lower()
@@ -106,7 +106,7 @@ class ForexDataSource(BaseDataSource):
'token': api_key
}
# 重试逻辑:处理 429 速率限制
# Retry logic: Handling 429 rate limiting
for attempt in range(3):
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
if response.status_code == 429:
@@ -119,7 +119,7 @@ class ForexDataSource(BaseDataSource):
if response.status_code == 429:
logger.warning("Tiingo rate limit exceeded for ticker request")
logger.info("Note: Tiingo 1-minute forex data requires a paid subscription")
# 返回缓存数据(如果有的话,即使已过期)
# Return cached data (if available, even if expired)
with _forex_cache_lock:
if cache_key in _forex_cache:
logger.info(f"Returning stale cache for {symbol} due to rate limit")
@@ -136,19 +136,19 @@ class ForexDataSource(BaseDataSource):
ask = float(item.get('askPrice', 0) or 0)
mid = float(item.get('midPrice', 0) or 0)
# 如果没有 midPrice,计算中间价
# If there is no midPrice, calculate the mid price
if not mid and bid and ask:
mid = (bid + ask) / 2
last_price = mid or bid or ask
# 获取前一天收盘价来计算涨跌(需要额外请求日线数据)
# Get the closing price of the previous day to calculate the rise and fall (additional request for daily data is required)
prev_close = 0
change = 0
change_pct = 0
try:
# 获取昨日收盘价
# Get yesterday's closing price
yesterday = (datetime.now() - timedelta(days=2)).strftime('%Y-%m-%d')
today = datetime.now().strftime('%Y-%m-%d')
price_url = f"{self.base_url}/fx/{tiingo_symbol}/prices"
@@ -167,7 +167,7 @@ class ForexDataSource(BaseDataSource):
change = last_price - prev_close
change_pct = (change / prev_close) * 100
except Exception:
pass # 涨跌计算失败不影响主要功能
pass # Failure to calculate the rise or fall does not affect the main functions
result = {
'last': round(last_price, 5),
@@ -179,7 +179,7 @@ class ForexDataSource(BaseDataSource):
'_cache_time': time.time()
}
# 缓存结果
# cache results
with _forex_cache_lock:
_forex_cache[cache_key] = result
@@ -191,7 +191,7 @@ class ForexDataSource(BaseDataSource):
return {'last': 0, 'symbol': symbol}
def _get_timeframe_seconds(self, timeframe: str) -> int:
"""获取时间周期对应的秒数"""
"""Get the number of seconds corresponding to the time period"""
return TIMEFRAME_SECONDS.get(timeframe, 86400)
def get_kline(
@@ -202,80 +202,80 @@ class ForexDataSource(BaseDataSource):
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
获取外汇K线数据
Get foreign exchange K-line data
Args:
symbol: 外汇对代码(如 XAUUSD, EURUSD
timeframe: 时间周期
limit: 数据条数
before_time: 结束时间戳
symbol: Forex pair symbol (such as XAUUSD, EURUSD)
timeframe: time period
limit: number of data items
before_time: end timestamp
"""
# 动态获取 API Key
# Dynamically obtain API Key
api_key = APIKeys.TIINGO_API_KEY
if not api_key:
logger.error("Tiingo API key is not configured")
return []
try:
# 1. 解析 Symbol
# 1. Parse Symbol
tiingo_symbol = self.SYMBOL_MAP.get(symbol)
if not tiingo_symbol:
# 尝试智能转换: EURUSD -> eurusd
# Try smart conversion: EURUSD -> eurusd
tiingo_symbol = symbol.lower()
# 2. 解析 Resolution (resampleFreq)
# 2. Analysis Resolution (resampleFreq)
resample_freq = self.TIMEFRAME_MAP.get(timeframe)
# 特殊处理:1W/1M 需要用日线聚合
# Special treatment: 1W/1M requires daily aggregation
aggregate_to_weekly = (timeframe == '1W')
aggregate_to_monthly = (timeframe == '1M')
original_limit = limit # 保存原始请求数量
original_limit = limit # Save original request quantity
if aggregate_to_weekly or aggregate_to_monthly:
# 用日线数据聚合
# Aggregate using daily data
resample_freq = '1day'
# 限制周线/月线的最大请求数量(Tiingo 免费 API 有数据量限制)
# 周线最多请求 100 周 = 700 天 ≈ 2年
# 月线最多请求 36 月 = 1080 天 ≈ 3年
# Limit the maximum number of weekly/monthly requests (Tiingo free API has data volume limit)
# The maximum weekly request is 100 weeks = 700 days ≈ 2 years
# The maximum monthly request is 36 months = 1080 days ≈ 3 years
max_limit = 100 if aggregate_to_weekly else 36
original_limit = min(original_limit, max_limit)
# 需要更多日线数据来聚合(周线需要7天,月线需要30天)
# More daily data is needed to aggregate (weekly lines require 7 days, monthly lines require 30 days)
limit = original_limit * (7 if aggregate_to_weekly else 30)
if not resample_freq:
logger.warning(f"Tiingo does not support timeframe: {timeframe}")
return []
# 1分钟数据需要付费订阅提示
# 1 minute data requires paid subscription reminder
if timeframe == '1m':
logger.info(f"Note: Tiingo 1-minute forex data requires a paid subscription")
# 3. 计算时间范围
# 3. Calculation time range
if before_time:
end_dt = datetime.fromtimestamp(before_time)
else:
end_dt = datetime.now()
# 根据周期和数量计算开始时间
# 注意:聚合模式下使用日线秒数计算
# Calculate start time based on period and quantity
# Note: Use daily seconds calculation in aggregation mode
if aggregate_to_weekly or aggregate_to_monthly:
tf_seconds = 86400 # 日线秒数
tf_seconds = 86400 # daily seconds
else:
tf_seconds = self._get_timeframe_seconds(timeframe)
# 多取一些缓冲时间(1.5倍,外汇周末不交易)
# Get more buffer time (1.5 times, foreign exchange does not trade on weekends)
start_dt = end_dt - timedelta(seconds=limit * tf_seconds * 1.5)
# Tiingo 免费 API 最多支持约 5 年数据,限制最大时间范围
max_days = 365 * 3 # 最多 3 年
# Tiingo free API supports up to about 5 years of data, limiting the maximum time range
max_days = 365 * 3 # up to 3 years
if (end_dt - start_dt).days > max_days:
start_dt = end_dt - timedelta(days=max_days)
logger.info(f"Tiingo: Limited date range to {max_days} days")
# 格式化日期为 YYYY-MM-DD (Tiingo 支持该格式)
# Format the date as YYYY-MM-DD (Tiingo supports this format)
start_date_str = start_dt.strftime('%Y-%m-%d')
end_date_str = end_dt.strftime('%Y-%m-%d')
# 4. API 请求(带重试逻辑)
# 4. API request (with retry logic)
# URL: https://api.tiingo.com/tiingo/fx/{ticker}/prices
url = f"{self.base_url}/fx/{tiingo_symbol}/prices"
@@ -289,9 +289,9 @@ class ForexDataSource(BaseDataSource):
# logger.info(f"Tiingo Request: {url} params={params}")
# 重试逻辑:处理 429 速率限制
# Retry logic: Handling 429 rate limiting
max_retries = 3
retry_delay = 2 #
retry_delay = 2 # Second
response = None
for attempt in range(max_retries):
@@ -299,13 +299,13 @@ class ForexDataSource(BaseDataSource):
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
if response.status_code == 429:
# 速率限制,等待后重试
# Rate limit, wait and try again
wait_time = retry_delay * (attempt + 1)
logger.warning(f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt + 1}/{max_retries})")
time.sleep(wait_time)
continue
break # 成功或其他错误,退出重试循环
break # Success or other errors, exit the retry loop
except requests.exceptions.Timeout:
if attempt < max_retries - 1:
@@ -329,7 +329,7 @@ class ForexDataSource(BaseDataSource):
response.raise_for_status()
data = response.json()
# 5. 处理响应
# 5. Process the response
# Tiingo returns a list of dicts:
# [
# {
@@ -350,15 +350,15 @@ class ForexDataSource(BaseDataSource):
klines = []
for item in data:
# 解析时间: "2023-01-01T00:00:00.000Z"
# Parsing time: "2023-01-01T00:00:00.000Z"
dt_str = item.get('date')
# Tiingo 返回的是 UTC 时间 ISO 格式,需要正确处理时区
# 将 UTC 时间转换为本地时间戳
# Tiingo returns UTC time in ISO format and needs to handle the time zone correctly.
# Convert UTC time to local timestamp
if dt_str.endswith('Z'):
dt_str = dt_str[:-1] + '+00:00' # 替换 Z 为 +00:00 表示 UTC
dt_str = dt_str[:-1] + '+00:00' # Replace Z with +00:00 for UTC
dt = datetime.fromisoformat(dt_str)
ts = int(dt.timestamp()) # 现在会正确处理 UTC 时区
ts = int(dt.timestamp()) # UTC time zone is now handled correctly
klines.append({
'time': ts,
@@ -366,13 +366,13 @@ class ForexDataSource(BaseDataSource):
'high': float(item.get('high')),
'low': float(item.get('low')),
'close': float(item.get('close')),
'volume': 0.0 # Tiingo FX 通常没有 volume
'volume': 0.0 # Tiingo FX usually does not have volume
})
# 按时间排序
# Sort by time
klines.sort(key=lambda x: x['time'])
# 如果需要聚合到周线或月线
# If you need to aggregate to weekly or monthly lines
if aggregate_to_weekly:
klines = self._aggregate_to_weekly(klines)
logger.debug(f"Aggregated {len(klines)} weekly candles from daily data")
@@ -380,11 +380,11 @@ class ForexDataSource(BaseDataSource):
klines = self._aggregate_to_monthly(klines)
logger.debug(f"Aggregated {len(klines)} monthly candles from daily data")
# 过滤到原始请求数量
# Filter to original request count
if len(klines) > original_limit:
klines = klines[-original_limit:]
# logger.info(f"获取到 {len(klines)} 条 Tiingo 外汇数据")
# logger.info(f"obtained {len(klines)} pieces of Tiingo foreign exchange data")
return klines
except requests.exceptions.RequestException as e:
@@ -395,7 +395,7 @@ class ForexDataSource(BaseDataSource):
return []
def _aggregate_to_weekly(self, daily_klines: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""将日线数据聚合为周线"""
"""Aggregate daily data into weekly data"""
if not daily_klines:
return []
@@ -405,15 +405,15 @@ class ForexDataSource(BaseDataSource):
for kline in daily_klines:
dt = datetime.fromtimestamp(kline['time'])
# 获取该日期所在周的周一
# Get the Monday of the week in which the date is located
week_start = dt - timedelta(days=dt.weekday())
week_key = week_start.strftime('%Y-%W')
if week_key != current_week:
# 保存上一周的数据
# Save data from last week
if week_data:
weekly_klines.append(week_data)
# 开始新的一周
# start a new week
current_week = week_key
week_data = {
'time': int(week_start.timestamp()),
@@ -424,20 +424,20 @@ class ForexDataSource(BaseDataSource):
'volume': kline['volume']
}
else:
# 更新本周数据
# Update this week's data
week_data['high'] = max(week_data['high'], kline['high'])
week_data['low'] = min(week_data['low'], kline['low'])
week_data['close'] = kline['close']
week_data['volume'] += kline['volume']
# 添加最后一周
# Add last week
if week_data:
weekly_klines.append(week_data)
return weekly_klines
def _aggregate_to_monthly(self, daily_klines: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""将日线数据聚合为月线"""
"""Aggregate daily data into monthly data"""
if not daily_klines:
return []
@@ -450,10 +450,10 @@ class ForexDataSource(BaseDataSource):
month_key = dt.strftime('%Y-%m')
if month_key != current_month:
# 保存上个月的数据
# Save last months data
if month_data:
monthly_klines.append(month_data)
# 开始新的一月
# start a new month
current_month = month_key
month_start = dt.replace(day=1, hour=0, minute=0, second=0)
month_data = {
@@ -465,13 +465,13 @@ class ForexDataSource(BaseDataSource):
'volume': kline['volume']
}
else:
# 更新本月数据
# Update this month's data
month_data['high'] = max(month_data['high'], kline['high'])
month_data['low'] = min(month_data['low'], kline['low'])
month_data['close'] = kline['close']
month_data['volume'] += kline['volume']
# 添加最后一月
# Add last month
if month_data:
monthly_klines.append(month_data)
+37 -37
View File
@@ -1,8 +1,8 @@
"""
期货数据源
支持:
1. 加密货币期货(Binance Futures via CCXT
2. 传统期货(Yahoo Finance
Futures data source
support:
1. Cryptocurrency Futures (Binance Futures via CCXT)
2. Traditional futures (Yahoo Finance)
"""
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
@@ -17,11 +17,11 @@ logger = get_logger(__name__)
class FuturesDataSource(BaseDataSource):
"""期货数据源"""
"""Futures data source"""
name = "Futures"
# Yahoo Finance时间周期映射
# Yahoo Finance time period mapping
YF_TIMEFRAME_MAP = {
'1m': '1m',
'5m': '5m',
@@ -33,21 +33,21 @@ class FuturesDataSource(BaseDataSource):
'1W': '1wk'
}
# CCXT时间周期映射
# CCXT time period mapping
CCXT_TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP
# 传统期货合约代码(Yahoo Finance
# Traditional futures contract code (Yahoo Finance)
YF_SYMBOLS = {
'GC': 'GC=F', # 黄金期货
'SI': 'SI=F', # 白银期货
'CL': 'CL=F', # 原油期货
'NG': 'NG=F', # 天然气期货
'ZC': 'ZC=F', # 玉米期货
'ZW': 'ZW=F', # 小麦期货
'GC': 'GC=F', # gold futures
'SI': 'SI=F', # Silver futures
'CL': 'CL=F', # Crude oil futures
'NG': 'NG=F', # Natural gas futures
'ZC': 'ZC=F', # Corn futures
'ZW': 'ZW=F', # Wheat futures
}
def __init__(self):
# 初始化CCXT(用于加密货币期货)
# Initialize CCXT (for cryptocurrency futures)
config = {
'timeout': CCXTConfig.TIMEOUT,
'enableRateLimit': CCXTConfig.ENABLE_RATE_LIMIT,
@@ -103,7 +103,7 @@ class FuturesDataSource(BaseDataSource):
return self.exchange.fetch_ticker(sym)
def _get_timeframe_seconds(self, timeframe: str) -> int:
"""获取时间周期对应的秒数"""
"""Get the number of seconds corresponding to the time period"""
return TIMEFRAME_SECONDS.get(timeframe, 86400)
def get_kline(
@@ -114,15 +114,15 @@ class FuturesDataSource(BaseDataSource):
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
获取期货K线数据
Get futures K-line data
Args:
symbol: 期货合约代码
timeframe: 时间周期
limit: 数据条数
before_time: 结束时间戳
symbol: futures contract code
timeframe: time period
limit: number of data items
before_time: end timestamp
"""
# 判断是传统期货还是加密货币期货
# Determine whether it is traditional futures or cryptocurrency futures
if symbol in self.YF_SYMBOLS or symbol.endswith('=F'):
return self._get_traditional_futures(symbol, timeframe, limit, before_time)
else:
@@ -135,19 +135,19 @@ class FuturesDataSource(BaseDataSource):
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""使用yfinance获取传统期货数据"""
"""Use yfinance to obtain traditional futures data"""
try:
# 转换symbol格式
# Convert symbol format
yf_symbol = self.YF_SYMBOLS.get(symbol, symbol)
if not yf_symbol.endswith('=F'):
yf_symbol = symbol + '=F'
# 转换时间周期
# conversion time period
yf_interval = self.YF_TIMEFRAME_MAP.get(timeframe, '1d')
# logger.info(f"获取传统期货K线: {yf_symbol}, 周期: {yf_interval}, 条数: {limit}")
# logger.info(f"Get traditional futures K-line: {yf_symbol}, period: {yf_interval}, number of bars: {limit}")
# 计算时间范围
# Calculation time range
if before_time:
end_time = datetime.fromtimestamp(before_time)
else:
@@ -156,10 +156,10 @@ class FuturesDataSource(BaseDataSource):
tf_seconds = self._get_timeframe_seconds(timeframe)
start_time = end_time - timedelta(seconds=tf_seconds * limit * 1.5)
# yfinance 的 end 参数是不包含的(exclusive),需要加一天
# The end parameter of yfinance is not included (exclusive), and one day needs to be added.
end_time_inclusive = end_time + timedelta(days=1)
# 获取数据
# Get data
ticker = yf.Ticker(yf_symbol)
df = ticker.history(
start=start_time,
@@ -171,7 +171,7 @@ class FuturesDataSource(BaseDataSource):
logger.warning(f"No data: {yf_symbol}")
return []
# 转换格式
# Convert format
klines = []
for index, row in df.iterrows():
klines.append({
@@ -187,7 +187,7 @@ class FuturesDataSource(BaseDataSource):
if len(klines) > limit:
klines = klines[-limit:]
# logger.info(f"获取到 {len(klines)} 条传统期货数据")
# logger.info(f"obtained {len(klines)} pieces of traditional futures data")
return klines
except Exception as e:
@@ -201,15 +201,15 @@ class FuturesDataSource(BaseDataSource):
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""使用CCXT获取加密货币期货数据"""
"""Obtain cryptocurrency futures data using CCXT"""
try:
# 确保symbol格式正确
# Make sure the symbol format is correct
ccxt_symbol = symbol if '/' in symbol else f"{symbol}/USDT"
ccxt_timeframe = self.CCXT_TIMEFRAME_MAP.get(timeframe, '1d')
# logger.info(f"获取加密货币期货K线: {ccxt_symbol}, 周期: {ccxt_timeframe}, 条数: {limit}")
# logger.info(f"Get cryptocurrency futures K-line: {ccxt_symbol}, period: {ccxt_timeframe}, number of bars: {limit}")
# 获取数据
# Get data
if before_time:
since_time = before_time - limit * self._get_timeframe_seconds(timeframe)
ohlcv = self.exchange.fetch_ohlcv(
@@ -225,7 +225,7 @@ class FuturesDataSource(BaseDataSource):
limit=limit
)
# 转换格式
# Convert format
klines = []
for candle in ohlcv:
klines.append({
@@ -237,7 +237,7 @@ class FuturesDataSource(BaseDataSource):
'volume': float(candle[5])
})
# logger.info(f"获取到 {len(klines)} 条加密货币期货数据")
# logger.info(f"obtained {len(klines)} pieces of cryptocurrency futures data")
return klines
except Exception as e:
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
"""
===================================
防封禁工具模块 (Rate Limiter)
Anti-ban tool module (Rate Limiter)
===================================
参考 daily_stock_analysis 项目实现
提供反爬虫策略:
1. 随机休眠(Jitter
2. 随机 User-Agent 轮换
3. 指数退避重试
4. 请求频率限制
Refer to daily_stock_analysis project implementation
Provide anti-crawler strategies:
1. Random sleep (Jitter)
2. Random User-Agent rotation
3. Exponential backoff retry
4. Request frequency limit
"""
import time
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
# ============================================
# User-Agent
# User-Agent Pond
# ============================================
USER_AGENTS = [
@@ -48,19 +48,19 @@ USER_AGENTS = [
def get_random_user_agent() -> str:
"""获取随机 User-Agent"""
"""Get a random User-Agent"""
return random.choice(USER_AGENTS)
def get_request_headers(referer: Optional[str] = None) -> dict:
"""
获取带有随机 User-Agent 的请求头
Get request header with random User-Agent
Args:
referer: 可选的 Referer
referer: optional Referer header
Returns:
请求头字典
Request header dictionary
"""
headers = {
'User-Agent': get_random_user_agent(),
@@ -77,7 +77,7 @@ def get_request_headers(referer: Optional[str] = None) -> dict:
# ============================================
# 随机休眠
# Random sleep
# ============================================
def random_sleep(
@@ -86,31 +86,31 @@ def random_sleep(
log: bool = False
) -> None:
"""
随机休眠(Jitter
Random sleep (Jitter)
防封禁策略:模拟人类行为的随机延迟
在请求之间加入不规则的等待时间
Anti-ban strategy: simulate random delays in human behavior
Incorporate irregular wait times between requests
Args:
min_seconds: 最小休眠时间(秒)
max_seconds: 最大休眠时间(秒)
log: 是否记录日志
min_seconds: Minimum sleep time (seconds)
max_seconds: Maximum sleep time (seconds)
log: whether to log
"""
sleep_time = random.uniform(min_seconds, max_seconds)
if log:
logger.debug(f"随机休眠 {sleep_time:.2f} ...")
logger.debug(f"Random hibernation {sleep_time:.2f} seconds...")
time.sleep(sleep_time)
# ============================================
# 请求频率限制器
# Request frequency limiter
# ============================================
class RateLimiter:
"""
请求频率限制器
Request frequency limiter
确保请求之间有最小间隔时间
Ensure there is a minimum amount of time between requests
"""
def __init__(
@@ -120,12 +120,12 @@ class RateLimiter:
jitter_max: float = 1.5
):
"""
初始化频率限制器
Initialize frequency limiter
Args:
min_interval: 最小请求间隔(秒)
jitter_min: 随机抖动最小值(秒)
jitter_max: 随机抖动最大值(秒)
min_interval: Minimum request interval (seconds)
jitter_min: minimum random jitter (seconds)
jitter_max: maximum random jitter (seconds)
"""
self.min_interval = min_interval
self.jitter_min = jitter_min
@@ -134,37 +134,37 @@ class RateLimiter:
def wait(self) -> float:
"""
等待直到可以发起下一次请求
Wait until the next request can be made
Returns:
实际等待的时间(秒)
Actual waiting time (seconds)
"""
wait_time = 0.0
if self._last_request_time is not None:
elapsed = time.time() - self._last_request_time
if elapsed < self.min_interval:
# 补充休眠到最小间隔
# Supplement sleep to minimum interval
wait_time = self.min_interval - elapsed
time.sleep(wait_time)
# 添加随机抖动
# Add random jitter
jitter = random.uniform(self.jitter_min, self.jitter_max)
time.sleep(jitter)
wait_time += jitter
# 记录本次请求时间
# Record the time of this request
self._last_request_time = time.time()
return wait_time
def reset(self) -> None:
"""重置限制器"""
"""reset limiter"""
self._last_request_time = None
# ============================================
# 指数退避重试装饰器
# Exponential backoff retry decorator
# ============================================
def retry_with_backoff(
@@ -176,17 +176,17 @@ def retry_with_backoff(
on_retry: Optional[Callable[[int, Exception], None]] = None
):
"""
指数退避重试装饰器
Exponential backoff retry decorator
Args:
max_attempts: 最大重试次数
base_delay: 基础延迟时间(秒)
max_delay: 最大延迟时间(秒)
exponential_base: 指数基数
exceptions: 需要重试的异常类型
on_retry: 重试时的回调函数
max_attempts: Maximum number of retries
base_delay: base delay time (seconds)
max_delay: maximum delay time (seconds)
exponential_base: exponential base
exceptions: Exception types that need to be retried
on_retry: callback function when retrying
使用示例:
Usage example:
@retry_with_backoff(max_attempts=3, exceptions=(ConnectionError, TimeoutError))
def fetch_data():
...
@@ -203,20 +203,20 @@ def retry_with_backoff(
last_exception = e
if attempt == max_attempts:
logger.error(f"[重试] {func.__name__} 已达最大重试次数 ({max_attempts}),放弃")
logger.error(f"[Retry] {func.__name__} has reached the maximum number of retries ({max_attempts}), giving up")
raise
# 计算退避延迟: base_delay * (exponential_base ^ (attempt - 1))
# Calculate the backoff delay: base_delay * (exponential_base ^ (attempt - 1))
delay = min(
base_delay * (exponential_base ** (attempt - 1)),
max_delay
)
# 添加随机抖动 (±20%)
# Add random jitter (±20%)
delay *= random.uniform(0.8, 1.2)
logger.warning(
f"[重试] {func.__name__} {attempt}/{max_attempts} 次失败: {e}, "
f"等待 {delay:.1f}s 后重试..."
f"[Retry] {func.__name__} failed for the {attempt}/{max_attempts} time: {e}, "
f"waiting {delay:.1f}s before retrying..."
)
if on_retry:
@@ -224,7 +224,7 @@ def retry_with_backoff(
time.sleep(delay)
# 不应该到达这里
# Shouldn't have gotten here
raise last_exception
return wrapper
@@ -232,24 +232,24 @@ def retry_with_backoff(
# ============================================
# 全局限流器实例
# Global current limiter example
# ============================================
# 东方财富接口限流器(较严格)
# Oriental Fortune interface current limiter (more stringent)
_eastmoney_limiter = RateLimiter(
min_interval=2.0,
jitter_min=1.0,
jitter_max=3.0
)
# 腾讯财经接口限流器(较宽松)
# Tencent Finance interface current limiter (relatively loose)
_tencent_limiter = RateLimiter(
min_interval=1.0,
jitter_min=0.5,
jitter_max=1.5
)
# Akshare 接口限流器
# Akshare interface current limiter
_akshare_limiter = RateLimiter(
min_interval=2.0,
jitter_min=1.5,
@@ -258,15 +258,15 @@ _akshare_limiter = RateLimiter(
def get_eastmoney_limiter() -> RateLimiter:
"""获取东方财富限流器"""
"""Get Oriental Wealth Current Limiter"""
return _eastmoney_limiter
def get_tencent_limiter() -> RateLimiter:
"""获取腾讯财经限流器"""
"""Get Tencent Finance current limiter"""
return _tencent_limiter
def get_akshare_limiter() -> RateLimiter:
"""获取 Akshare 限流器"""
"""Get Akshare Throttler"""
return _akshare_limiter
+45 -45
View File
@@ -1,6 +1,6 @@
"""
美股数据源
使用 yfinance finnhub 获取数据
US stock data source
Get data using yfinance and finnhub
"""
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
@@ -15,11 +15,11 @@ logger = get_logger(__name__)
class USStockDataSource(BaseDataSource):
"""美股数据源"""
"""US stock data source"""
name = "USStock/yfinance"
# yfinance 时间周期映射
# yfinance time period mapping
INTERVAL_MAP = {
'1m': '1m',
'5m': '5m',
@@ -31,7 +31,7 @@ class USStockDataSource(BaseDataSource):
'1W': '1wk'
}
# 不同周期获取数据的天数范围
# The range of days to obtain data in different periods
DAYS_MAP = {
'1m': lambda limit: min(7, max(1, (limit // 390) + 2)),
'5m': lambda limit: min(60, max(1, (limit // 78) + 2)),
@@ -44,7 +44,7 @@ class USStockDataSource(BaseDataSource):
}
def __init__(self):
# 初始化 finnhub 作为备选
# Initialize finnhub as an alternative
self.finnhub_client = None
try:
import finnhub
@@ -56,45 +56,45 @@ class USStockDataSource(BaseDataSource):
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
获取美股实时报价
Get realtime quotes for U.S. stocks
优先使用 Finnhub(更实时),降级使用 yfinance fast_info
Use Finnhub first (more real-time), downgrade to yfinance fast_info
Returns:
dict: {
'last': 当前价格,
'change': 涨跌额,
'changePercent': 涨跌幅,
'high': 最高价,
'low': 最低价,
'open': 开盘价,
'previousClose': 昨收价
'last': current price,
'change': change amount,
'changePercent': increase or decrease,
'high': highest price,
'low': lowest price,
'open': opening price,
'previousClose': yesterday's closing price
}
"""
symbol = (symbol or '').strip().upper()
# 优先使用 Finnhub(实时数据)
# Prefer using Finnhub (live data)
if self.finnhub_client:
try:
quote = self.finnhub_client.quote(symbol)
if quote and quote.get('c'):
return {
'last': quote.get('c', 0), # 当前价格
'change': quote.get('d', 0), # 涨跌额
'changePercent': quote.get('dp', 0), # 涨跌幅
'high': quote.get('h', 0), # 日内最高
'low': quote.get('l', 0), # 日内最低
'open': quote.get('o', 0), # 开盘价
'previousClose': quote.get('pc', 0) # 昨收价
'last': quote.get('c', 0), # current price
'change': quote.get('d', 0), # Changes
'changePercent': quote.get('dp', 0), # Increase or decrease
'high': quote.get('h', 0), # Best in Japan
'low': quote.get('l', 0), # Lowest within the day
'open': quote.get('o', 0), # opening price
'previousClose': quote.get('pc', 0) # Yesterday's closing price
}
except Exception as e:
logger.warning(f"Finnhub quote failed for {symbol}: {e}")
# 降级使用 yfinance
# Downgrade to use yfinance
try:
ticker = yf.Ticker(symbol)
# 尝试 fast_info(更快)
# Try fast_info (faster)
try:
fast_info = ticker.fast_info
last_price = fast_info.get('lastPrice') or fast_info.get('last_price')
@@ -115,7 +115,7 @@ class USStockDataSource(BaseDataSource):
except Exception as e:
logger.debug(f"yfinance fast_info failed for {symbol}: {e}")
# 降级使用 info(较慢但数据更全)
# Downgrade to use info (slower but more complete data)
try:
info = ticker.info
last_price = info.get('regularMarketPrice') or info.get('currentPrice')
@@ -136,7 +136,7 @@ class USStockDataSource(BaseDataSource):
except Exception as e:
logger.debug(f"yfinance info failed for {symbol}: {e}")
# 最后降级:使用最近的 1 分钟 K 线
# Last downgrade: use the most recent 1-minute K-line
try:
hist = ticker.history(period='1d', interval='1m')
if hist is not None and not hist.empty:
@@ -152,7 +152,7 @@ class USStockDataSource(BaseDataSource):
'high': float(hist['High'].max()),
'low': float(hist['Low'].min()),
'open': open_price,
'previousClose': open_price # 近似
'previousClose': open_price # approximate
}
except Exception as e:
logger.debug(f"yfinance history fallback failed for {symbol}: {e}")
@@ -169,7 +169,7 @@ class USStockDataSource(BaseDataSource):
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取美股K线数据"""
"""Get U.S. stock K-line data"""
klines = []
try:
@@ -177,7 +177,7 @@ class USStockDataSource(BaseDataSource):
days_func = self.DAYS_MAP.get(timeframe, lambda x: x + 1)
days = days_func(limit)
# 计算日期范围
# Calculate date range
if before_time:
end_date = datetime.fromtimestamp(before_time)
start_date = end_date - timedelta(days=days)
@@ -185,13 +185,13 @@ class USStockDataSource(BaseDataSource):
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# logger.info(f"使用 yfinance 获取 {symbol}, 周期: {interval}, 日期: {start_date.date()} ~ {end_date.date()}")
# logger.info(f"Use yfinance to get {symbol}, period: {interval}, date: {start_date.date()} ~ {end_date.date()}")
# 尝试 yfinance
# Try yfinance
df = self._fetch_yfinance(symbol, interval, start_date, end_date)
if df is None or df.empty:
# 尝试 finnhub
# try finnhub
if self.finnhub_client and timeframe == '1D':
klines = self._fetch_finnhub(symbol, start_date, end_date, limit)
if klines:
@@ -199,10 +199,10 @@ class USStockDataSource(BaseDataSource):
else:
klines = self._convert_dataframe(df, limit)
# 过滤和限制
# Filter and restrict
klines = self.filter_and_limit(klines, limit, before_time)
# 记录结果
# Record results
self.log_result(symbol, klines, timeframe)
except Exception as e:
@@ -213,12 +213,12 @@ class USStockDataSource(BaseDataSource):
return klines
def _fetch_yfinance(self, symbol: str, interval: str, start_date: datetime, end_date: datetime):
"""使用 yfinance 获取数据"""
"""Use yfinance to get data"""
try:
ticker = yf.Ticker(symbol)
# yfinance 的 end 参数是不包含的(exclusive),所以需要加一天才能包含 end_date 当天的数据
# 例如:end="2026-01-12" 实际只返回到 2026-01-11 的数据
# The end parameter of yfinance is not included (exclusive), so you need to add one day to include the end_date data of the current day.
# For example: end="2026-01-12" actually only returns the data of 2026-01-11
end_date_inclusive = end_date + timedelta(days=1)
df = ticker.history(
@@ -226,7 +226,7 @@ class USStockDataSource(BaseDataSource):
end=end_date_inclusive.strftime('%Y-%m-%d'),
interval=interval
)
# logger.info(f"yfinance 返回 {len(df) if df is not None and not df.empty else 0} 条数据")
# logger.info(f"yfinance returns {len(df) if df is not None and not df.empty else 0} pieces of data")
return df
except Exception as e:
logger.warning(f"yfinance fetch failed: {e}")
@@ -239,13 +239,13 @@ class USStockDataSource(BaseDataSource):
end_date: datetime,
limit: int
) -> List[Dict[str, Any]]:
"""使用 finnhub 获取日线数据"""
"""Use finnhub to get daily data"""
klines = []
try:
start_ts = int(start_date.timestamp())
end_ts = int(end_date.timestamp())
# logger.info(f"使用 Finnhub 获取 {symbol} 日线数据")
# logger.info(f"Use Finnhub to obtain {symbol} daily data")
candles = self.finnhub_client.stock_candles(symbol, 'D', start_ts, end_ts)
if candles and candles.get('s') == 'ok':
@@ -258,18 +258,18 @@ class USStockDataSource(BaseDataSource):
close=candles['c'][i],
volume=candles['v'][i]
))
# logger.info(f"Finnhub 返回 {len(klines)} 条数据")
# logger.info(f"Finnhub returns {len(klines)} pieces of data")
except Exception as e:
logger.error(f"Finnhub fetch failed: {e}")
return klines
def _convert_dataframe(self, df, limit: int) -> List[Dict[str, Any]]:
"""转换 DataFrame 为K线列表"""
"""Convert DataFrame to K-line list"""
klines = []
df = df.tail(limit).reset_index()
# 确定时间列名(日线是 Date,分钟级是 Datetime
# Determine the time column name (the daily line is Date, the minute level is Datetime)
time_col = None
if 'Datetime' in df.columns:
time_col = 'Datetime'
@@ -284,7 +284,7 @@ class USStockDataSource(BaseDataSource):
for _, row in df.iterrows():
try:
# 处理时间戳
# Processing timestamps
time_value = row[time_col]
if hasattr(time_value, 'timestamp'):
ts = int(time_value.timestamp())
+1 -1
View File
@@ -188,7 +188,7 @@ def login():
user_id=user_id,
username=user.get('username', username),
role=user.get('role', 'admin'),
token_version=new_token_version # 包含新的 token_version
token_version=new_token_version # Contains new token_version
)
if not token:
+19 -19
View File
@@ -88,15 +88,15 @@ def _normalize_lang(lang: str | None) -> str:
@backtest_bp.route('/backtest/precision-info', methods=['GET'])
def get_precision_info():
"""
获取回测精度信息用于前端提示
Get backtest accuracy information (for front-end prompts)
Params (Query String):
market: 市场类型
startDate: 开始日期 (YYYY-MM-DD)
endDate: 结束日期 (YYYY-MM-DD)
market: market type
startDate: start date (YYYY-MM-DD)
endDate: end date (YYYY-MM-DD)
Returns:
精度信息包含推荐的执行时间框架和预估K线数量
Accuracy information, including recommended execution time frame and estimated number of K-lines
"""
try:
# Use request.args for GET params
@@ -164,7 +164,7 @@ def run_backtest():
leverage = int(data.get('leverage', 1))
trade_direction = data.get('tradeDirection', 'long') # long, short, both
strategy_config = data.get('strategyConfig') or {}
# 多时间框架回测开关(默认开启,仅加密货币市场有效)
# Multi-timeframe backtesting switch (enabled by default, only valid for cryptocurrency markets)
enable_mtf = data.get('enableMtf', True)
if isinstance(enable_mtf, str):
enable_mtf = enable_mtf.lower() in ['true', '1', 'yes']
@@ -185,7 +185,7 @@ def run_backtest():
except Exception:
pass
# 参数验证
# Parameter validation
if not all([indicator_code, symbol, market, timeframe, start_date_str, end_date_str]):
return jsonify({
'code': 0,
@@ -193,27 +193,27 @@ def run_backtest():
'data': None
}), 400
# 转换日期
# 开始日期:当天的 00:00:00
# conversion date
# Start date: 00:00:00 today
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
# 结束日期:当天的 23:59:59,确保包含整天的数据
# End date: 23:59:59 of the current day, ensuring that the entire day's data is included
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
# 验证时间范围限制
# Validation time range limit
days_diff = (end_date - start_date).days
# 根据周期设置不同的时间限制
# Set different time limits based on cycles
if timeframe == '1m':
max_days = 30 # 1分钟K线最多1个月
max_days = 30 # 1 minute K-line up to 1 month
max_range_text = '1 month'
elif timeframe == '5m':
max_days = 180 # 5分钟K线最多6个月
max_days = 180 # 5 minute K-line up to 6 months
max_range_text = '6 months'
elif timeframe in ['15m', '30m']:
max_days = 365 # 15分钟和30分钟K线最多1年
max_days = 365 # 15-minute and 30-minute K-line up to 1 year
max_range_text = '1 year'
else: # 1H, 4H, 1D, 1W
max_days = 1095 # 1小时及以上最多3年
max_days = 1095 # 1 hour and above up to 3 years
max_range_text = '3 years'
if days_diff > max_days:
@@ -224,8 +224,8 @@ def run_backtest():
}), 400
# 执行回测(支持多时间框架高精度回测)
# 加密货币市场且启用MTF时,使用多时间框架回测
# Execute backtesting (supports multi-time frame high-precision backtesting)
# Cryptocurrency markets and using multi-timeframe backtesting when MTF is enabled
if enable_mtf and market.lower() in ['crypto', 'cryptocurrency']:
result = backtest_service.run_multi_timeframe(
indicator_code=indicator_code,
@@ -257,7 +257,7 @@ def run_backtest():
trade_direction=trade_direction,
strategy_config=strategy_config
)
# 添加标准回测的精度信息
# Add accuracy information for standard backtests
result['precision_info'] = {
'enabled': False,
'timeframe': timeframe,
+5 -5
View File
@@ -1,9 +1,9 @@
"""
Billing APIs - 会员购买/套餐配置Mock支付
Billing APIs - Membership purchase/package configuration (Mock payment)
当前版本先实现快速商业闭环的最小可用
- 从系统设置(.env)读取 3 档会员包月/包年/永久金额与赠送积分配置
- 用户在前端购买后立即开通/发放积分后续可替换为真实支付网关
The current version first implements the minimum availability of "fast commercial closed loop":
- Read the three levels of membership (monthly/annual/permanent) amounts and bonus points configuration from the system settings (.env)
- Users activate/issue points immediately after purchasing on the front end (can be replaced with a real payment gateway later)
"""
from flask import Blueprint, jsonify, request, g
@@ -59,7 +59,7 @@ def purchase_membership():
# =========================
# USDT Pay (方案B)
# USDT Pay (Plan B)
# =========================
+41 -41
View File
@@ -1,7 +1,7 @@
"""
Community APIs - 指标社区接口
Community APIs - indicator community interface
提供指标市场购买评论等功能的 REST API
REST API that provides indicator markets, buying, commenting, and more.
"""
from flask import Blueprint, jsonify, request, g
@@ -16,20 +16,20 @@ community_bp = Blueprint("community", __name__)
# ==========================================
# 指标市场
# indicator market
# ==========================================
@community_bp.route("/indicators", methods=["GET"])
@login_required
def get_market_indicators():
"""
获取市场指标列表
Get a list of market indicators
Query params:
page: 页码 (default 1)
page_size: 每页数量 (default 12)
keyword: 搜索关键词
pricing_type: 'free' / 'paid' / (全部)
page: page number (default 1)
page_size: Number of pages per page (default 12)
keyword: search keyword
pricing_type: 'free' / 'paid' / empty (all)
sort_by: 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating'
"""
try:
@@ -39,7 +39,7 @@ def get_market_indicators():
pricing_type = request.args.get('pricing_type', '').strip() or None
sort_by = request.args.get('sort_by', 'newest').strip()
# 限制每页数量
# Limit the number of pages per page
page_size = min(max(page_size, 1), 50)
service = get_community_service()
@@ -62,7 +62,7 @@ def get_market_indicators():
@community_bp.route("/indicators/<int:indicator_id>", methods=["GET"])
@login_required
def get_indicator_detail(indicator_id: int):
"""获取指标详情"""
"""Get indicator details"""
try:
service = get_community_service()
result = service.get_indicator_detail(indicator_id, user_id=g.user_id)
@@ -78,20 +78,20 @@ def get_indicator_detail(indicator_id: int):
# ==========================================
# 购买功能
# Purchase function
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/purchase", methods=["POST"])
@login_required
def purchase_indicator(indicator_id: int):
"""
购买指标
buy indicator
会自动
1. 检查积分是否充足
2. 扣除买家积分增加卖家积分
3. 创建购买记录
4. 复制指标到买家账户
will automatically:
1. Check whether the points are sufficient
2. Deduct buyer points and increase seller points
3. Create purchase records
4. Copy the indicator to the buyers account
"""
try:
service = get_community_service()
@@ -113,7 +113,7 @@ def purchase_indicator(indicator_id: int):
@community_bp.route("/my-purchases", methods=["GET"])
@login_required
def get_my_purchases():
"""获取我购买的指标列表"""
"""Get a list of indicators I purchased"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
@@ -134,13 +134,13 @@ def get_my_purchases():
# ==========================================
# 评论功能
# Comment function
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/comments", methods=["GET"])
@login_required
def get_comments(indicator_id: int):
"""获取指标评论列表"""
"""Get a list of indicator comments"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
@@ -164,13 +164,13 @@ def get_comments(indicator_id: int):
@login_required
def add_comment(indicator_id: int):
"""
添加评论
Add comment
Request body:
rating: 1-5 星评分
content: 评论内容可选最多500字
rating: 1-5 star rating
content: Comment content (optional, up to 500 words)
注意只有购买过的用户可以评论且只能评论一次
Note: Only users who have purchased can comment, and they can only comment once
"""
try:
data = request.get_json() or {}
@@ -199,11 +199,11 @@ def add_comment(indicator_id: int):
@login_required
def update_comment(indicator_id: int, comment_id: int):
"""
更新评论只能修改自己的评论
Update comments (you can only modify your own comments)
Request body:
rating: 1-5 星评分
content: 评论内容最多500字
rating: 1-5 star rating
content: Comment content (up to 500 words)
"""
try:
data = request.get_json() or {}
@@ -232,7 +232,7 @@ def update_comment(indicator_id: int, comment_id: int):
@community_bp.route("/indicators/<int:indicator_id>/my-comment", methods=["GET"])
@login_required
def get_my_comment(indicator_id: int):
"""获取当前用户对指定指标的评论(用于编辑)"""
"""Get the current user's comments on the specified indicator (for editing)"""
try:
service = get_community_service()
result = service.get_user_comment(
@@ -248,13 +248,13 @@ def get_my_comment(indicator_id: int):
# ==========================================
# 实盘表现
# Real offer performance
# ==========================================
@community_bp.route("/indicators/<int:indicator_id>/performance", methods=["GET"])
@login_required
def get_indicator_performance(indicator_id: int):
"""获取指标的实盘表现统计"""
"""Get real performance statistics of indicators"""
try:
service = get_community_service()
result = service.get_indicator_performance(indicator_id)
@@ -267,11 +267,11 @@ def get_indicator_performance(indicator_id: int):
# ==========================================
# 管理员审核功能
# Administrator review function
# ==========================================
def _is_admin():
"""检查当前用户是否是管理员"""
"""Check if the current user is an administrator"""
role = getattr(g, 'user_role', None)
return role == 'admin'
@@ -280,11 +280,11 @@ def _is_admin():
@login_required
def get_pending_indicators():
"""
获取待审核的指标列表管理员专用
Get the list of indicators to be reviewed (for administrators only)
Query params:
page: 页码 (default 1)
page_size: 每页数量 (default 20)
page: page number (default 1)
page_size: Number of pages per page (default 20)
review_status: 'pending' / 'approved' / 'rejected' / 'all'
"""
try:
@@ -313,7 +313,7 @@ def get_pending_indicators():
@community_bp.route("/admin/review-stats", methods=["GET"])
@login_required
def get_review_stats():
"""获取审核统计数据(管理员专用)"""
"""Get audit statistics (for administrators only)"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
@@ -332,11 +332,11 @@ def get_review_stats():
@login_required
def review_indicator(indicator_id: int):
"""
审核指标管理员专用
Audit indicators (for administrators only)
Request body:
action: 'approve' / 'reject'
note: 审核备注可选
note: review notes (optional)
"""
try:
if not _is_admin():
@@ -371,10 +371,10 @@ def review_indicator(indicator_id: int):
@login_required
def unpublish_indicator(indicator_id: int):
"""
下架指标管理员专用
Delisting indicator (for administrators only)
Request body:
note: 下架原因可选
note: Reason for delisting (optional)
"""
try:
if not _is_admin():
@@ -403,7 +403,7 @@ def unpublish_indicator(indicator_id: int):
@community_bp.route("/admin/indicators/<int:indicator_id>", methods=["DELETE"])
@login_required
def admin_delete_indicator(indicator_id: int):
"""删除指标(管理员专用)"""
"""Delete indicator (only for administrators)"""
try:
if not _is_admin():
return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403
+1 -1
View File
@@ -105,7 +105,7 @@ def get_egress_ip():
"data": {
"ipv4": ipv4 or None,
"ipv6": ipv6 or None,
# 兼容旧前端:优先 IPv4,否则 IPv6
# Compatible with old frontends: IPv4 first, otherwise IPv6
"ip": ipv4 or ipv6 or None,
},
}
+40 -40
View File
@@ -38,22 +38,22 @@ logger = get_logger(__name__)
global_market_bp = Blueprint("global_market", __name__)
# Cache for market data (simple in-memory cache)
# 多用户场景下,合理的缓存可以大幅减少 API 请求
# In multi-user scenarios, reasonable caching can significantly reduce API requests.
_cache: Dict[str, Dict[str, Any]] = {}
_cache_ttl = 60 # Default 60 seconds cache
# 缓存时间配置(秒)
# Cache time configuration (seconds)
CACHE_TTL = {
"crypto_heatmap": 300, # 5分钟 - 加密货币变化快但热力图不需要实时
"forex_pairs": 120, # 2分钟 - 外汇日内波动较小
"stock_indices": 120, # 2分钟 - 指数变化较慢
"market_overview": 120, # 2分钟 - 概览数据
"market_heatmap": 120, # 2分钟 - 热力图
"commodities": 120, # 2分钟 - 大宗商品
"market_news": 180, # 3分钟 - 新闻
"economic_calendar": 3600, # 1小时 - 日历事件
"market_sentiment": 21600, # 6小时 - 宏观情绪变化缓慢
"trading_opportunities": 3600, # 1小时 - 每小时更新一次
"crypto_heatmap": 300, # 5 minutes - Cryptocurrencies change fast but heatmaps dont need to be real-time
"forex_pairs": 120, # 2 minutes - Forex intraday fluctuations are small
"stock_indices": 120, # 2 minutes - index changes slowly
"market_overview": 120, # 2 minutes - overview data
"market_heatmap": 120, # 2 minutes - heat map
"commodities": 120, # 2 minutes - Commodities
"market_news": 180, # 3 minutes - News
"economic_calendar": 3600, # 1 hour - calendar event
"market_sentiment": 21600, # 6 hours - Macro sentiment changes slowly
"trading_opportunities": 3600, # 1 hour - updated every hour
}
@@ -61,7 +61,7 @@ def _get_cached(key: str, ttl: int = None) -> Optional[Any]:
"""Get cached data if not expired."""
if key in _cache:
entry = _cache[key]
# 优先使用传入的 ttl,然后是 CACHE_TTL 配置,最后是默认值
# Use the incoming ttl first, then the CACHE_TTL configuration, then the default value
cache_ttl = ttl or CACHE_TTL.get(key, entry.get("ttl", _cache_ttl))
if time.time() - entry.get("ts", 0) < cache_ttl:
return entry.get("data")
@@ -253,7 +253,7 @@ def _fetch_crypto_prices() -> List[Dict[str, Any]]:
def _fetch_stock_indices() -> List[Dict[str, Any]]:
"""Fetch major stock indices using yfinance."""
indices = [
# US Markets - 坐标错开避免重叠
# US Markets - Coordinates are staggered to avoid overlap
{"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},
@@ -520,12 +520,12 @@ def _fetch_fear_greed_index() -> Dict[str, Any]:
def _fetch_vix() -> Dict[str, Any]:
"""Fetch VIX (CBOE Volatility Index) with multiple fallbacks."""
# 默认值 - 合理的市场中性水平
# Default - a reasonable market neutral level
DEFAULT_VIX = {"value": 18, "change": 0, "level": "low",
"interpretation": "低波动 - 市场稳定",
"interpretation_en": "Low - Market Stable"}
# 1) 尝试 yfinance
# 1) Try yfinance
try:
import yfinance as yf
logger.debug("Fetching VIX from yfinance")
@@ -551,10 +551,10 @@ def _fetch_vix() -> Dict[str, Any]:
except Exception as e:
logger.warning(f"yfinance VIX failed, trying akshare: {e}")
# 2) 尝试 Akshare (对中国服务器友好)
# 2) Try Akshare (friendly for Chinese servers)
try:
import akshare as ak
vix_df = ak.index_vix() # VIX指数
vix_df = ak.index_vix() # VIX index
if vix_df is not None and len(vix_df) > 0:
current = float(vix_df.iloc[-1]['close'])
prev_close = float(vix_df.iloc[-2]['close']) if len(vix_df) >= 2 else current
@@ -602,7 +602,7 @@ def _fetch_vix() -> Dict[str, Any]:
def _fetch_dollar_index() -> Dict[str, Any]:
"""Fetch US Dollar Index (DXY) with multiple fallbacks."""
# 默认值 - 合理的中性水平
# Default - reasonably neutral level
DEFAULT_DXY = {"value": 104, "change": 0, "level": "moderate_strong",
"interpretation": "美元偏强 - 关注资金流向",
"interpretation_en": "Moderately Strong - Watch capital flows"}
@@ -610,7 +610,7 @@ def _fetch_dollar_index() -> Dict[str, Any]:
current = 0
change = 0
# 1) 尝试 yfinance
# 1) Try yfinance
try:
import yfinance as yf
logger.debug("Fetching DXY from yfinance")
@@ -636,15 +636,15 @@ def _fetch_dollar_index() -> Dict[str, Any]:
except Exception as e:
logger.warning(f"yfinance DXY failed, trying akshare: {e}")
# 2) 尝试 Akshare 获取美元指数
# 2) Try Akshare to get USD Index
try:
import akshare as ak
# Akshare 外汇数据
# Akshare Forex Data
fx_df = ak.currency_boc_sina(symbol="美元")
if fx_df is not None and len(fx_df) > 0:
# 使用中行汇率估算 DXY (近似值)
# Estimate DXY using Bank of China exchange rate (approximate value)
usd_cny = float(fx_df.iloc[-1]['中行汇买价']) / 100
current = usd_cny * 14.5 # 大致换算
current = usd_cny * 14.5 # Approximate conversion
change = 0
logger.info(f"DXY estimated from akshare: {current:.2f}")
else:
@@ -698,14 +698,14 @@ def _fetch_yield_curve() -> Dict[str, Any]:
# 10-year Treasury yield
tnx = yf.Ticker("^TNX")
# 使用 try-except 包裹 history 调用
# Use try-except to wrap history calls
try:
tnx_hist = tnx.history(period="5d")
except Exception as hist_err:
logger.warning(f"TNX history fetch failed: {hist_err}")
tnx_hist = None
# 安全检查
# security check
if tnx_hist is None or tnx_hist.empty:
logger.warning("TNX history is None or empty, returning default")
return {
@@ -1070,7 +1070,7 @@ def _fetch_financial_news(lang: str = "all") -> Dict[str, List[Dict[str, Any]]]:
def _get_economic_calendar() -> List[Dict[str, Any]]:
"""
Get economic calendar events with impact indicators.
Impact: bullish (利多), bearish (利空), neutral (中性)
Impact: bullish (positive), bearish (negative), neutral (neutral)
"""
today = datetime.now()
events = []
@@ -1084,7 +1084,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]:
"importance": "high",
"forecast": "180K",
"previous": "175K",
"impact_if_above": "bullish", # 高于预期利多美元
"impact_if_above": "bullish", # Higher than expected bullish for dollar
"impact_if_below": "bearish",
"impact_desc": "高于预期利多美元/美股,低于预期利空",
"impact_desc_en": "Above forecast: bullish USD/stocks; Below: bearish"
@@ -1096,7 +1096,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]:
"importance": "high",
"forecast": "5.25%",
"previous": "5.25%",
"impact_if_above": "bearish", # 加息利空股市
"impact_if_above": "bearish", # Raising interest rates is bad for the stock market
"impact_if_below": "bullish",
"impact_desc": "加息利空股市/加密货币,降息利多",
"impact_desc_en": "Rate hike: bearish stocks/crypto; Cut: bullish"
@@ -1108,7 +1108,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]:
"importance": "high",
"forecast": "0.3%",
"previous": "0.4%",
"impact_if_above": "bearish", # CPI高利空
"impact_if_above": "bearish", # High CPI is negative
"impact_if_below": "bullish",
"impact_desc": "CPI高于预期增加加息预期,利空股市",
"impact_desc_en": "Higher CPI increases rate hike expectations, bearish stocks"
@@ -1132,7 +1132,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]:
"importance": "high",
"forecast": "0.10%",
"previous": "0.10%",
"impact_if_above": "bullish", # 日本加息利多日元
"impact_if_above": "bullish", # Japan's interest rate hikes are bullish for the yen
"impact_if_below": "bearish",
"impact_desc": "加息预期利多日元,利空日股",
"impact_desc_en": "Rate hike expectation: bullish JPY, bearish Nikkei"
@@ -1315,11 +1315,11 @@ def _generate_heatmap_data() -> Dict[str, Any]:
"crypto": [],
"sectors": [],
"forex": [],
"commodities": [], # 新增大宗商品热力图
"commodities": [], # Added commodity heat map
"indices": []
}
# Commodities heatmap (黄金、白银、原油等)
# Commodities heatmap (gold, silver, crude oil, etc.)
commodities_data = _get_cached("commodities")
if not commodities_data:
commodities_data = _fetch_commodities()
@@ -1563,7 +1563,7 @@ def market_sentiment():
Includes: Fear & Greed, VIX, DXY, Yield Curve, VXN, GVZ, VIX Term Structure.
"""
try:
# 缓存6小时 (21600秒),宏观数据变化缓慢,减少 API 调用
# Cache for 6 hours (21600 seconds), macro data changes slowly, reducing API calls
MACRO_CACHE_TTL = 21600 # 6 hours
cached = _get_cached("market_sentiment", MACRO_CACHE_TTL)
if cached:
@@ -1863,7 +1863,7 @@ def _analyze_opportunities_forex(opportunities: list):
def _analyze_opportunities_polymarket(opportunities: list):
"""扫描预测市场机会"""
"""Scan for prediction market opportunities"""
try:
from app.data_sources.polymarket import PolymarketDataSource
from app.services.polymarket_analyzer import PolymarketAnalyzer
@@ -1871,24 +1871,24 @@ def _analyze_opportunities_polymarket(opportunities: list):
polymarket_source = PolymarketDataSource()
analyzer = PolymarketAnalyzer()
# 获取热门市场
# Get popular markets
markets = polymarket_source.get_trending_markets(limit=20)
for market in markets:
try:
# AI分析
# AI analysis
analysis = analyzer.analyze_market(market['market_id'])
if analysis.get('error'):
continue
# 只添加高分机会
# Only add high score chances
if analysis.get('opportunity_score', 0) > 75:
opportunities.append({
"symbol": market['question'][:50], # 简化显示
"symbol": market['question'][:50], # Simplified display
"name": market['question'],
"price": market['current_probability'],
"change_24h": 0, # 预测市场没有24h涨跌幅概念
"change_24h": 0, # There is no concept of 24h rise and fall in the prediction market
"signal": "prediction_opportunity",
"strength": "strong" if analysis.get('opportunity_score', 0) > 85 else "medium",
"reason": f"AI预测概率{analysis.get('ai_predicted_probability', 0):.1f}%,市场概率{market['current_probability']:.1f}%,差异{analysis.get('divergence', 0):.1f}%",
+4 -4
View File
@@ -1,5 +1,5 @@
"""
健康检查路由
Health check routing
"""
from flask import Blueprint, jsonify
from datetime import datetime
@@ -9,7 +9,7 @@ health_bp = Blueprint('health', __name__)
@health_bp.route('/', methods=['GET'])
def index():
"""API 首页"""
"""API Home Page"""
return jsonify({
'name': 'QuantDinger Python API',
'version': '2.0.0',
@@ -20,7 +20,7 @@ def index():
@health_bp.route('/health', methods=['GET'])
def health_check():
"""健康检查"""
"""health check"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat()
@@ -29,5 +29,5 @@ def health_check():
@health_bp.route('/api/health', methods=['GET'])
def api_health_check():
"""兼容路径:用于容器健康检查/反代探针等场景。"""
"""Compatible path: used for container health check/anti-generation probe and other scenarios."""
return health_check()
+26 -26
View File
@@ -210,7 +210,7 @@ def save_indicator():
now = _now_ts() # For BIGINT fields (createtime, updatetime)
# 检查用户是否是管理员(管理员发布的指标自动通过审核)
# Check whether the user is an administrator (indicators published by the administrator automatically pass the review)
user_role = getattr(g, 'user_role', 'user')
is_admin = user_role == 'admin'
@@ -222,7 +222,7 @@ def save_indicator():
except Exception:
pass
if indicator_id and indicator_id > 0:
# 检查是否从未发布改为发布,需要设置审核状态
# Check whether the change from unpublished to published requires setting the review status
if publish_to_community:
cur.execute(
"SELECT publish_to_community, review_status FROM qd_indicator_codes WHERE id = ? AND user_id = ?",
@@ -230,8 +230,8 @@ def save_indicator():
)
existing = cur.fetchone()
was_published = existing and existing.get('publish_to_community')
# 如果之前未发布,现在发布,设置审核状态
# 管理员发布的直接通过,普通用户需要待审核
# If it has not been published before, publish it now and set the review status
# Posted by the administrator, it passes directly, and ordinary users need to wait for review.
new_review_status = 'approved' if is_admin else 'pending'
if not was_published:
cur.execute(
@@ -248,7 +248,7 @@ def save_indicator():
new_review_status, user_id if is_admin else None, now, indicator_id, user_id),
)
else:
# 已发布过的更新,保持原审核状态
# Updates that have been released will remain in their original review status.
cur.execute(
"""
UPDATE qd_indicator_codes
@@ -261,7 +261,7 @@ def save_indicator():
(name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, now, indicator_id, user_id),
)
else:
# 取消发布,清除审核状态
# Unpublish and clear review status
cur.execute(
"""
UPDATE qd_indicator_codes
@@ -275,7 +275,7 @@ def save_indicator():
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id),
)
else:
# 新建指标 - 管理员发布的直接通过,普通用户需要待审核
# New indicators - those released by administrators are passed directly, ordinary users need to wait for review
review_status = None
if publish_to_community:
review_status = 'approved' if is_admin else 'pending'
@@ -329,12 +329,12 @@ def delete_indicator():
@login_required
def get_indicator_params():
"""
获取指标的参数声明
Get parameter declaration of indicator
用于前端在策略创建时显示可配置的参数表单
Used by the front end to display a configurable parameter form when creating a policy.
Query params:
indicator_id: 指标ID
indicator_id: indicator ID
Returns:
params: [
@@ -342,7 +342,7 @@ def get_indicator_params():
"name": "ma_fast",
"type": "int",
"default": 5,
"description": "短期均线周期"
"description": "Short-term moving average cycle"
},
...
]
@@ -541,7 +541,7 @@ def ai_generate():
if not prompt:
# Keep SSE contract (match PHP behavior) so frontend doesn't look "stuck".
def _err_stream():
yield "data: " + json.dumps({"error": "提示词不能为空"}, ensure_ascii=False) + "\n\n"
yield "data: " + json.dumps({"error": "The prompt word cannot be empty"}, ensure_ascii=False) + "\n\n"
yield "data: [DONE]\n\n"
return Response(
@@ -740,22 +740,22 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
@login_required
def call_indicator():
"""
调用另一个指标供前端 Pyodide 环境使用
Call another indicator (for use by the front-end Pyodide environment)
POST /api/indicator/callIndicator
Body: {
"indicatorRef": int | str, # 指标ID或名称
"klineData": List[Dict], # K线数据
"params": Dict, # 传递给被调用指标的参数(可选)
"currentIndicatorId": int # 当前指标ID(用于循环依赖检测,可选)
"indicatorRef": int | str, # indicator ID or name
"klineData": List[Dict], # K-line data
"params": Dict, # Parameters passed to the called indicator (optional)
"currentIndicatorId": int # Current indicator ID (used for circular dependency detection, optional)
}
Returns:
{
"code": 1,
"data": {
"df": List[Dict], # 执行后的DataFrame(转换为JSON
"columns": List[str] # DataFrame的列名
"df": List[Dict], # DataFrame after execution (converted to JSON)
"columns": List[str] # DataFrame column name
}
}
"""
@@ -780,32 +780,32 @@ def call_indicator():
"data": None
}), 400
# 获取用户ID
# Get user ID
user_id = g.user_id
# 创建 IndicatorCaller
# Create IndicatorCaller
indicator_caller = IndicatorCaller(user_id, current_indicator_id)
# 将前端传入的K线数据转换为DataFrame
# Convert the K-line data passed in from the front end into a DataFrame
df = pd.DataFrame(kline_data)
# 确保必要的列存在
# Make sure necessary columns exist
required_columns = ['open', 'high', 'low', 'close', 'volume']
for col in required_columns:
if col not in df.columns:
df[col] = 0.0
# 转换数据类型
# Convert data type
df['open'] = df['open'].astype('float64')
df['high'] = df['high'].astype('float64')
df['low'] = df['low'].astype('float64')
df['close'] = df['close'].astype('float64')
df['volume'] = df['volume'].astype('float64')
# 调用指标
# call indicator
result_df = indicator_caller.call_indicator(indicator_ref, df, params)
# 将DataFrame转换为JSON格式(前端可以使用的格式)
# Convert the DataFrame to JSON format (a format that the front end can use)
result_dict = result_df.to_dict(orient='records')
return jsonify({
+11 -11
View File
@@ -1,5 +1,5 @@
"""
K线数据 API 路由
K-line data API routing
"""
from flask import Blueprint, request, jsonify
from datetime import datetime
@@ -17,17 +17,17 @@ kline_service = KlineService()
@kline_bp.route('/kline', methods=['GET'])
def get_kline():
"""
获取K线数据
Get K-line data
参数:
market: 市场类型 (Crypto, USStock, Forex, Futures)
symbol: 交易对/股票代码
timeframe: 时间周期 (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W)
limit: 数据条数 (默认300)
before_time: 获取此时间之前的数据 (可选Unix时间戳)
parameter:
market: market type (Crypto, USStock, Forex, Futures)
symbol: trading pair/stock code
timeframe: time period (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W)
limit: number of data items (default 300)
before_time: Get data before this time (optional, Unix timestamp)
"""
try:
# 强制 GET, 使用 request.args
# To force GET, use request.args
market = request.args.get('market', 'USStock')
symbol = request.args.get('symbol', '')
timeframe = request.args.get('timeframe', '1D')
@@ -55,7 +55,7 @@ def get_kline():
)
if not klines:
# 针对特定情况给出更详细的提示
# Give more detailed tips for specific situations
msg = 'No data found'
if market == 'Forex' and timeframe == '1m':
msg = 'Forex 1-minute data requires Tiingo paid subscription'
@@ -86,7 +86,7 @@ def get_kline():
@kline_bp.route('/price', methods=['GET'])
def get_price():
"""获取最新价格"""
"""Get the latest price"""
try:
market = request.args.get('market', 'USStock')
symbol = request.args.get('symbol', '')
+24 -24
View File
@@ -291,10 +291,10 @@ def remove_watchlist():
def get_single_price(market: str, symbol: str) -> dict:
"""获取单个标的的价格数据"""
"""Get price data of a single target"""
try:
# 使用 get_realtime_price 获取实时价格(内部已有30秒缓存)
# 相比原先的 '1D' K线逻辑,这能更及时地反映 Crypto 等 24h 市场的变化
# Use get_realtime_price to get the real-time price (30 seconds cached internally)
# Compared with the original '1D' K-line logic, this can reflect changes in 24h markets such as Crypto in a more timely manner
price_data = kline_service.get_realtime_price(market, symbol)
return {
@@ -318,7 +318,7 @@ def get_single_price(market: str, symbol: str) -> dict:
@market_bp.route('/watchlist/prices', methods=['GET'])
def get_watchlist_prices():
"""
批量获取自选股价格
Get the prices of self-selected stocks in batches
Params (Query String):
watchlist: JSON string of list of {market, symbol} objects
@@ -338,11 +338,11 @@ def get_watchlist_prices():
'data': []
}), 400
# logger.info(f"开始获取 {len(watchlist)} 个自选股价格数据")
# logger.info(f"Start getting {len(watchlist)} self-selected stock price data")
results = []
# 使用线程池并行获取价格
# Fetch prices in parallel using thread pool
futures = {}
for item in watchlist:
market = item.get('market', '')
@@ -352,7 +352,7 @@ def get_watchlist_prices():
future = executor.submit(get_single_price, market, symbol)
futures[future] = (market, symbol)
# 收集结果(带超时保护)
# Collect results (with timeout protection)
completed_futures = set()
try:
for future in as_completed(futures, timeout=30):
@@ -371,7 +371,7 @@ def get_watchlist_prices():
'changePercent': 0
})
except TimeoutError:
# 超时时,为未完成的任务添加默认结果
# Add default results for unfinished tasks on timeout
for future, (market, symbol) in futures.items():
if future not in completed_futures:
logger.warning(f"Price fetch timed out: {market}:{symbol}")
@@ -406,11 +406,11 @@ def get_watchlist_prices():
@market_bp.route('/price', methods=['GET'])
def get_price():
"""
获取单个标的价格
Get the price of a single target
参数:
market: 市场类型
symbol: 交易标的
parameter:
market: market type
symbol: transaction target
"""
try:
market = request.args.get('market', '')
@@ -443,15 +443,15 @@ def get_price():
@market_bp.route('/stock/name', methods=['POST'])
def get_stock_name():
"""
获取股票名称
Get stock name
请求体:
Request body:
{
"market": "USStock",
"symbol": "AAPL"
}
响应:
response:
{
"code": 1,
"msg": "success",
@@ -479,7 +479,7 @@ def get_stock_name():
'data': None
}), 400
# 尝试从缓存获取(1天缓存)
# Try to get from cache (1 day cache)
cache_key = f"stock_name:{market}:{symbol}"
cached_name = cache.get(cache_key)
@@ -491,30 +491,30 @@ def get_stock_name():
'data': {'name': cached_name}
})
# 根据不同市场获取股票名称
stock_name = symbol # 默认使用代码
# Get stock names based on different markets
stock_name = symbol # Default use code
try:
if market == 'USStock':
# 对于股票,尝试获取基本信息
# For stocks, try to get basic information
import yfinance as yf
yf_symbol = symbol
ticker = yf.Ticker(yf_symbol)
info = ticker.info
# 尝试获取名称
# Try to get the name
stock_name = info.get('longName') or info.get('shortName') or symbol
elif market == 'Crypto':
# 加密货币,使用交易对格式
# Cryptocurrency, using trading pair format
if '/' in symbol:
stock_name = symbol
else:
stock_name = f"{symbol}/USDT"
elif market == 'Forex':
# 外汇
# Forex
forex_names = {
'XAUUSD': '黄金',
'XAGUSD': '白银',
@@ -528,7 +528,7 @@ def get_stock_name():
stock_name = forex_names.get(symbol, symbol)
elif market == 'Futures':
# 期货
# futures
futures_names = {
'GC': '黄金期货',
'SI': '白银期货',
@@ -545,7 +545,7 @@ def get_stock_name():
logger.warning(f"Failed to fetch stock name; falling back to symbol: {market}:{symbol} - {str(e)}")
stock_name = symbol
# 缓存1天
# Cache for 1 day
cache.set(cache_key, stock_name, 86400)
return jsonify({
+29 -29
View File
@@ -1,6 +1,6 @@
"""
Polymarket预测市场API路由
提供按需分析接口只读不涉及交易
Polymarket prediction market API routing
Provide on-demand analysis interface (read-only, no transactions involved)
"""
from flask import Blueprint, jsonify, request, g
@@ -15,7 +15,7 @@ logger = get_logger(__name__)
polymarket_bp = Blueprint('polymarket', __name__)
# 初始化服务
# Initialize service
polymarket_source = PolymarketDataSource()
@@ -23,20 +23,20 @@ polymarket_source = PolymarketDataSource()
@login_required
def analyze_polymarket():
"""
分析Polymarket预测市场用户输入链接或标题
Analyze Polymarket prediction market (user enters link or title)
POST /api/polymarket/analyze
Body: {
"input": "https://polymarket.com/event/xxx" "市场标题",
"input": "https://polymarket.com/event/xxx" or "market title",
"language": "zh-CN" (optional)
}
流程
1. 从输入中解析market_id或slug
2. 从API获取市场数据
3. 检查计费并扣除积分
4. 调用AI分析
5. 返回分析结果
process:
1. Parse market_id or slug from input
2. Get market data from API
3. Check billing and deduct points
4. Call AI analysis
5. Return analysis results
"""
try:
from app.services.billing_service import BillingService
@@ -62,11 +62,11 @@ def analyze_polymarket():
"data": None
}), 400
# 1. 解析market_idslug
# 1. Parse market_id or slug
market_id = None
slug = None
# 尝试从URL中提取
# Try to extract from URL
url_patterns = [
r'polymarket\.com/event/([^/?]+)',
r'polymarket\.com/markets/(\d+)',
@@ -77,20 +77,20 @@ def analyze_polymarket():
match = re.search(pattern, input_text)
if match:
extracted = match.group(1)
# 如果是数字,是market_id;否则是slug
# If it is a number, it is market_id; otherwise it is slug
if extracted.isdigit():
market_id = extracted
else:
slug = extracted
break
# 如果没有从URL提取到,尝试搜索市场
# If not extracted from the URL, try searching the market
if not market_id and not slug:
# 尝试通过标题搜索
# Try searching by title
logger.info(f"Searching for market by title: {input_text[:100]}")
search_results = polymarket_source.search_markets(input_text, limit=5)
if search_results:
# 使用第一个搜索结果
# Use first search result
market_id = search_results[0].get('market_id')
logger.info(f"Found market via search: {market_id}")
@@ -101,11 +101,11 @@ def analyze_polymarket():
"data": None
}), 400
# 2. 获取市场数据
# 2. Obtain market data
if market_id:
market = polymarket_source.get_market_details(market_id)
elif slug:
# 通过slug查找市场(需要先搜索)
# Find the market by slug (need to search first)
search_results = polymarket_source.search_markets(slug, limit=10)
market = None
for result in search_results:
@@ -115,7 +115,7 @@ def analyze_polymarket():
break
if not market and search_results:
# 使用第一个搜索结果
# Use first search result
market = search_results[0]
market_id = market.get('market_id')
@@ -136,7 +136,7 @@ def analyze_polymarket():
"data": None
}), 400
# 3. 检查计费
# 3. Check billing
billing = BillingService()
cost = 0
@@ -156,7 +156,7 @@ def analyze_polymarket():
}
}), 400
# 扣除积分(使用check_and_consume方法,它会自动从配置中获取成本)
# Deduct points (use check_and_consume method, it will automatically get the cost from the configuration)
success, error_msg = billing.check_and_consume(
user_id=user_id,
feature='polymarket_deep_analysis',
@@ -164,7 +164,7 @@ def analyze_polymarket():
)
if not success:
# 检查是否是积分不足的错误
# Check whether it is an error due to insufficient points
if error_msg.startswith('insufficient_credits'):
parts = error_msg.split(':')
if len(parts) >= 3:
@@ -185,9 +185,9 @@ def analyze_polymarket():
"data": None
}), 500
# 4. 执行AI分析(传递语言和模型参数)
# 4. Perform AI analysis (pass language and model parameters)
analyzer = PolymarketAnalyzer()
model = request.get_json().get('model') # 可选:从请求中获取模型参数
model = request.get_json().get('model') # Optional: Get model parameters from request
analysis_result = analyzer.analyze_market(
market_id,
user_id=user_id,
@@ -203,7 +203,7 @@ def analyze_polymarket():
"data": None
}), 500
# 5. 获取剩余积分
# 5. Get remaining points
remaining_credits = 0
if billing.is_billing_enabled():
remaining_credits = float(billing.get_user_credits(user_id))
@@ -245,7 +245,7 @@ def get_polymarket_history():
with get_db_connection() as db:
cur = db.cursor()
# 获取总数
# Get total
cur.execute("""
SELECT COUNT(*) AS total
FROM qd_analysis_tasks
@@ -254,7 +254,7 @@ def get_polymarket_history():
total_row = cur.fetchone()
total = total_row['total'] if total_row else 0
# 获取历史记录
# Get history
cur.execute("""
SELECT
t.id,
@@ -273,7 +273,7 @@ def get_polymarket_history():
rows = cur.fetchall() or []
cur.close()
# 解析结果
# Parse results
items = []
for row in rows:
result_json = row.get('result_json', '{}')
+11 -11
View File
@@ -42,8 +42,8 @@ def _now_ts() -> int:
def _serialize_monitor_ts(value):
"""
JSON 序列化监控时间字段PostgreSQL TIMESTAMP tz 时按 UTC 解释 Docker 默认一致
输出带 Z ISO避免前端把无时区字符串当本地时间而偏差 8 小时
JSON serialized monitoring time field. PostgreSQL TIMESTAMP is interpreted in UTC when there is no tz (consistent with Docker's default),
Output the ISO with Z to prevent the front end from treating the non-time zone string as local time and deviating by 8 hours.
"""
if value is None:
return None
@@ -85,17 +85,17 @@ def _get_single_price(market: str, symbol: str, force_refresh: bool = False) ->
"""
Get price data for a single symbol.
优先使用实时报价 APIticker降级使用分钟/日线 K 线数据
这样可以在交易时段获取更实时的价格而不是只显示日线收盘价
Priority is given to using the real-time quotation API (ticker), and downgrading to minute/daily K-line data.
This allows for more real-time prices during the trading session, rather than just showing daily closing prices.
内置速率限制同一市场的请求间隔至少 REQUEST_INTERVAL
避免触发 API 限制 yfinanceTiingoFinnhub
Built-in rate limit: requests for the same market must be at least REQUEST_INTERVAL seconds apart,
Avoid triggering API limits (like yfinance, Tiingo, Finnhub, etc.).
Args:
force_refresh: 是否强制刷新跳过缓存
force_refresh: whether to force refresh (skip cache)
"""
try:
# 速率限制:同一市场的请求间隔
# Rate Limit: Interval between requests for the same market
with _request_lock:
now = time.time()
last_time = _last_request_time.get(market, 0)
@@ -104,7 +104,7 @@ def _get_single_price(market: str, symbol: str, force_refresh: bool = False) ->
time.sleep(wait_time)
_last_request_time[market] = time.time()
# 使用新的 get_realtime_price 方法获取实时价格
# Get real-time prices using the new get_realtime_price method
price_data = kline_service.get_realtime_price(market, symbol, force_refresh=force_refresh)
return {
@@ -113,7 +113,7 @@ def _get_single_price(market: str, symbol: str, force_refresh: bool = False) ->
'price': price_data.get('price', 0),
'change': price_data.get('change', 0),
'changePercent': price_data.get('changePercent', 0),
'source': price_data.get('source', 'unknown') # 记录数据来源,便于调试
'source': price_data.get('source', 'unknown') # Record data sources for easy debugging
}
except Exception as e:
logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}")
@@ -598,7 +598,7 @@ def add_monitor():
db.commit()
cur.close()
# 创建后立即在后台跑一轮:立刻发通知,并以完成时刻为基准写入 next_run_at(间隔后再次执行)
# Run one round in the background immediately after creation: send a notification immediately, and write next_run_at based on the completion time (execute again after the interval)
if is_active and monitor_id:
try:
from app.services.portfolio_monitor import run_single_monitor as _run_single_monitor
+53 -53
View File
@@ -1,5 +1,5 @@
"""
Settings API - 读取和保存 .env 配置
Settings API - Reading and saving .env configurations
Admin-only endpoints for system configuration management.
"""
@@ -16,7 +16,7 @@ logger = get_logger(__name__)
settings_bp = Blueprint('settings', __name__)
# .env 文件路径
# .env file path
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
@@ -68,16 +68,16 @@ def _refresh_runtime_services() -> None:
except Exception as e:
logger.warning(f"Singleton reset skipped: {module_name}.{field_name}: {e}")
# 配置项定义(分组)- 按功能模块划分,每个配置项包含描述
# Configuration item definition (grouping) - divided by functional modules, each configuration item contains a description
# ---------------------------------------------------------------
# 精简原则:
# - 部署级配置(host/port/debug)不在 UI 暴露,用户通过 .env docker-compose 设置
# - 内部调优参数(超时/重试/tick间隔/向量维度等)使用默认值即可,不暴露给普通用户
# - 只保留用户真正需要配置的功能开关和 API Key
# Streamlining principle:
# - Deployment-level configuration (host/port/debug) is not exposed in the UI, users can set it through .env or docker-compose
# - Internal tuning parameters (timeout/retry/tick interval/vector dimension, etc.) can use default values and are not exposed to ordinary users.
# - Only keep the function switches and API Keys that users really need to configure
# ---------------------------------------------------------------
CONFIG_SCHEMA = {
# ==================== 1. 安全认证 ====================
# ==================== 1. Security certification ====================
'auth': {
'title': 'Security & Authentication',
'icon': 'lock',
@@ -114,7 +114,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 2. AI/LLM 配置 ====================
# ==================== 2. AI/LLM configuration ====================
'ai': {
'title': 'AI / LLM & Search',
'icon': 'robot',
@@ -342,7 +342,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 3. 实盘交易 ====================
# ==================== 3. Real offer ====================
'trading': {
'title': 'Live Trading',
'icon': 'stock',
@@ -366,7 +366,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 4. 数据源配置 ====================
# ==================== 4. Data source configuration ====================
'data_source': {
'title': 'Data Sources',
'icon': 'database',
@@ -402,7 +402,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 5. 邮件配置 ====================
# ==================== 5. Email configuration ====================
'email': {
'title': 'Email (SMTP)',
'icon': 'mail',
@@ -460,7 +460,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 6. 短信配置 ====================
# ==================== 6. SMS configuration ====================
'sms': {
'title': 'SMS (Twilio)',
'icon': 'phone',
@@ -571,7 +571,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 8. 网络代理 ====================
# ==================== 8. Network proxy ====================
'network': {
'title': 'Network & Proxy',
'icon': 'global',
@@ -587,7 +587,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 10. 注册与 OAuth ====================
# ==================== 10. Registration and OAuth ====================
'security': {
'title': 'Registration & OAuth',
'icon': 'safety',
@@ -658,7 +658,7 @@ CONFIG_SCHEMA = {
]
},
# ==================== 11. 计费配置 ====================
# ==================== 11. Billing configuration ====================
'billing': {
'title': 'Billing & Credits',
'icon': 'dollar',
@@ -716,7 +716,7 @@ CONFIG_SCHEMA = {
'description': 'Credits granted every 30 days for lifetime members'
},
# ===== USDT Pay (方案B:每单独立地址) =====
# ===== USDT Pay (Plan B: independent address for each order) =====
{
'key': 'USDT_PAY_ENABLED',
'label': 'Enable USDT Pay',
@@ -809,7 +809,7 @@ CONFIG_SCHEMA = {
def read_env_file():
"""读取 .env 文件"""
"""Read .env file"""
env_values = {}
if not os.path.exists(ENV_FILE_PATH):
@@ -820,15 +820,15 @@ def read_env_file():
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# 跳过空行和注释
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# 解析 KEY=VALUE
# Parse KEY=VALUE
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
# 移除引号
# Remove quotes
if (value.startswith('"') and value.endswith('"')) or \
(value.startswith("'") and value.endswith("'")):
value = value[1:-1]
@@ -840,11 +840,11 @@ def read_env_file():
def write_env_file(env_values):
"""写入 .env 文件,保留注释和格式"""
"""Write to .env file, preserving comments and formatting"""
lines = []
existing_keys = set()
# 读取原文件保留格式
# Read the original file and keep the format
if os.path.exists(ENV_FILE_PATH):
try:
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
@@ -852,18 +852,18 @@ def write_env_file(env_values):
original_line = line
stripped = line.strip()
# 保留空行和注释
# Keep blank lines and comments
if not stripped or stripped.startswith('#'):
lines.append(original_line)
continue
# 更新已存在的键
# Update an existing key
if '=' in stripped:
key = stripped.split('=', 1)[0].strip()
if key in env_values:
existing_keys.add(key)
value = env_values[key]
# 如果值包含特殊字符,用引号包裹
# If the value contains special characters, wrap it in quotes
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
lines.append(f'{key}="{value}"\n')
else:
@@ -875,7 +875,7 @@ def write_env_file(env_values):
except Exception as e:
logger.error(f"Failed to read .env file for update: {e}")
# 添加新的键
# Add new key
new_keys = set(env_values.keys()) - existing_keys
if new_keys:
if lines and not lines[-1].endswith('\n'):
@@ -888,7 +888,7 @@ def write_env_file(env_values):
else:
lines.append(f'{key}={value}\n')
# 写入文件
# write file
try:
with open(ENV_FILE_PATH, 'w', encoding='utf-8') as f:
f.writelines(lines)
@@ -902,7 +902,7 @@ def write_env_file(env_values):
@login_required
@admin_required
def get_settings_schema():
"""获取配置项定义 (admin only)"""
"""Get configuration item definition (admin only)"""
return jsonify({
'code': 1,
'msg': 'success',
@@ -914,10 +914,10 @@ def get_settings_schema():
@login_required
@admin_required
def get_settings_values():
"""获取当前配置值 - 包括敏感信息(真实值)(admin only)"""
"""Get current configuration values - including sensitive information (real values) (admin only)"""
env_values = read_env_file()
# 构建返回数据,返回真实值
# Construct return data and return true value
result = {}
for group_key, group in CONFIG_SCHEMA.items():
result[group_key] = {}
@@ -925,7 +925,7 @@ def get_settings_values():
key = item['key']
value = env_values.get(key, item.get('default', ''))
result[group_key][key] = value
# 标记密码类型是否已配置
# Marks whether the password type is configured
if item['type'] == 'password':
result[group_key][f'{key}_configured'] = bool(value)
@@ -940,16 +940,16 @@ def get_settings_values():
@login_required
@admin_required
def save_settings():
"""保存配置 (admin only)"""
"""Save configuration (admin only)"""
try:
data = request.get_json()
if not data:
return jsonify({'code': 0, 'msg': 'Invalid request payload'})
# 读取当前配置
# Read current configuration
current_env = read_env_file()
# 更新配置
# Update configuration
updates = {}
for group_key, group_values in data.items():
if group_key not in CONFIG_SCHEMA:
@@ -960,23 +960,23 @@ def save_settings():
if key in group_values:
new_value = group_values[key]
# 空值处理
# Null value handling
if new_value is None or new_value == '':
if not item.get('required', True):
updates[key] = ''
else:
updates[key] = str(new_value)
# 合并更新
# Merge updates
current_env.update(updates)
# 写入文件
# write file
if write_env_file(current_env):
# 清除配置缓存
# Clear configuration cache
clear_config_cache()
# 热重载运行时环境变量(无需重启进程)
# Hot reload runtime environment variables (no need to restart the process)
_reload_runtime_env()
# 重置依赖配置的服务单例(下次请求自动按新配置重建)
# Reset the service singleton that depends on the configuration (the next request will be automatically rebuilt according to the new configuration)
_refresh_runtime_services()
return jsonify({
@@ -1001,7 +1001,7 @@ def save_settings():
@login_required
@admin_required
def get_openrouter_balance():
"""查询 OpenRouter 账户余额 (admin only)"""
"""Check OpenRouter account balance (admin only)"""
try:
import requests
from app.config.api_keys import APIKeys
@@ -1014,7 +1014,7 @@ def get_openrouter_balance():
'data': None
})
# 调用 OpenRouter API 查询余额
# Call OpenRouter API to query balance
# https://openrouter.ai/docs#limits
resp = requests.get(
'https://openrouter.ai/api/v1/auth/key',
@@ -1027,11 +1027,11 @@ def get_openrouter_balance():
if resp.status_code == 200:
data = resp.json()
# OpenRouter 返回格式: {"data": {"label": "...", "usage": 0.0, "limit": null, ...}}
# OpenRouter return format: {"data": {"label": "...", "usage": 0.0, "limit": null, ...}}
key_data = data.get('data', {})
usage = key_data.get('usage', 0) # 已使用金额
limit = key_data.get('limit') # 限额(可能为null表示无限制)
limit_remaining = key_data.get('limit_remaining') # 剩余额度
usage = key_data.get('usage', 0) # Amount used
limit = key_data.get('limit') # limit (may be null for no limit)
limit_remaining = key_data.get('limit_remaining') # remaining balance
is_free_tier = key_data.get('is_free_tier', False)
rate_limit = key_data.get('rate_limit', {})
@@ -1039,9 +1039,9 @@ def get_openrouter_balance():
'code': 1,
'msg': 'success',
'data': {
'usage': round(usage, 4), # 已使用(美元)
'limit': limit, # 总限额
'limit_remaining': round(limit_remaining, 4) if limit_remaining is not None else None, # 剩余额度
'usage': round(usage, 4), # Used (USD)
'limit': limit, # total limit
'limit_remaining': round(limit_remaining, 4) if limit_remaining is not None else None, # remaining balance
'is_free_tier': is_free_tier,
'rate_limit': rate_limit,
'label': key_data.get('label', '')
@@ -1079,13 +1079,13 @@ def get_openrouter_balance():
@login_required
@admin_required
def test_connection():
"""测试API连接 (admin only)"""
"""Test API connection (admin only)"""
try:
data = request.get_json()
service = data.get('service')
if service == 'openrouter':
# 测试 OpenRouter 连接
# Test OpenRouter connection
from app.services.llm import LLMService
llm = LLMService()
result = llm.test_connection()
@@ -1095,7 +1095,7 @@ def test_connection():
return jsonify({'code': 0, 'msg': 'OpenRouter connection failed'})
elif service == 'finnhub':
# 测试 Finnhub 连接
# Test Finnhub connection
import requests
api_key = data.get('api_key') or os.getenv('FINNHUB_API_KEY')
if not api_key:
+8 -8
View File
@@ -600,7 +600,7 @@ def get_positions():
pct = _calc_pnl_percent(entry, size, pnl)
rr = dict(r)
# 确保 entry_price 有值(如果数据库中是 NULL,使用计算出的 entry 值)
# Make sure entry_price has a value (if it is NULL in the database, use the calculated entry value)
if not rr.get("entry_price") or float(rr.get("entry_price") or 0.0) <= 0:
rr["entry_price"] = float(entry or 0.0)
else:
@@ -818,10 +818,10 @@ def test_connection():
try:
data = request.get_json() or {}
# 记录请求数据(用于调试,但不记录敏感信息)
# Log request data (for debugging, but do not log sensitive information)
logger.debug(f"Connection test request keys: {list(data.keys())}")
# 获取交易所配置
# Get exchange configuration
exchange_config = data.get('exchange_config', data)
# Local deployment: no encryption/decryption; accept dict or JSON string.
@@ -832,7 +832,7 @@ def test_connection():
except Exception:
pass
# 验证 exchange_config 是否为字典
# Verify exchange_config is a dictionary
if not isinstance(exchange_config, dict):
logger.error(f"Invalid exchange_config type: {type(exchange_config)}, data: {str(exchange_config)[:200]}")
# Frontend expects HTTP 200 with {code:0} for business failures.
@@ -844,21 +844,21 @@ def test_connection():
user_id = g.user_id if hasattr(g, 'user_id') else 1
resolved = resolve_exchange_config(exchange_config, user_id=user_id)
# 验证必要字段 (check resolved config after credential merge)
# Verify required fields (check resolved config after credential merge)
if not resolved.get('exchange_id'):
return jsonify({'code': 0, 'msg': 'Please select an exchange', 'data': None})
api_key = resolved.get('api_key', '')
secret_key = resolved.get('secret_key', '')
# 详细日志排查
# Detailed log troubleshooting
logger.info(f"Testing connection: exchange_id={resolved.get('exchange_id')}")
if api_key:
logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})")
if secret_key:
logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})")
# 检查是否有特殊字符
# Check if there are special characters
if api_key and api_key.strip() != api_key:
logger.warning("API key contains leading/trailing whitespace")
if secret_key and secret_key.strip() != secret_key:
@@ -1052,7 +1052,7 @@ def get_strategy_notifications():
created_at = item.get('created_at')
if created_at:
if hasattr(created_at, 'timestamp'):
# 无时区 datetime:连接已 SET TIME ZONE UTC,按 UTC 解释再转 Unix,避免服务端本地 TZ 误判
# No time zone datetime: The connection has SET TIME ZONE UTC, interpret it according to UTC and then transfer to Unix to avoid misjudgment of the local TZ on the server side.
if getattr(created_at, 'tzinfo', None) is None:
created_at = created_at.replace(tzinfo=_dt_tz.utc)
item['created_at'] = int(created_at.timestamp())
+1 -1
View File
@@ -1,5 +1,5 @@
"""
业务服务层
business service layer
"""
from app.services.kline import KlineService
from app.services.backtest import BacktestService
@@ -20,11 +20,11 @@ logger = get_logger(__name__)
def _safe_json_parse(val, default=None):
"""安全解析 JSON - 处理已是 Python 对象或字符串的情况"""
"""Safely parse JSON - handle cases where it's already a Python object or string"""
if val is None:
return default
if isinstance(val, (dict, list)):
return val # 已经是 Python 对象 (PostgreSQL JSONB 自动转换)
return val # Already a Python object (PostgreSQL JSONB automatically converted)
if isinstance(val, str):
try:
return json.loads(val)
@@ -48,7 +48,7 @@ class AnalysisMemory:
with get_db_connection() as db:
cur = db.cursor()
# 创建表(如果不存在)
# Create table if it does not exist
cur.execute("""
CREATE TABLE IF NOT EXISTS qd_analysis_memory (
id SERIAL PRIMARY KEY,
@@ -80,7 +80,7 @@ class AnalysisMemory:
);
""")
# 检查并添加缺失的列(用于已存在的表)
# Check and add missing columns (for existing tables)
cur.execute("""
DO $$
BEGIN
@@ -151,7 +151,7 @@ class AnalysisMemory:
END $$;
""")
# 创建索引
#Create index
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol
ON qd_analysis_memory(market, symbol);
@@ -187,7 +187,7 @@ class AnalysisMemory:
with get_db_connection() as db:
cur = db.cursor()
# 准备数据
# Prepare data
market = analysis_result.get("market")
symbol = analysis_result.get("symbol")
decision = analysis_result.get("decision")
@@ -221,7 +221,7 @@ class AnalysisMemory:
"completed", "",
))
# 使用 lastrowid 属性获取 IDexecute 内部已经处理了 RETURNING
# Use the lastrowid attribute to get the ID (execute has already processed RETURNING internally)
memory_id = cur.lastrowid
db.commit()
cur.close()
@@ -425,8 +425,8 @@ class AnalysisMemory:
summary, reasons, scores, indicators, raw,
"processing", "",
))
# PostgresCursor.execute() 会在 INSERT 时提前 fetchone() 消耗 RETURNING 结果,
# 所以这里不要再 cur.fetchone(),直接取 lastrowid
# PostgresCursor.execute() will consume the RETURNING result in advance from fetchone() during INSERT.
# So dont use cur.fetchone() here, just get lastrowid.
memory_id = cur.lastrowid
db.commit()
cur.close()
+40 -40
View File
@@ -877,7 +877,7 @@ class BacktestService:
if i > 0 and i % progress_log_interval == 0:
progress_pct = (i / total_exec_candles) * 100
logger.info(f"Execution progress: {i}/{total_exec_candles} ({progress_pct:.1f}%), trades={executed_trades_count}, position={position}")
# 爆仓后直接停止回测,输出结果
# After the liquidation, the backtest will be stopped directly and the results will be output.
if is_liquidated:
break
@@ -1175,7 +1175,7 @@ class BacktestService:
executed_trades_count += 1
if executed_trades_count <= 10:
logger.info(f"Trade #{executed_trades_count}: close_short (before open_long) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}")
# 检查是否爆仓
# Check whether the position is liquidated
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
@@ -1232,7 +1232,7 @@ class BacktestService:
highest_since_entry = None
lowest_since_entry = None
pending_signal = None
# 检查是否爆仓
# Check whether the position is liquidated
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
@@ -1263,7 +1263,7 @@ class BacktestService:
executed_trades_count += 1
if executed_trades_count <= 10:
logger.info(f"Trade #{executed_trades_count}: close_long (before open_short) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}")
# 检查是否爆仓
# Check whether the position is liquidated
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
@@ -1321,7 +1321,7 @@ class BacktestService:
highest_since_entry = None
lowest_since_entry = None
pending_signal = None
# 检查是否爆仓
# Check whether the position is liquidated
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
@@ -1763,16 +1763,16 @@ class BacktestService:
local_vars['commission'] = backtest_params.get('commission', 0.0002)
local_vars['trade_direction'] = backtest_params.get('trade_direction', 'both')
# === 指标参数支持 ===
# backtest_params 获取用户设置的指标参数
# === Indicator parameter support ===
# Get the indicator parameters set by the user from backtest_params
user_indicator_params = (backtest_params or {}).get('indicator_params', {})
# 解析指标代码中声明的参数
# Parse the parameters declared in the indicator code
declared_params = IndicatorParamsParser.parse_params(code)
# 合并参数(用户值优先,否则使用默认值)
# Merge parameters (user values take precedence, otherwise default values are used)
merged_params = IndicatorParamsParser.merge_params(declared_params, user_indicator_params)
local_vars['params'] = merged_params
# === 指标调用器支持 ===
# === Indicator caller support ===
user_id = (backtest_params or {}).get('user_id', 1)
indicator_id = (backtest_params or {}).get('indicator_id')
indicator_caller = IndicatorCaller(user_id, indicator_id)
@@ -2453,7 +2453,7 @@ import pandas as pd
add_short_price_arr = signals.get('add_short_price', pd.Series([0.0] * len(df))).values
for i, (timestamp, row) in enumerate(df.iterrows()):
# 爆仓后直接停止回测,输出结果
# After the liquidation, the backtest will be stopped directly and the results will be output.
if is_liquidated:
break
@@ -2471,7 +2471,7 @@ import pandas as pd
'balance': 0
})
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
break # 直接停止
break # Stop directly
# Use OHLC to evaluate triggers.
high = row['high']
@@ -3126,7 +3126,7 @@ import pandas as pd
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
# 检查是否爆仓
# Check whether the position is liquidated
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
@@ -3251,7 +3251,7 @@ import pandas as pd
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
# 检查是否爆仓
# Check whether the position is liquidated
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
@@ -3354,7 +3354,7 @@ import pandas as pd
# If liquidation hit, check SL signal first
if position != 0 and not is_liquidated:
if position_type == 'long' and low <= liquidation_price:
# Long触及爆仓线:检查是否有止损信号
# Long touches the liquidation line: check whether there is a stop loss signal
has_stop_loss = close_long_arr[i] and close_long_price_arr[i] > 0
stop_loss_price = close_long_price_arr[i] if has_stop_loss else 0
@@ -3397,7 +3397,7 @@ import pandas as pd
continue
elif position_type == 'short' and high >= liquidation_price:
# Short触及爆仓线:检查是否有止损信号
# Short hits the liquidation line: check if there is a stop loss signal
has_stop_loss = close_short_arr[i] and close_short_price_arr[i] > 0
stop_loss_price = close_short_price_arr[i] if has_stop_loss else 0
@@ -3530,7 +3530,7 @@ import pandas as pd
strategy_config: Optional[Dict[str, Any]] = None
) -> tuple:
"""
使用旧格式信号进行交易模拟保持兼容性
Trading simulation using old format signals (maintaining compatibility)
"""
equity_curve = []
trades = []
@@ -3628,7 +3628,7 @@ import pandas as pd
signals_exec = signals
for i, (timestamp, row) in enumerate(df.iterrows()):
# 爆仓后直接停止回测,输出结果
# After the liquidation, the backtest will be stopped directly and the results will be output.
if is_liquidated:
break
@@ -3775,7 +3775,7 @@ import pandas as pd
if signal == 0 and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade:
# Long
if position_type == 'long' and position > 0:
# Trend add(顺势加仓:上涨触发)
# Trend add (increase positions with the trend: rising trigger)
if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 + trend_add_step_pct_eff)
@@ -3808,7 +3808,7 @@ import pandas as pd
'balance': round(max(0, capital), 2)
})
# DCA add(逆势加仓:下跌触发)
# DCA add (Add position against the trend: Triggered by decline)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 - dca_add_step_pct_eff)
@@ -3841,7 +3841,7 @@ import pandas as pd
'balance': round(max(0, capital), 2)
})
# Trend reduce(顺势减仓:上涨触发)
# Trend reduce (reduce positions with the trend: triggered by rising prices)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 + trend_reduce_step_pct_eff)
@@ -3876,7 +3876,7 @@ import pandas as pd
'balance': round(max(0, capital), 2)
})
# Adverse reduce(逆势减仓:下跌触发)
# Adverse reduce (against the trend: falling trigger)
if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 - adverse_reduce_step_pct_eff)
@@ -3915,7 +3915,7 @@ import pandas as pd
if position_type == 'short' and position < 0:
shares_total = abs(position)
# Trend add(顺势加空:下跌触发)
# Trend add (Add short with the trend: triggered by decline)
if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 - trend_add_step_pct_eff)
@@ -3949,7 +3949,7 @@ import pandas as pd
'balance': round(max(0, capital), 2)
})
# DCA add(逆势加空:上涨触发)
# DCA add (Add short against the trend: rise trigger)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 + dca_add_step_pct_eff)
@@ -3983,7 +3983,7 @@ import pandas as pd
'balance': round(max(0, capital), 2)
})
# Trend reduce(顺势减空:下跌触发,回补一部分)
# Trend reduce (short reduction: triggered by decline, covering part of it)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 - trend_reduce_step_pct_eff)
@@ -4019,7 +4019,7 @@ import pandas as pd
'balance': round(max(0, capital), 2)
})
# Adverse reduce(逆势减空:上涨触发)
# Adverse reduce (against the trend: rising trigger)
if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 + adverse_reduce_step_pct_eff)
@@ -4464,7 +4464,7 @@ import pandas as pd
# Note: check after all signals, SL/TP takes priority
if position != 0 and not is_liquidated:
if position_type == 'long':
# Long爆仓:价格跌破爆仓线
# Long liquidation: the price falls below the liquidation line
if price <= liquidation_price:
logger.warning(f"Long liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}")
is_liquidated = True
@@ -4486,7 +4486,7 @@ import pandas as pd
})
continue
elif position_type == 'short':
# Short爆仓:价格涨破爆仓线
# Short liquidation: the price rises above the liquidation line
if price >= liquidation_price:
logger.warning(f"Short liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}")
is_liquidated = True
@@ -4603,7 +4603,7 @@ import pandas as pd
end_date: datetime,
total_commission: float = 0
) -> Dict:
"""计算回测指标"""
"""Calculate backtest indicators"""
if not equity_curve:
return {}
@@ -4670,7 +4670,7 @@ import pandas as pd
}
def _calculate_max_drawdown(self, values: List[float]) -> float:
"""计算最大回撤"""
"""Calculate maximum drawdown"""
if not values:
return 0
@@ -4688,12 +4688,12 @@ import pandas as pd
def _calculate_sharpe(self, values: List[float], timeframe: str = '1D', risk_free_rate: float = 0.02) -> float:
"""
计算夏普比率
Calculate Sharpe Ratio
Args:
values: 权益曲线数值列表
timeframe: 时间周期
risk_free_rate: 无风险收益率年化
values: list of equity curve values
timeframe: time period
risk_free_rate: risk-free rate of return (annualized)
"""
if len(values) < 2:
return 0
@@ -4706,11 +4706,11 @@ import pandas as pd
# Determine annualization factor by timeframe
annualization_factor = {
'1m': 252 * 24 * 60, # 1m candle: ~362,880
'5m': 252 * 24 * 12, # 5分钟K:约72,576
'15m': 252 * 24 * 4, # 15分钟K:约24,192
'30m': 252 * 24 * 2, # 30分钟K:约12,096
'5m': 252 * 24 * 12, # 5 minutes K: about 72,576
'15m': 252 * 24 * 4, # 15 minutes K: about 24,192
'30m': 252 * 24 * 2, # 30 minutes K: about 12,096
'1H': 252 * 24, # 1H candle: 6,048
'4H': 252 * 6, # 4小时K1,512
'4H': 252 * 6, # 4 hours K: 1,512
'1D': 252, # 1D candle: 252
'1W': 52 # 1W candle: 52
}.get(timeframe, 252)
@@ -4746,7 +4746,7 @@ import pandas as pd
equity_curve: List,
trades: List
) -> Dict[str, Any]:
"""格式化回测结果"""
"""Format backtest results"""
# Simplify equity curve
max_points = 500
if len(equity_curve) > max_points:
@@ -4755,7 +4755,7 @@ import pandas as pd
# Clean NaN/Inf values for JSON serialization
def clean_value(value):
"""清理数值,将NaN/Inf转换为0"""
"""Clean values, convert NaN/Inf to 0"""
if isinstance(value, float):
if np.isnan(value) or np.isinf(value):
return 0
@@ -1,13 +1,13 @@
"""
Billing Service - 统一计费服务
Billing Service - unified billing service
负责用户积分余额功能扣费会员状态与套餐发放
当前计费模型为
1. 是否扣费由 `BILLING_ENABLED` 与各功能 cost 配置决定
2. VIP/会员状态用于会员套餐与权益展示
3. 社区指标的 `vip_free` 逻辑在社区购买流程中单独处理不在这里做全局旁路
Responsible for user points balance, function deductions, membership status and package issuance.
The current billing model is:
1. Whether to deduct fees is determined by `BILLING_ENABLED` and the cost configuration of each function.
2. VIP/member status is used to display membership packages and benefits.
3. The `vip_free` logic of community indicators is processed separately in the community purchase process, and no global bypass is done here.
计费配置存储在 `.env` 文件中可通过系统设置界面配置
Accounting configuration is stored in the `.env` file and can be configured through the system settings interface.
"""
import os
import time
@@ -21,16 +21,16 @@ from app.utils.logger import get_logger
logger = get_logger(__name__)
# 功能计费配置键名
# Function billing configuration key name
BILLING_CONFIG_PREFIX = 'BILLING_'
# 默认计费配置
# Default billing configuration
DEFAULT_BILLING_CONFIG = {
# 全局开关
'enabled': False, # 是否启用计费
# global switch
'enabled': False, # Whether to enable billing
# 各功能积分消耗(0表示免费)
# ai_analysis 统一单价:即时分析 / AI过滤 / 定时任务 均按此单价 × 标的数扣费
# Point consumption for each function (0 means free)
# ai_analysis unified unit price: real-time analysis / AI filtering / scheduled tasks are all deducted based on this unit price × number of targets
'cost_ai_analysis': 10,
'cost_ai_code_gen': 30,
'cost_polymarket_deep_analysis': 15,
@@ -45,15 +45,15 @@ FEATURE_NAMES = {
class BillingService:
"""计费服务类"""
"""Billing service category"""
def __init__(self):
self._config_cache = None
self._config_cache_time = 0
self._cache_ttl = 60 # 配置缓存60秒
self._cache_ttl = 60 # Configure cache for 60 seconds
def get_billing_config(self) -> Dict[str, Any]:
"""获取计费配置"""
"""Get billing configuration"""
now = time.time()
if self._config_cache and (now - self._config_cache_time) < self._cache_ttl:
return self._config_cache
@@ -80,23 +80,23 @@ class BillingService:
return config
def clear_config_cache(self):
"""清除配置缓存"""
"""Clear configuration cache"""
self._config_cache = None
self._config_cache_time = 0
def is_billing_enabled(self) -> bool:
"""检查是否启用计费"""
"""Check if billing is enabled"""
config = self.get_billing_config()
return config.get('enabled', False)
def get_feature_cost(self, feature: str) -> int:
"""获取指定功能的积分消耗,0 表示免费"""
"""Get the points consumption of the specified function, 0 means free"""
config = self.get_billing_config()
cost_key = f'cost_{feature}'
return config.get(cost_key, 0)
def get_user_credits(self, user_id: int) -> Decimal:
"""获取用户积分余额"""
"""Get user points balance"""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -116,10 +116,10 @@ class BillingService:
def get_user_vip_status(self, user_id: int) -> Tuple[bool, Optional[datetime]]:
"""
获取用户VIP状态
Get user VIP status
Returns:
(is_vip, expires_at): VIP是否有效, VIP过期时间
(is_vip, expires_at): whether the VIP is valid, VIP expiration time
"""
try:
with get_db_connection() as db:
@@ -138,11 +138,11 @@ class BillingService:
if row and row.get('vip_expires_at'):
expires_at = row['vip_expires_at']
# 确保是 datetime 对象
# Make sure it is a datetime object
if isinstance(expires_at, str):
expires_at = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
# 检查是否过期
# Check if expired
now = datetime.now(timezone.utc)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
@@ -449,49 +449,49 @@ class BillingService:
def check_and_consume(self, user_id: int, feature: str, reference_id: str = '') -> Tuple[bool, str]:
"""
检查并消耗积分
Check and spend points
Args:
user_id: 用户ID
feature: 功能名称ai_analysis / polymarket_deep_analysis
reference_id: 关联ID可选
user_id: user ID
feature: function name (ai_analysis / polymarket_deep_analysis)
reference_id: association ID (optional)
Returns:
(success, message): 是否成功, 提示消息
(success, message): Whether it is successful, prompt message
"""
# 检查是否启用计费
# Check if billing is enabled
if not self.is_billing_enabled():
return True, 'billing_disabled'
config = self.get_billing_config()
cost = self.get_feature_cost(feature)
# 免费功能
# free features
if cost <= 0:
return True, 'free_feature'
# 说明:这里不再根据 VIP 做全局免扣积分旁路。
# VIP / membership 仅保留为套餐、到期时间和社区 vip_free 指标权益。
# Note: There is no longer a global point deduction bypass based on VIP.
# VIP/membership is reserved only for package, expiry time and community vip_free metric benefits.
# 检查积分余额
# Check points balance
credits = self.get_user_credits(user_id)
if credits < cost:
return False, f'insufficient_credits:{credits}:{cost}'
# 扣除积分
# Points deducted
try:
new_balance = credits - Decimal(str(cost))
with get_db_connection() as db:
cur = db.cursor()
# 更新用户积分
# Update user points
cur.execute(
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
(float(new_balance), user_id)
)
# 记录日志 - 使用 UTC 时间确保跨时区显示正确
# Logging - Use UTC time to ensure correct display across time zones
feature_name = FEATURE_NAMES.get(feature, feature)
created_at_utc = datetime.now(timezone.utc)
cur.execute(
@@ -516,15 +516,15 @@ class BillingService:
def add_credits(self, user_id: int, amount: int, action: str = 'recharge',
remark: str = '', operator_id: int = None, reference_id: str = '') -> Tuple[bool, str]:
"""
增加用户积分
Increase user points
Args:
user_id: 用户ID
amount: 增加金额正数
action: 操作类型recharge/admin_adjust/refund/referral_bonus/register_bonus
remark: 备注
operator_id: 操作人ID管理员操作时
reference_id: 关联ID如被邀请用户ID订单号等
user_id: user ID
amount: increase the amount (positive number)
action: action type (recharge/admin_adjust/refund/referral_bonus/register_bonus)
remark: remark
operator_id: operator ID (when the administrator operates)
reference_id: association ID (such as invited user ID, order number, etc.)
Returns:
(success, message)
@@ -539,13 +539,13 @@ class BillingService:
with get_db_connection() as db:
cur = db.cursor()
# 更新用户积分
# Update user points
cur.execute(
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
(float(new_balance), user_id)
)
# 记录日志(包含 reference_id
# Logging (contains reference_id)
cur.execute(
"""
INSERT INTO qd_credits_log
@@ -568,13 +568,13 @@ class BillingService:
def set_credits(self, user_id: int, amount: int, remark: str = '',
operator_id: int = None) -> Tuple[bool, str]:
"""
设置用户积分管理员直接设置
Set user points (set directly by the administrator)
Args:
user_id: 用户ID
amount: 设置的金额
remark: 备注
operator_id: 操作人ID
user_id: user ID
amount: the set amount
remark: remark
operator_id: operator ID
Returns:
(success, message)
@@ -589,13 +589,13 @@ class BillingService:
with get_db_connection() as db:
cur = db.cursor()
# 更新用户积分
# Update user points
cur.execute(
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
(amount, user_id)
)
# 记录日志
# logging
cur.execute(
"""
INSERT INTO qd_credits_log
@@ -618,13 +618,13 @@ class BillingService:
def set_vip(self, user_id: int, expires_at: Optional[datetime],
remark: str = '', operator_id: int = None) -> Tuple[bool, str]:
"""
设置用户VIP状态
Set user VIP status
Args:
user_id: 用户ID
expires_at: VIP过期时间None表示取消VIP
remark: 备注
operator_id: 操作人ID
user_id: user ID
expires_at: VIP expiration time (None means canceling VIP)
remark: remark
operator_id: operator ID
Returns:
(success, message)
@@ -633,13 +633,13 @@ class BillingService:
with get_db_connection() as db:
cur = db.cursor()
# 更新VIP过期时间
# Update VIP expiration time
cur.execute(
"UPDATE qd_users SET vip_expires_at = ?, updated_at = NOW() WHERE id = ?",
(expires_at, user_id)
)
# 记录日志
# logging
action = 'vip_grant' if expires_at else 'vip_revoke'
log_remark = remark or (f'VIP granted until {expires_at}' if expires_at else 'VIP revoked')
cur.execute(
@@ -662,21 +662,21 @@ class BillingService:
return False, str(e)
def get_credits_log(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取用户积分变动日志"""
"""Get user points change log"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# 获取总数
# Get total
cur.execute(
"SELECT COUNT(*) as count FROM qd_credits_log WHERE user_id = ?",
(user_id,)
)
total = cur.fetchone()['count']
# 获取日志
# Get log
cur.execute(
"""
SELECT id, action, amount, balance_after, feature, reference_id, remark, created_at
@@ -700,7 +700,7 @@ class BillingService:
if getattr(dt, 'tzinfo', None) is not None:
d['created_at'] = dt.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S') + 'Z'
else:
# 无时区:新记录用 UTC 写入,旧记录可能为服务器本地时间,统一按 UTC 返回以便前端正确转换
# No time zone: New records are written in UTC, and old records may be in the local time of the server. They are returned in UTC to facilitate correct conversion by the front end.
d['created_at'] = dt.strftime('%Y-%m-%dT%H:%M:%S') + 'Z'
logs.append(d)
@@ -716,7 +716,7 @@ class BillingService:
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def get_user_billing_info(self, user_id: int) -> Dict[str, Any]:
"""获取用户计费与会员信息快照(供前端显示)"""
"""Get a snapshot of user billing and membership information (for front-end display)"""
credits = self.get_user_credits(user_id)
is_vip, vip_expires_at = self.get_user_vip_status(user_id)
config = self.get_billing_config()
@@ -734,12 +734,12 @@ class BillingService:
}
# 全局单例
# Global singleton
_billing_service = None
def get_billing_service() -> BillingService:
"""获取计费服务单例"""
"""Get billing service singleton instance"""
global _billing_service
if _billing_service is None:
_billing_service = BillingService()
@@ -1,7 +1,7 @@
"""
Community Service - 指标社区服务
Community Service - indicator community service
处理指标市场购买评论等功能
Handles functions such as indicator markets, purchases, and comments.
"""
import json
import time
@@ -16,7 +16,7 @@ logger = get_logger(__name__)
class CommunityService:
"""指标社区服务类"""
"""Indicator Community Service Category"""
def __init__(self):
self.billing = get_billing_service()
@@ -31,7 +31,7 @@ class CommunityService:
pass
# ==========================================
# 指标市场
# indicator market
# ==========================================
def get_market_indicators(
@@ -41,16 +41,16 @@ class CommunityService:
keyword: str = None,
pricing_type: str = None, # 'free' / 'paid' / None(all)
sort_by: str = 'newest', # 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating'
user_id: int = None # 当前用户ID,用于判断是否已购买
user_id: int = None # Current user ID, used to determine whether purchase has been made
) -> Dict[str, Any]:
"""获取市场上已发布的指标列表"""
"""Get a list of published indicators on the market"""
offset = (page - 1) * page_size
try:
with get_db_connection() as db:
cur = db.cursor()
# 构建查询条件 - 只显示已发布且审核通过的指标
# Build query conditions - only display published and approved indicators
where_clauses = ["i.publish_to_community = 1", "(i.review_status = 'approved' OR i.review_status IS NULL)"]
params = []
@@ -66,7 +66,7 @@ class CommunityService:
where_sql = " AND ".join(where_clauses)
# 排序
# sort
order_map = {
'newest': 'i.created_at DESC',
'hot': 'i.purchase_count DESC, i.view_count DESC',
@@ -76,7 +76,7 @@ class CommunityService:
}
order_sql = order_map.get(sort_by, 'i.created_at DESC')
# 获取总数
# Get total
count_sql = f"""
SELECT COUNT(*) as count
FROM qd_indicator_codes i
@@ -85,7 +85,7 @@ class CommunityService:
cur.execute(count_sql, tuple(params))
total = cur.fetchone()['count']
# 获取列表(联表查询作者信息)
# Get the list (join the table to query author information)
query_sql = f"""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
@@ -102,7 +102,7 @@ class CommunityService:
cur.execute(query_sql, tuple(params + [page_size, offset]))
rows = cur.fetchall() or []
# 如果有当前用户,查询已购买的指标
# If there is a current user, query the purchased indicators
purchased_ids = set()
if user_id:
indicator_ids = [r['id'] for r in rows]
@@ -116,7 +116,7 @@ class CommunityService:
cur.close()
# 格式化返回数据
#Format return data
items = []
for row in rows:
items.append({
@@ -160,7 +160,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 获取指标信息
# Get indicator information
cur.execute("""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
@@ -179,12 +179,12 @@ class CommunityService:
cur.close()
return None
# 检查是否已发布到社区(或者是自己的指标)
# Check if it has been published to the community (or its own indicator)
if not row['publish_to_community'] and row['user_id'] != user_id:
cur.close()
return None
# 检查是否已购买
# Check if purchased
is_purchased = False
if user_id:
cur.execute(
@@ -193,7 +193,7 @@ class CommunityService:
)
is_purchased = cur.fetchone() is not None
# 增加浏览次数
# Increase the number of views
cur.execute(
"UPDATE qd_indicator_codes SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?",
(indicator_id,)
@@ -230,7 +230,7 @@ class CommunityService:
return None
# ==========================================
# 购买功能
# Purchase function
# ==========================================
def purchase_indicator(self, buyer_id: int, indicator_id: int) -> Tuple[bool, str, Dict[str, Any]]:
@@ -244,7 +244,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 1. 获取指标信息
# 1. Get indicator information
cur.execute("""
SELECT id, user_id, name, code, description, pricing_type, price, COALESCE(vip_free, FALSE) as vip_free,
preview_image, is_encrypted
@@ -266,12 +266,12 @@ class CommunityService:
# VIP-free indicator: VIP users can get it without credits charge
effective_price = 0.0 if (vip_free and is_vip) else price
# 2. 检查是否购买自己的指标
# 2. Check whether to buy your own indicator
if seller_id == buyer_id:
cur.close()
return False, 'cannot_buy_own', {}
# 3. 检查是否已购买
# 3. Check if purchased
cur.execute(
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
(indicator_id, buyer_id)
@@ -280,7 +280,7 @@ class CommunityService:
cur.close()
return False, 'already_purchased', {}
# 4. 如果是付费指标,检查并扣除积分
# 4. If it is a paid indicator, check and deduct points
if pricing_type != 'free' and effective_price > 0:
buyer_credits = self.billing.get_user_credits(buyer_id)
if buyer_credits < effective_price:
@@ -290,22 +290,22 @@ class CommunityService:
'current': float(buyer_credits)
}
# 扣除买家积分
# Deduct buyer points
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)
)
# 记录买家积分日志
# Record buyer points log
cur.execute("""
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, -effective_price, float(new_buyer_balance), str(indicator_id),
f"购买指标: {indicator['name']}"))
f"Buy indicator: {indicator['name']}"))
# 给卖家增加积分(可配置抽成比例,这里先100%给卖家)
# Increase points for sellers (the commission ratio can be configured, here 100% is given to sellers first)
seller_credits = self.billing.get_user_credits(seller_id)
new_seller_balance = seller_credits + Decimal(str(effective_price))
cur.execute(
@@ -313,22 +313,22 @@ class CommunityService:
(float(new_seller_balance), seller_id)
)
# 记录卖家积分日志
# Record seller points log
cur.execute("""
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, effective_price, float(new_seller_balance), str(indicator_id),
f"出售指标: {indicator['name']}"))
f"Sell indicator: {indicator['name']}"))
# 5. 创建购买记录
# 5. Create purchase record
cur.execute("""
INSERT INTO qd_indicator_purchases
(indicator_id, buyer_id, seller_id, price, created_at)
VALUES (?, ?, ?, ?, NOW())
""", (indicator_id, buyer_id, seller_id, effective_price))
# 6. 复制指标到买家账户
# 6. Copy the indicator to the buyers account
now_ts = int(time.time())
# Get vip_free as boolean from indicator
vip_free_value = bool(indicator.get('vip_free') or False)
@@ -349,7 +349,7 @@ class CommunityService:
now_ts, now_ts
))
# 7. 更新指标购买次数
# 7. Update indicator purchase times
cur.execute("""
UPDATE qd_indicator_codes
SET purchase_count = COALESCE(purchase_count, 0) + 1
@@ -374,14 +374,14 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 获取总数
# Get total
cur.execute(
"SELECT COUNT(*) as count FROM qd_indicator_purchases WHERE buyer_id = ?",
(user_id,)
)
total = cur.fetchone()['count']
# 获取列表
# Get list
cur.execute("""
SELECT
p.id as purchase_id, p.price as purchase_price, p.created_at as purchase_time,
@@ -429,7 +429,7 @@ class CommunityService:
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
# ==========================================
# 评论功能
# Comment function
# ==========================================
def get_comments(self, indicator_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
@@ -440,14 +440,14 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 获取总数(只统计一级评论)
# Get the total number (only first-level comments are counted)
cur.execute("""
SELECT COUNT(*) as count FROM qd_indicator_comments
WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
""", (indicator_id,))
total = cur.fetchone()['count']
# 获取评论列表
# Get the list of comments
cur.execute("""
SELECT
c.id, c.rating, c.content, c.created_at,
@@ -498,14 +498,14 @@ class CommunityService:
添加评论只有购买过的用户可以评论且只能评论一次
"""
try:
# 验证评分范围
# Verify score range
rating = max(1, min(5, int(rating)))
content = (content or '').strip()[:500] # 限制500字
content = (content or '').strip()[:500] # Limit 500 words
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在
# Check if the indicator exists
cur.execute(
"SELECT id, user_id FROM qd_indicator_codes WHERE id = ? AND publish_to_community = 1",
(indicator_id,)
@@ -515,12 +515,12 @@ class CommunityService:
cur.close()
return False, 'indicator_not_found', {}
# 不能评论自己的指标
# Cannot comment on own indicators
if indicator['user_id'] == user_id:
cur.close()
return False, 'cannot_comment_own', {}
# 检查是否已购买(免费指标也需要"获取"才能评论)
# Check if it has been purchased (free indicators also need to be "acquired" to comment)
cur.execute(
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
(indicator_id, user_id)
@@ -529,7 +529,7 @@ class CommunityService:
cur.close()
return False, 'not_purchased', {}
# 检查是否已评论
# Check if comments have been made
cur.execute(
"SELECT id FROM qd_indicator_comments WHERE indicator_id = ? AND user_id = ? AND parent_id IS NULL",
(indicator_id, user_id)
@@ -538,7 +538,7 @@ class CommunityService:
cur.close()
return False, 'already_commented', {}
# 添加评论
#Add comment
cur.execute("""
INSERT INTO qd_indicator_comments
(indicator_id, user_id, rating, content, created_at, updated_at)
@@ -546,7 +546,7 @@ class CommunityService:
""", (indicator_id, user_id, rating, content))
comment_id = cur.lastrowid
# 更新指标的评分统计
# Update the scoring statistics of the indicator
cur.execute("""
UPDATE qd_indicator_codes
SET
@@ -586,7 +586,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 检查评论是否存在且属于当前用户
# Check if the comment exists and belongs to the current user
cur.execute("""
SELECT id, rating as old_rating FROM qd_indicator_comments
WHERE id = ? AND user_id = ? AND indicator_id = ? AND is_deleted = 0
@@ -599,14 +599,14 @@ class CommunityService:
old_rating = comment['old_rating']
# 更新评论
# Update comment
cur.execute("""
UPDATE qd_indicator_comments
SET rating = ?, content = ?, updated_at = NOW()
WHERE id = ?
""", (rating, content, comment_id))
# 如果评分变了,更新指标的平均评分
# If the rating changes, update the average rating of the metric
if old_rating != rating:
cur.execute("""
UPDATE qd_indicator_codes
@@ -656,7 +656,7 @@ class CommunityService:
return None
# ==========================================
# 管理员审核功能
# Administrator review function
# ==========================================
def get_pending_indicators(
@@ -672,7 +672,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 构建查询条件
# Build query conditions
where_clauses = ["i.publish_to_community = 1"]
params = []
@@ -682,7 +682,7 @@ class CommunityService:
where_sql = " AND ".join(where_clauses)
# 获取总数
# Get total
count_sql = f"""
SELECT COUNT(*) as count
FROM qd_indicator_codes i
@@ -691,7 +691,7 @@ class CommunityService:
cur.execute(count_sql, tuple(params))
total = cur.fetchone()['count']
# 获取列表
# Get the list
query_sql = f"""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price,
@@ -720,7 +720,7 @@ class CommunityService:
'pricing_type': row['pricing_type'] or 'free',
'price': float(row['price'] or 0),
'preview_image': row['preview_image'] or '',
'code': row['code'] or '', # 管理员可以看代码
'code': row['code'] or '', # Administrators can view the code
'review_status': row['review_status'] or 'pending',
'review_note': row['review_note'] or '',
'reviewed_at': row['reviewed_at'].isoformat() if row['reviewed_at'] else None,
@@ -761,7 +761,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在且已发布到社区
# Check if the indicator exists and has been published to the community
cur.execute("""
SELECT id, name, user_id FROM qd_indicator_codes
WHERE id = ? AND publish_to_community = 1
@@ -772,7 +772,7 @@ class CommunityService:
cur.close()
return False, 'indicator_not_found'
# 更新审核状态
# Update review status
cur.execute("""
UPDATE qd_indicator_codes
SET review_status = ?, review_note = ?, reviewed_at = NOW(), reviewed_by = ?
@@ -797,7 +797,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在
# Check if the indicator exists
cur.execute("""
SELECT id, name FROM qd_indicator_codes WHERE id = ?
""", (indicator_id,))
@@ -807,13 +807,13 @@ class CommunityService:
cur.close()
return False, 'indicator_not_found'
# 下架(取消发布)
# Remove from shelves (cancel publication)
cur.execute("""
UPDATE qd_indicator_codes
SET publish_to_community = 0, review_status = 'rejected',
review_note = ?, reviewed_at = NOW(), reviewed_by = ?
WHERE id = ?
""", (f"下架: {note}" if note else "管理员下架", admin_id, indicator_id))
""", (f"Removal: {note}" if note else "Removal by administrator", admin_id, indicator_id))
db.commit()
cur.close()
@@ -831,7 +831,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# 检查指标是否存在
# Check if the indicator exists
cur.execute("SELECT id, name FROM qd_indicator_codes WHERE id = ?", (indicator_id,))
indicator = cur.fetchone()
@@ -839,13 +839,13 @@ class CommunityService:
cur.close()
return False, 'indicator_not_found'
# 删除关联的评论
# Delete associated comments
cur.execute("DELETE FROM qd_indicator_comments WHERE indicator_id = ?", (indicator_id,))
# 删除关联的购买记录
# Delete associated purchase history
cur.execute("DELETE FROM qd_indicator_purchases WHERE indicator_id = ?", (indicator_id,))
# 删除指标
# Delete indicator
cur.execute("DELETE FROM qd_indicator_codes WHERE id = ?", (indicator_id,))
db.commit()
@@ -859,7 +859,7 @@ class CommunityService:
return False, f'error: {str(e)}'
def get_review_stats(self) -> Dict[str, int]:
"""获取审核统计"""
"""Get audit statistics"""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -884,7 +884,7 @@ class CommunityService:
return {'pending': 0, 'approved': 0, 'rejected': 0}
# ==========================================
# 实盘表现(聚合回测 + 实盘交易数据)
# Real performance (aggregated backtest + real transaction data)
# ==========================================
def get_indicator_performance(self, indicator_id: int) -> Dict[str, Any]:
@@ -908,7 +908,7 @@ class CommunityService:
with get_db_connection() as db:
cur = db.cursor()
# ---------- Part 1: 回测数据(从 result_json 解析) ----------
# ---------- Part 1: Backtest data (parsed from result_json) ----------
bt_returns = []
bt_win_rates = []
bt_drawdowns = []
@@ -941,21 +941,21 @@ class CommunityService:
bt_run_count = len(bt_returns)
# ---------- Part 2: 实盘交易数据 ----------
# ---------- Part 2: Real transaction data ----------
live_strategy_count = 0
live_trade_count = 0
live_win_rate = 0.0
live_total_profit = 0.0
try:
# 找出使用该指标的策略(indicator_config JSON 中 indicator_id 匹配)
# Find the strategy that uses this indicator (matches indicator_id in indicator_config JSON)
cur.execute("""
SELECT id FROM qd_strategies_trading
WHERE indicator_config::text LIKE %s
""", (f'%"indicator_id": {indicator_id}%',))
strategy_rows = cur.fetchall()
# 也尝试匹配无空格的格式
# Also try to match formats without spaces
if not strategy_rows:
cur.execute("""
SELECT id FROM qd_strategies_trading
@@ -993,7 +993,7 @@ class CommunityService:
total_strategy_count = bt_run_count + live_strategy_count
total_trade_count = sum(bt_trade_counts) + live_trade_count
# 综合胜率:优先实盘 > 回测平均
# Comprehensive winning rate: Prioritize real offer > Backtest average
if live_trade_count > 0:
combined_win_rate = live_win_rate
elif bt_win_rates:
@@ -1001,13 +1001,13 @@ class CommunityService:
else:
combined_win_rate = 0.0
# 平均收益率(回测 totalReturn %
# Average return (backtest totalReturn %)
avg_return = round(sum(bt_returns) / len(bt_returns), 2) if bt_returns else 0.0
# 总利润:优先用实盘绝对利润,无实盘则显示回测平均收益率
#Total profit: priority is given to the absolute profit of the real offer. If there is no real offer, the average return rate of the backtest will be displayed.
combined_profit = live_total_profit if live_trade_count > 0 else avg_return
# 最大回撤取回测中最差的(maxDrawdown 是负数,取最小即最差)
# The maximum drawdown is the worst in the backtest (maxDrawdown is a negative number, the smallest is the worst)
avg_drawdown = round(min(bt_drawdowns), 2) if bt_drawdowns else 0.0
if total_strategy_count == 0 and total_trade_count == 0:
@@ -1027,7 +1027,7 @@ class CommunityService:
return default_result
# 全局单例
# Global singleton
_community_service = None
+199 -199
View File
@@ -1,12 +1,12 @@
"""
Fast Analysis Service 3.0
系统性重构版本 - 使用统一的数据采集器
Systematic refactoring version - using unified data collector
核心改进
1. 数据源统一 - 使用 MarketDataCollector与K线模块自选列表完全一致
2. 宏观数据 - 新增美元指数VIX利率等宏观经济指标
3. 多维新闻 - 使用结构化API无需深度阅读
4. 单次LLM调用 - 强约束prompt输出结构化分析
Core improvements:
1. Unified data sources - use MarketDataCollector, which is completely consistent with the K-line module and watch list
2. Macroeconomic data - added macroeconomic indicators such as the US dollar index, VIX, and interest rates
3. Multi-dimensional news - using structured API, no need for in-depth reading
4. Single LLM call - strong constraint prompt, output structured analysis
"""
import json
import os
@@ -185,12 +185,12 @@ def _is_major_geopolitical_news_text(combined_text: str) -> bool:
class FastAnalysisService:
"""
快速分析服务 3.0
Rapid Analysis Service 3.0
架构
1. 数据采集层 - MarketDataCollector (统一数据源)
2. 分析层 - 单次LLM调用 (强约束prompt)
3. 记忆层 - 分析历史存储和检索
Architecture:
1. Data collection layer - MarketDataCollector (unified data source)
2. Analysis layer - single LLM call (strong constraint prompt)
3. Memory layer - analysis history storage and retrieval
"""
def __init__(self):
@@ -212,14 +212,14 @@ class FastAnalysisService:
timeout: int = 45,
) -> Dict[str, Any]:
"""
使用统一的数据采集器收集市场数据
Collect market data using a unified data collector
数据层次
1. 核心数据: 价格K线技术指标
2. 基本面: 公司信息财务数据
3. 宏观数据: DXYVIXTNX黄金等
4. 情绪数据: 新闻市场情绪
5. 预测市场: 相关预测市场事件新增
Data level:
1. Core data: price, K-line, technical indicators
2. Fundamentals: Company information, financial data
3. Macro data: DXY, VIX, TNX, gold, etc.
4. Sentiment data: news, market sentiment
5. Prediction market: related prediction market events (new)
"""
return self.data_collector.collect_all(
market=market,
@@ -227,8 +227,8 @@ class FastAnalysisService:
timeframe=timeframe,
include_macro=include_macro,
include_news=include_news,
include_polymarket=include_polymarket, # 包含预测市场数据
timeout=timeout, # 增加超时时间,确保数据收集完成
include_polymarket=include_polymarket, # Contains prediction market data
timeout=timeout, # Increase timeout to ensure data collection is complete
)
def _calculate_indicators(self, kline_data: List[Dict]) -> Dict[str, Any]:
@@ -678,13 +678,13 @@ IMPORTANT:
return system_prompt, user_prompt
def _format_financial_statements(self, statements: Dict[str, Any]) -> str:
"""格式化财务报表数据用于提示词"""
"""Formatting financial statement data for prompt words"""
if not statements:
return "财务报表数据暂不可用"
lines = []
# 资产负债表
# balance sheet
if 'balance_sheet' in statements:
bs = statements['balance_sheet']
lines.append("资产负债表 (Balance Sheet):")
@@ -702,7 +702,7 @@ IMPORTANT:
current_ratio = bs['current_assets'] / bs['current_liabilities'] if bs['current_liabilities'] > 0 else 0
lines.append(f" - 流动比率: {current_ratio:.2f}")
# 利润表
# income statement
if 'income_statement' in statements:
is_stmt = statements['income_statement']
lines.append("利润表 (Income Statement):")
@@ -717,7 +717,7 @@ IMPORTANT:
if is_stmt.get('eps'):
lines.append(f" - 每股收益: ${is_stmt['eps']:.2f}")
# 现金流量表
# cash flow statement
if 'cash_flow' in statements:
cf = statements['cash_flow']
lines.append("现金流量表 (Cash Flow):")
@@ -729,13 +729,13 @@ IMPORTANT:
return "\n".join(lines) if lines else "财务报表数据暂不可用"
def _format_earnings_data(self, earnings: Dict[str, Any]) -> str:
"""格式化盈利数据用于提示词"""
"""Format profit data for prompt words"""
if not earnings:
return "盈利数据暂不可用"
lines = []
# 历史盈利
# historical profit
if 'history' in earnings and earnings['history']:
lines.append("历史盈利 (Earnings History):")
for i, hist in enumerate(earnings['history'][:4], 1):
@@ -753,7 +753,7 @@ IMPORTANT:
line += f", 超预期={surprise_str}"
lines.append(line)
# 未来盈利
# future profit
if 'upcoming' in earnings:
upcoming = earnings['upcoming']
if upcoming.get('next_earnings_date'):
@@ -763,7 +763,7 @@ IMPORTANT:
if upcoming.get('revenue_estimate'):
lines.append(f" - 收入预期: ${upcoming['revenue_estimate']:,.0f}")
# 季度盈利
# quarterly profit
if 'quarterly' in earnings:
q = earnings['quarterly']
if q.get('latest_quarter'):
@@ -776,25 +776,25 @@ IMPORTANT:
return "\n".join(lines) if lines else "盈利数据暂不可用"
def _format_macro_summary(self, macro: Dict[str, Any], market: str) -> str:
"""格式化宏观数据摘要"""
"""Format macro data summaries"""
if not macro:
return "宏观数据暂不可用"
lines = []
# 美元指数
# dollar index
if 'DXY' in macro:
dxy = macro['DXY']
direction = "" if dxy.get('change', 0) > 0 else ""
lines.append(f"- {dxy.get('name', 'USD Index')}: {dxy.get('price', 'N/A')} ({direction}{abs(dxy.get('changePercent', 0)):.2f}%)")
# 美元强弱对不同资产的影响
# The impact of the strength of the U.S. dollar on different assets
if market == 'Crypto':
impact = "利空加密货币" if dxy.get('change', 0) > 0 else "利好加密货币"
lines.append(f" ⚠️ 美元{direction} {impact}")
elif market == 'Forex':
lines.append(f" ⚠️ 美元{direction} 直接影响外汇走势")
# VIX恐慌指数
# VIX panic index
if 'VIX' in macro:
vix = macro['VIX']
vix_value = vix.get('price', 0)
@@ -808,7 +808,7 @@ IMPORTANT:
level = "低波动 (<15)"
lines.append(f"- {vix.get('name', 'VIX')}: {vix_value:.2f} - {level}")
# 美债收益率
# U.S. Treasury yields
if 'TNX' in macro:
tnx = macro['TNX']
direction = "" if tnx.get('change', 0) > 0 else ""
@@ -816,19 +816,19 @@ IMPORTANT:
if tnx.get('price', 0) > 4.5:
lines.append(" ⚠️ 高利率环境,对估值不利")
# 黄金
# gold
if 'GOLD' in macro:
gold = macro['GOLD']
direction = "" if gold.get('change', 0) > 0 else ""
lines.append(f"- {gold.get('name', 'Gold')}: ${gold.get('price', 'N/A'):.2f} ({direction}{abs(gold.get('changePercent', 0)):.2f}%)")
# 标普500
# S&P 500
if 'SPY' in macro:
spy = macro['SPY']
direction = "" if spy.get('change', 0) > 0 else ""
lines.append(f"- {spy.get('name', 'S&P 500')}: ${spy.get('price', 'N/A'):.2f} ({direction}{abs(spy.get('changePercent', 0)):.2f}%)")
# 比特币 (作为风险指标)
# Bitcoin (as a risk indicator)
if 'BTC' in macro and market != 'Crypto':
btc = macro['BTC']
direction = "" if btc.get('change', 0) > 0 else ""
@@ -876,8 +876,8 @@ IMPORTANT:
logger.info(f"Fast analysis starting: {market}:{symbol}")
# Consensus timeframes:
# - 默认:用用户传入的 timeframe 作为主周期,再加一个上层周期(1D/4H)提升稳定性
# - 也允许通过 env 覆盖(逗号分隔),例如 AI_ANALYSIS_CONSENSUS_TIMEFRAMES=1D,4H
# - Default: use the timeframe passed in by the user as the main cycle, and add an upper cycle (1D/4H) to improve stability
# - Overriding via env (comma separated) is also allowed, e.g. AI_ANALYSIS_CONSENSUS_TIMEFRAMES=1D,4H
env_tfs = os.getenv("AI_ANALYSIS_CONSENSUS_TIMEFRAMES", "").strip()
if env_tfs:
consensus_timeframes = [t.strip() for t in env_tfs.split(",") if t.strip()]
@@ -1067,16 +1067,16 @@ IMPORTANT:
# Validate we have essential data - with fallback to indicators
current_price = None
# 优先从 price 数据获取
# Get it from price data first
if data.get("price") and data["price"].get("price"):
current_price = data["price"]["price"]
# Fallback: indicators 获取 (如果 K 线成功计算了)
# Fallback: Get from indicators (if the K-line is calculated successfully)
if not current_price and data.get("indicators"):
current_price = data["indicators"].get("current_price")
if current_price:
logger.info(f"Using price from indicators: ${current_price}")
# 构建简化的 price 数据
# Build simplified price data
data["price"] = {
"price": current_price,
"change": 0,
@@ -1084,7 +1084,7 @@ IMPORTANT:
"source": "indicators_fallback"
}
# Fallback: 从 kline 最后一根获取
# Fallback: Get from the last kline
if not current_price and data.get("kline"):
klines = data["kline"]
if klines and len(klines) > 0:
@@ -1346,13 +1346,13 @@ IMPORTANT:
"take_profit": analysis.get("take_profit"),
"position_size_pct": analysis.get("position_size_pct", 10),
"timeframe": analysis.get("timeframe", "medium"),
# camelCase + 语义别名:供私有前端/旧版组件绑定(勿用 indicators.trading_levels 充当计划)
# camelCase + semantic alias: for private front-end/legacy component binding (do not use indicators.trading_levels as a plan)
"entryPrice": analysis.get("entry_price"),
"stopLoss": analysis.get("stop_loss"),
"takeProfit": analysis.get("take_profit"),
"positionSizePct": analysis.get("position_size_pct", 10),
"decision": str(analysis.get("decision", "HOLD") or "HOLD").upper(),
# stop_loss / take_profit 数值相同;命名强调「亏损离场 / 盈利目标」避免与多单参考线混淆
# The same value as stop_loss / take_profit; the naming emphasizes "loss exit / profit target" to avoid confusion with the long order reference line
"loss_exit_price": analysis.get("stop_loss"),
"profit_target_price": analysis.get("take_profit"),
},
@@ -1398,12 +1398,12 @@ IMPORTANT:
def _build_decision_guidance(self, rsi_value: float, macd_signal: str, ma_trend: str, change_24h: float) -> str:
"""
根据技术指标构建决策指导帮助AI做出更合理的决策
强调SELL信号是有效的做空机会
Build decision guidance based on technical indicators to help AI make more reasonable decisions.
Emphasize that the SELL signal is an effective short selling opportunity.
"""
guidance_parts = []
# RSI 指导 - 更积极地识别做空机会
# RSI Guidance - Identify shorting opportunities more aggressively
if rsi_value > 70:
guidance_parts.append("🔴 RSI > 70 (超买): 强烈建议SELL做空,避免BUY")
elif rsi_value > 60:
@@ -1415,7 +1415,7 @@ IMPORTANT:
else:
guidance_parts.append("⚪ RSI 40-60 (中性): 技术面中性,需要结合其他指标判断")
# MACD 指导 - 明确做空信号
# MACD Guidance - Clear Short Signal
if macd_signal == "bullish":
guidance_parts.append("🟢 MACD 看涨: 支持BUY做多")
elif macd_signal == "bearish":
@@ -1423,7 +1423,7 @@ IMPORTANT:
else:
guidance_parts.append("⚪ MACD 中性: 无明显方向")
# MA 趋势指导 - 识别趋势反转机会
# MA Trend Guidance - Identify Trend Reversal Opportunities
if "uptrend" in ma_trend.lower() or "strong_uptrend" in ma_trend.lower():
if rsi_value > 60:
guidance_parts.append("⚠️ 均线向上但RSI超买: 可能接近顶部,考虑SELL做空")
@@ -1434,13 +1434,13 @@ IMPORTANT:
else:
guidance_parts.append("⚪ 均线横盘: 趋势不明确")
# 24小时涨跌幅指导 - 识别过度波动
# 24-hour price range guidance - identifying excessive volatility
if change_24h > 5:
guidance_parts.append("🔴 24h涨幅 > 5%: 可能已过度上涨,建议SELL做空或获利了结")
elif change_24h < -5:
guidance_parts.append("🟢 24h跌幅 > 5%: 可能已过度下跌,可以考虑BUY做多")
# 综合建议
# Comprehensive suggestions
sell_signals = sum([
rsi_value > 60,
macd_signal == "bearish",
@@ -1465,14 +1465,14 @@ IMPORTANT:
def _has_major_news(self, news_data: List[Dict]) -> bool:
"""
检查是否有重大新闻事件
重大新闻包括监管变化重大合作丑闻重大政策地缘政治事件等
地缘类使用词边界与分级避免 toward/extension/us 等子串误判
Check for breaking news events.
Breaking news includes: regulatory changes, major collaborations, scandals, major policies, geopolitical events, etc.
The geographical category uses word boundaries and classification to avoid misjudgment of substrings such as toward/extension/us.
"""
if not news_data:
return False
# 子串关键词(较长词或中文,避免过短英文误匹配)
# Substring keywords (longer words or Chinese to avoid mismatching of too short English)
major_keywords = [
"regulation", "regulatory", "approval", "policy", "government", "central bank",
"监管", "禁令", "批准", "政策", "政府", "央行",
@@ -1481,7 +1481,7 @@ IMPORTANT:
"sanctions", "embargo", "制裁", "中东", "海湾", "北约",
"united states", "middle east",
]
# 短英文词用词边界匹配(不用裸子串)
# Use word boundary matching for short English words (without using naked substrings)
major_short_patterns = [
re.compile(r"\b(?:ban|banned|banning)\b", re.I),
re.compile(r"\b(?:crisis|crises)\b", re.I),
@@ -1510,31 +1510,31 @@ IMPORTANT:
def _has_macro_event(self, macro_data: Dict, market: str) -> bool:
"""
检查是否有重大宏观事件
重大宏观事件包括VIX异常高DXY大幅波动利率政策变化等
Check for major macro events.
Major macro events include: abnormally high VIX, large fluctuations in DXY, changes in interest rate policies, etc.
"""
if not macro_data:
return False
# 检查VIX(恐慌指数)
# Check the VIX (fear index)
if "VIX" in macro_data:
vix = macro_data["VIX"]
vix_value = vix.get("price", 0)
if vix_value > 30: # VIX > 30 表示极度恐慌
if vix_value > 30: # VIX > 30 indicates extreme panic
return True
# 检查DXY大幅波动(>1%
# Check for large DXY swings (>1%)
if "DXY" in macro_data:
dxy = macro_data["DXY"]
change_pct = abs(dxy.get("changePercent", 0))
if change_pct > 1.0: # 美元指数波动超过1%
if change_pct > 1.0: # The U.S. dollar index fluctuates more than 1%
return True
# 检查利率变化(对股票和加密货币影响大)
# Check for interest rate changes (big impact on stocks and cryptocurrencies)
if "TNX" in macro_data and market in ["USStock", "Crypto"]:
tnx = macro_data["TNX"]
change_pct = abs(tnx.get("changePercent", 0))
if change_pct > 2.0: # 利率变化超过2%
if change_pct > 2.0: # Interest rates change by more than 2%
return True
return False
@@ -1688,7 +1688,7 @@ IMPORTANT:
else:
analysis["decision"] = decision
# 基于技术指标验证决策合理性(允许宏观/新闻因素覆盖)
# Validate decision-making rationality based on technical indicators (allow macro/news factor coverage)
if indicators:
analysis = self._validate_decision_against_indicators(
analysis, indicators, confidence,
@@ -1704,14 +1704,14 @@ IMPORTANT:
def _validate_decision_against_indicators(self, analysis: Dict, indicators: Dict, confidence: int,
has_major_news: bool = False, has_macro_event: bool = False) -> Dict:
"""
根据技术指标验证决策的合理性但允许宏观/新闻因素覆盖技术指标
Justify decisions against technical indicators, but allow macro/news factors to override technical indicators.
Args:
analysis: AI分析结果
indicators: 技术指标数据
confidence: 置信度
has_major_news: 是否有重大新闻事件
has_macro_event: 是否有重大宏观事件
analysis: AI analysis results
indicators: technical indicator data
confidence: confidence
has_major_news: Is there a major news event?
has_macro_event: Is there a major macro event?
"""
decision = analysis.get("decision", "HOLD")
rsi_data = indicators.get("rsi", {})
@@ -1722,70 +1722,70 @@ IMPORTANT:
macd_signal = macd_data.get("signal", "neutral")
ma_trend = ma_data.get("trend", "sideways")
# 如果置信度太低,强制改为HOLD
# If the confidence level is too low, force it to HOLD
if confidence < 60:
if decision != "HOLD":
logger.warning(f"Decision {decision} with low confidence {confidence}, forcing to HOLD")
analysis["decision"] = "HOLD"
analysis["confidence"] = max(confidence, 45) # 降低置信度
analysis["confidence"] = max(confidence, 45) # Reduce confidence
return analysis
# 如果有重大新闻或宏观事件,允许覆盖技术指标(但记录警告)
# Allows technical indicators to be overridden (but logs warnings) if there is major news or macro events
allow_override = has_major_news or has_macro_event
# 检查BUY决策是否与技术指标矛盾
# Check whether the BUY decision conflicts with technical indicators
if decision == "BUY":
conflicts = []
# RSI > 70 时不应该BUY(除非有重大利好)
# You should not buy when RSI > 70 (unless there is a major upside)
if rsi_value > 70:
conflicts.append(f"RSI {rsi_value:.1f} > 70 (超买)")
# MACD看跌时不应该BUY(除非有重大利好)
# You should not BUY when MACD is bearish (unless there is a major upside)
if macd_signal == "bearish":
conflicts.append("MACD bearish")
# 均线趋势向下时不应该BUY(除非有重大利好)
# 只有当趋势非常强烈时才认为是冲突(避免过于敏感)
# You should not buy when the moving average trend is downward (unless there is a major benefit)
# Only consider a conflict if the trend is very strong (avoid being too sensitive)
if "strong_downtrend" in ma_trend.lower() or ("downtrend" in ma_trend.lower() and rsi_value > 50):
conflicts.append(f"MA trend: {ma_trend}")
if conflicts:
if allow_override:
# 允许覆盖,但降低置信度并添加说明
# Allow override, but lower confidence and add description
logger.info(f"BUY decision conflicts with indicators but major news/macro event allows override: {', '.join(conflicts)}")
analysis["confidence"] = max(confidence - 15, 50)
original_summary = analysis.get("summary", "")
analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]"
else:
# 没有重大事件,强制改为HOLD
# If there is no major event, it is forced to be changed to HOLD.
logger.warning(f"BUY decision conflicts with indicators and no major event: {', '.join(conflicts)}. Forcing to HOLD")
analysis["decision"] = "HOLD"
analysis["confidence"] = max(confidence - 20, 40)
original_summary = analysis.get("summary", "")
analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},建议观望]"
# 检查SELL决策是否与技术指标矛盾(放宽限制,因为SELL是有效的做空机会)
# Check whether SELL decisions contradict technical indicators (relax restrictions because SELL is a valid short opportunity)
elif decision == "SELL":
conflicts = []
# 只有在强烈看涨信号时才阻止SELL(放宽条件)
# RSI < 30 MACD看涨 且 均线向上时,才认为矛盾
# Only block SELL (relax conditions) if there is a strong bullish signal
# It is considered a contradiction when RSI < 30 and MACD is bullish and the moving average is upward.
if rsi_value < 30 and macd_signal == "bullish" and "uptrend" in ma_trend.lower():
conflicts.append(f"Strong bullish signals (RSI {rsi_value:.1f} < 30, MACD bullish, uptrend)")
# 或者 RSI < 30 且 均线强烈向上
# Or RSI < 30 and the moving average is strongly upward
elif rsi_value < 30 and "strong_uptrend" in ma_trend.lower():
conflicts.append(f"Very strong uptrend with oversold RSI {rsi_value:.1f}")
if conflicts:
if allow_override:
# 允许覆盖,但降低置信度并添加说明
# Allow override, but lower confidence and add description
logger.info(f"SELL decision conflicts with strong bullish indicators but major news/macro event allows override: {', '.join(conflicts)}")
analysis["confidence"] = max(confidence - 15, 50)
original_summary = analysis.get("summary", "")
analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]"
else:
# 只有在非常强烈的看涨信号时才改为HOLD
# Only change to HOLD if there is a very strong bullish signal
logger.warning(f"SELL decision conflicts with very strong bullish indicators: {', '.join(conflicts)}. Forcing to HOLD")
analysis["decision"] = "HOLD"
analysis["confidence"] = max(confidence - 20, 40)
@@ -1796,16 +1796,16 @@ IMPORTANT:
def _calculate_objective_score(self, data: Dict[str, Any], current_price: float) -> Dict[str, float]:
"""
基于客观数据计算量化评分系统
Calculates a quantitative scoring system based on objective data
返回一个-100+100的分数
- +100: 强烈利多强烈BUY
- +70+100: 强烈利多强烈BUY
- +40+70: 利多BUY
- -40+40: 中性HOLD
- -70-40: 利空SELL
- -100-70: 强烈利空强烈SELL
- -100: 强烈利空强烈SELL
Return a score between -100 and +100:
- +100: Strong bullish (strong BUY)
- +70 to +100: Strong bullish (strong BUY)
- +40 to +70: BUY
- -40 to +40: Neutral (HOLD)
- -70 to -40: SELL
- -100 to -70: Strongly bearish (strongly SELL)
- -100: Strongly bearish (strongly SELL)
"""
indicators = data.get("indicators") or {}
fundamental = data.get("fundamental") or {}
@@ -1813,27 +1813,27 @@ IMPORTANT:
macro = data.get("macro") or {}
price_data = data.get("price") or {}
# 1. 技术指标评分 (-100 to +100)
# 1. Technical indicator score (-100 to +100)
technical_score = self._calculate_technical_score(indicators, price_data)
# 2. 基本面评分 (-100 to +100)
# 2. Fundamental score (-100 to +100)
fundamental_score = self._calculate_fundamental_score(fundamental, data.get("market", ""))
# 3. 新闻情绪评分 (-100 to +100)
# 3. News sentiment score (-100 to +100)
sentiment_score = self._calculate_sentiment_score(news)
# 4. 宏观环境评分 (-100 to +100)
# 4. Macro environment score (-100 to +100)
macro_score = self._calculate_macro_score(macro, data.get("market", ""))
# 5. 综合评分(加权平均)
# 优化权重:默认技术35%,基本面20%,情绪25%(包含地缘政治),宏观20%(提高宏观权重)
# 但要做“可用信息重加权”:当某些模块缺失(如新闻/宏观没取到),不要用0分去稀释整体强度,
# 而是重新归一化权重,让技术信号在缺失时仍可发挥主导作用。
# 5. Comprehensive rating (weighted average)
# Optimization weight: Default technical 35%, fundamentals 20%, sentiment 25% (including geopolitics), macro 20% (increase macro weight)
# But we need to "reweight the available information": when some modules are missing (such as news/macro is not obtained), do not use 0 points to dilute the overall strength.
# Instead, the weights are renormalized so that technical signals can still play a leading role in their absence.
market_type = str(data.get("market") or "")
fundamental_present = (market_type == "USStock") and bool(fundamental)
sentiment_present = bool(news)
macro_present = bool(macro)
# indicators 一旦成功计算通常就存在,但这里也做一次保护
# indicators usually exist once they are successfully calculated, but they are also protected here.
technical_present = bool(indicators)
weights = {
@@ -1897,28 +1897,28 @@ IMPORTANT:
return cfg
def _calculate_technical_score(self, indicators: Dict, price_data: Dict) -> float:
"""计算技术指标评分 (-100 to +100)"""
"""Calculate technical indicator score (-100 to +100)"""
score = 0.0
weight_sum = 0.0
# RSI 评分 (-50 to +50)
# RSI score (-50 to +50)
rsi_data = indicators.get("rsi", {})
rsi_value = rsi_data.get("value", 50)
if rsi_value > 0:
if rsi_value > 70:
rsi_score = -50 # 超买,强烈利空
rsi_score = -50 # Overbought, strongly bearish
elif rsi_value > 60:
rsi_score = -30 # 偏超买,利空
rsi_score = -30 # Overbought, negative
elif rsi_value < 30:
rsi_score = +50 # 超卖,强烈利多
rsi_score = +50 # Oversold, strongly bullish
elif rsi_value < 40:
rsi_score = +30 # 偏超卖,利多
rsi_score = +30 # Oversold, bullish
else:
rsi_score = (50 - rsi_value) * 0.6 # 40-60之间,线性映射
rsi_score = (50 - rsi_value) * 0.6 # Between 40-60, linear mapping
score += rsi_score * 0.30
weight_sum += 0.30
# MACD 评分 (-40 to +40)
# MACD score (-40 to +40)
macd_data = indicators.get("macd", {})
macd_signal = macd_data.get("signal", "neutral")
if macd_signal == "bullish":
@@ -1930,7 +1930,7 @@ IMPORTANT:
score += macd_score * 0.25
weight_sum += 0.25
# 均线趋势评分 (-40 to +40)
# Moving average trend score (-40 to +40)
ma_data = indicators.get("moving_averages", {})
ma_trend = ma_data.get("trend", "sideways")
if "strong_uptrend" in ma_trend.lower():
@@ -1946,36 +1946,36 @@ IMPORTANT:
score += ma_score * 0.25
weight_sum += 0.25
# 24小时涨跌幅评分 (-20 to +20)
# 24-hour rise and fall score (-20 to +20)
change_24h = price_data.get("changePercent", 0)
if change_24h > 10:
change_score = -20 # 过度上涨,利空
change_score = -20 # Excessive rise is bad
elif change_24h > 5:
change_score = -10
elif change_24h < -10:
change_score = +20 # 过度下跌,利多
change_score = +20 # Excessive decline, bullish
elif change_24h < -5:
change_score = +10
else:
change_score = change_24h * 2 # 线性映射
change_score = change_24h * 2 # linear mapping
score += change_score * 0.20
weight_sum += 0.20
# ========== 额外技术特征(轻量增强,不改变主体结构) ==========
# 这些特征来自 MarketDataCollector._calculate_indicators 的输出:
# - price_position: 过去20根K线区间位置 0~100
# - volume_ratio: 最新成交量 / 20期均量
# ========== Additional technical features (lightweight enhancement, no change to the main structure) ==========
# These characteristics come from the output of MarketDataCollector._calculate_indicators:
# - price_position: range position of the past 20 K-lines 0~100
# - volume_ratio: latest trading volume / average volume of 20 periods
# - bollinger: BB_upper/BB_lower/BB_width
# - volatility: atr, pct
extra_score = 0.0
extra_weight = 0.0
# 1) 区间位置:接近区间顶部更偏利空,接近区间底部更偏利多
# 1) Range position: Close to the top of the range is more bearish, and close to the bottom of the range is more bullish.
try:
pp = float(indicators.get("price_position", 50.0))
# 0~100 -> -15~+15 (线性映射,中心50为0)
# 0~100 -> -15~+15 (linear mapping, center 50 is 0)
pp_score = (50.0 - pp) * 0.3
# 在极端区域增强信号
# Boost signal in extreme areas
if pp >= 85:
pp_score -= 5
elif pp <= 15:
@@ -1985,7 +1985,7 @@ IMPORTANT:
except Exception:
pass
# 2) 布林带触及:突破上轨偏利空,跌破下轨偏利多
# 2) The Bollinger Bands are touched: a breakthrough of the upper band is negative, and a fall below the lower band is positive.
try:
cur_px = float(indicators.get("current_price") or price_data.get("price") or 0.0)
bb = indicators.get("bollinger") or {}
@@ -2006,7 +2006,7 @@ IMPORTANT:
except Exception:
pass
# 3) 成交量放大:在趋势方向上加分,逆趋势减分(弱信号)
# 3) Trading volume amplification: plus points in the direction of the trend and minus points against the trend (weak signal)
try:
vr = float(indicators.get("volume_ratio") or 1.0)
trend = str(indicators.get("trend") or indicators.get("moving_averages", {}).get("trend") or "").lower()
@@ -2018,22 +2018,22 @@ IMPORTANT:
extra_score += -8
extra_weight += 0.15
else:
# 放量但无趋势:更偏不确定,略微降低(当作偏利空风险)
# Large volume but no trend: more uncertain, slightly lower (considered to be a bearish risk)
extra_score += -3
extra_weight += 0.10
elif vr <= 0.6:
# 缩量:趋势信号可信度下降(轻微回归到0)
# Shrinkage: The credibility of the trend signal decreases (slight return to 0)
extra_score += 0
extra_weight += 0.05
except Exception:
pass
# 4) 高波动:减少强方向自信(用“缩放”形式实现,避免硬反转)
# 4) High volatility: reduce strong directional confidence (implemented in the form of "scaling" to avoid hard reversals)
try:
vol = indicators.get("volatility") or {}
vol_pct = float(vol.get("pct") or 0.0)
if vol_pct >= 6.0:
# 极高波动:把额外分数打折,并轻微把总体拉回0
# Extremely High Volatility: Discounts extra points and slightly brings the total back to 0
extra_score *= 0.6
score *= 0.92
elif vol_pct >= 3.5:
@@ -2049,29 +2049,29 @@ IMPORTANT:
score += extra_norm * 0.15
weight_sum += 0.15
# 归一化到-100到+100
# Normalized to -100 to +100
if weight_sum > 0:
score = score / weight_sum * 100
return max(-100, min(100, score))
def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float:
"""计算基本面评分 (-100 to +100)"""
"""Calculate fundamental score (-100 to +100)"""
if market != "USStock" or not fundamental:
return 0.0 # 非美股或无基本面数据,返回中性
return 0.0 # Non-U.S. stocks or no fundamental data, return neutral
score = 0.0
factors = 0
# PE Ratio 评分
# PE Ratio score
pe_ratio = fundamental.get("pe_ratio")
if pe_ratio and pe_ratio > 0:
if pe_ratio < 15:
pe_score = +20 # 低PE,利多
pe_score = +20 # Low PE, bullish
elif pe_ratio < 25:
pe_score = +10
elif pe_ratio > 50:
pe_score = -20 # 高PE,利空
pe_score = -20 # High PE, negative
elif pe_ratio > 35:
pe_score = -10
else:
@@ -2079,15 +2079,15 @@ IMPORTANT:
score += pe_score
factors += 1
# ROE 评分
# ROE score
roe = fundamental.get("roe")
if roe:
if roe > 20:
roe_score = +20 # 高ROE,利多
roe_score = +20 # High ROE, Rita
elif roe > 15:
roe_score = +10
elif roe < 5:
roe_score = -20 # 低ROE,利空
roe_score = -20 # Low ROE, bad news
elif roe < 10:
roe_score = -10
else:
@@ -2095,15 +2095,15 @@ IMPORTANT:
score += roe_score
factors += 1
# 营收增长评分
# revenue growth score
revenue_growth = fundamental.get("revenue_growth")
if revenue_growth:
if revenue_growth > 20:
growth_score = +20 # 高增长,利多
growth_score = +20 # High growth, good news
elif revenue_growth > 10:
growth_score = +10
elif revenue_growth < -10:
growth_score = -20 # 负增长,利空
growth_score = -20 # negative growth, bad
elif revenue_growth < 0:
growth_score = -10
else:
@@ -2111,15 +2111,15 @@ IMPORTANT:
score += growth_score
factors += 1
# 利润率评分
# Profitability score
profit_margin = fundamental.get("profit_margin")
if profit_margin:
if profit_margin > 20:
margin_score = +15 # 高利润率,利多
margin_score = +15 # High profit margin, profit
elif profit_margin > 10:
margin_score = +7
elif profit_margin < 0:
margin_score = -15 # 亏损,利空
margin_score = -15 # loss, bad
elif profit_margin < 5:
margin_score = -7
else:
@@ -2127,31 +2127,31 @@ IMPORTANT:
score += margin_score
factors += 1
# 债务权益比评分
# Debt to Equity Ratio Score
debt_to_equity = fundamental.get("debt_to_equity")
if debt_to_equity:
if debt_to_equity < 0.5:
debt_score = +10 # 低负债,利多
debt_score = +10 # Low debt, good profits
elif debt_to_equity > 2.0:
debt_score = -10 # 高负债,利空
debt_score = -10 # High debt, bad
else:
debt_score = 0
score += debt_score
factors += 1
# 归一化(如果有多个因素)
# Normalization (if there are multiple factors)
if factors > 0:
score = score / factors * 100 / 4 # 最大可能分数是4个因素各20分=80,归一化到100
score = score / factors * 100 / 4 # The maximum possible score is 20 points for each of the 4 factors = 80, normalized to 100
return max(-100, min(100, score))
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
"""
计算新闻情绪评分 (-100 to +100)
地缘/冲突类词边界 + 分级惩罚单条封顶避免 extension/toward 等误判叠加
Calculate news sentiment score (-100 to +100)
Geographical/conflict category: word boundary + hierarchical punishment, single capping, to avoid superposition of misjudgments such as extension/toward.
"""
if not news:
return 0.0 # 无新闻,中性
return 0.0 # No news, neutral
positive_count = 0
negative_count = 0
@@ -2210,55 +2210,55 @@ IMPORTANT:
def _calculate_macro_score(self, macro: Dict, market: str) -> float:
"""
计算宏观环境评分 (-100 to +100)
包含VIXDXY利率等宏观经济指标
Calculate macro environment score (-100 to +100)
Contains macroeconomic indicators such as VIX, DXY, interest rates, etc.
"""
if not macro:
return 0.0 # 无宏观数据,中性
return 0.0 # No macro data, neutral
score = 0.0
factors = 0
# VIX 评分(恐慌指数)- 权重提高
# VIX score (fear index) - increased weight
vix = macro.get("VIX", {})
vix_value = vix.get("price", 0)
if vix_value > 0:
if vix_value > 35:
vix_score = -50 # 极高恐慌(如战争期间),严重利空
vix_score = -50 # Extremely high panic (such as during a war), severely negative
elif vix_value > 30:
vix_score = -40 # 高恐慌,严重利空
vix_score = -40 # High panic, serious negative news
elif vix_value > 25:
vix_score = -30 # 较高恐慌,利空
vix_score = -30 # Higher panic, bad news
elif vix_value > 20:
vix_score = -15 # 中等恐慌,轻微利空
vix_score = -15 # Moderate panic, slightly negative
elif vix_value < 12:
vix_score = +20 # 低恐慌,利多
vix_score = +20 # Low panic, bullish
elif vix_value < 15:
vix_score = +10 # 较低恐慌,轻微利多
vix_score = +10 # Lower panic, slightly bullish
else:
vix_score = 0
score += vix_score
factors += 1
# DXY 评分(美元指数)- 权重提高
# DXY Score (USD Index) - Increased weighting
dxy = macro.get("DXY", {})
dxy_value = dxy.get("price", 0)
dxy_change = dxy.get("changePercent", 0)
if dxy_value > 0:
# 对于加密货币和商品,强美元通常是利空
# For Cryptocurrencies and Commodities, A Strong USD Is Typically Bearish
if market in ["Crypto", "Forex", "Futures"]:
if dxy_change > 2:
dxy_score = -30 # 美元大幅走强,严重利空
dxy_score = -30 # The sharp strengthening of the US dollar is seriously negative
elif dxy_change > 1:
dxy_score = -20 # 美元走强,利空
dxy_score = -20 # A stronger U.S. dollar is a negative
elif dxy_change < -2:
dxy_score = +30 # 美元大幅走弱,利多
dxy_score = +30 # The U.S. dollar weakens sharply, which is bullish
elif dxy_change < -1:
dxy_score = +20 # 美元走弱,利多
dxy_score = +20 # A weaker dollar is bullish
else:
dxy_score = 0
else:
# 对股票也有影响,但较小
# It also has an impact on stocks, but its smaller
if dxy_change > 2:
dxy_score = -10
elif dxy_change < -2:
@@ -2268,21 +2268,21 @@ IMPORTANT:
score += dxy_score
factors += 1
# 利率评分(TNX- 权重提高
# Interest Rate Score (TNX) - Increased weighting
tnx = macro.get("TNX", {})
tnx_change = tnx.get("changePercent", 0)
tnx_value = tnx.get("price", 0)
if tnx_change != 0 or tnx_value > 0:
# 利率上升对成长股和加密货币通常是利空
# Rising interest rates are generally negative for growth stocks and cryptocurrencies
if market in ["Crypto", "USStock"]:
if tnx_change > 3:
tnx_score = -30 # 利率大幅上升,严重利空
tnx_score = -30 # Interest rates rise sharply, which is seriously negative
elif tnx_change > 2:
tnx_score = -20 # 利率上升,利空
tnx_score = -20 # Rising interest rates are bad
elif tnx_change < -3:
tnx_score = +30 # 利率大幅下降,利多
tnx_score = +30 # A sharp drop in interest rates is bullish
elif tnx_change < -2:
tnx_score = +20 # 利率下降,利多
tnx_score = +20 # Falling interest rates are bullish
else:
tnx_score = 0
else:
@@ -2290,7 +2290,7 @@ IMPORTANT:
score += tnx_score
factors += 1
# 恐惧贪婪指数(更适合 Crypto):极端贪婪偏利空,极端恐惧偏利多(弱信号)
# Fear and greed index (more suitable for Crypto): extreme greed is negative, extreme fear is bullish (weak signal)
try:
fg = macro.get("FEAR_GREED", {}) or {}
fg_value = float(fg.get("price") or 0.0)
@@ -2310,12 +2310,12 @@ IMPORTANT:
except Exception:
pass
# 归一化(考虑权重)
# Normalization (considering weights)
if factors > 0:
# 最大可能分数:VIX(-50~+20), DXY(-30~+30), TNX(-30~+30) = 约-110到+80
# 归一化到-100到+100
# 加上 Fear&Greed 的幅度(约 15),给点 buffer
max_possible = 125 # 最大绝对值
# Maximum possible score: VIX(-50~+20), DXY(-30~+30), TNX(-30~+30) = about -110 to +80
# Normalized to -100 to +100
# Add the amplitude of Fear&Greed (about 15) and give some buffer
max_possible = 125 # maximum absolute value
score = score / max_possible * 100
return max(-100, min(100, score))
@@ -2330,23 +2330,23 @@ IMPORTANT:
def _score_to_decision(self, score: float, *, market: str = "Crypto") -> str:
"""
根据客观评分转换为决策
Transformed into decisions based on objective scoring
优化后的阈值大幅缩小HOLD区间使决策更明确
- score >= +20: BUY利多
- score <= -20: SELL利空
- -20 < score < +20: HOLD中性
Optimized threshold (significantly narrows the HOLD interval to make decisions clearer):
- score >= +20: BUY (profit)
- score <= -20: SELL (bad)
- -20 < score < +20: HOLD (neutral)
分级决策用于更细粒度的判断
- score >= +70: 强烈BUY
- +40 <= score < +70: 明显BUY
Hierarchical decision-making (for finer-grained judgment):
- score >= +70: strong BUY
- +40 <= score < +70: obvious BUY
- +20 <= score < +40: BUY
- +10 < score < +20: 弱利多倾向于BUY但可HOLD
- -10 <= score <= +10: 中性HOLD真正的中性区间
- -20 < score < -10: 弱利空倾向于SELL但可HOLD
- +10 < score < +20: Weak profit and long (tend to BUY, but can HOLD)
- -10 <= score <= +10: Neutral HOLD (true neutral interval)
- -20 < score < -10: Weakly bearish (inclined to SELL, but can be HOLD)
- -40 < score <= -20: SELL
- -70 < score <= -40: 明显SELL
- score <= -70: 强烈SELL
- -70 < score <= -40: obviously SELL
- score <= -70: Strong SELL
"""
cfg = self._get_ai_calibration(market=market)
buy_thr = float(cfg.get("buy_threshold") or 20.0)
@@ -2361,14 +2361,14 @@ IMPORTANT:
def _calculate_overall_score(self, analysis: Dict) -> int:
"""Calculate weighted overall score (legacy method, now uses objective score if available)."""
# 优先使用客观评分
# Prioritize objective scoring
if "objective_score" in analysis:
objective = analysis["objective_score"]
overall = objective.get("overall_score", 50)
# 转换为0-100格式(原系统使用)
# Convert to 0-100 format (used by the original system)
return max(0, min(100, int(50 + overall * 0.5)))
# 降级到LLM评分
# Downgraded to LLM rating
tech = analysis.get("technical_score", 50)
fund = analysis.get("fundamental_score", 50)
sent = analysis.get("sentiment_score", 50)
@@ -1,17 +1,17 @@
"""
Indicator Parameters Parser and Helper Functions
支持两个核心功能
1. 指标参数外部传递 - 解析指标代码中的 @param 声明
2. 指标调用其他指标 - 提供 call_indicator() 函数
Supports two core functions:
1. External transfer of indicator parameters - parse the @param statement in the indicator code
2. Indicators call other indicators - provide call_indicator() function
参数声明格式
# @param param_name type default_value 描述
# @param ma_fast int 5 短期均线周期
# @param ma_slow int 20 长期均线周期
# @param threshold float 0.5 阈值
Parameter declaration format:
# @param param_name type default_value description
# @param ma_fast int 5 short-term moving average period
# @param ma_slow int 20 long-term moving average period
# @param threshold float 0.5 threshold
支持的类型int, float, bool, str
Supported types: int, float, bool, str
"""
import re
@@ -24,9 +24,9 @@ logger = get_logger(__name__)
class IndicatorParamsParser:
"""解析指标代码中的参数声明"""
"""Parsing parameter declarations in indicator code"""
# 参数声明正则:# @param name type default description
# Parameter declaration rules: # @param name type default description
PARAM_PATTERN = re.compile(
r'#\s*@param\s+(\w+)\s+(int|float|bool|str|string)\s+(\S+)\s*(.*)',
re.IGNORECASE
@@ -35,7 +35,7 @@ class IndicatorParamsParser:
@classmethod
def parse_params(cls, indicator_code: str) -> List[Dict[str, Any]]:
"""
解析指标代码中的参数声明
Parsing parameter declarations in indicator code
Returns:
List of param definitions:
@@ -44,7 +44,7 @@ class IndicatorParamsParser:
"name": "ma_fast",
"type": "int",
"default": 5,
"description": "短期均线周期"
"description": "Short-term moving average cycle"
},
...
]
@@ -62,10 +62,10 @@ class IndicatorParamsParser:
default_str = match.group(3)
description = match.group(4).strip() if match.group(4) else ''
# 转换默认值类型
# Convert default value type
default = cls._convert_value(default_str, param_type)
# 规范化类型名
# Canonical type name
if param_type == 'string':
param_type = 'str'
@@ -80,7 +80,7 @@ class IndicatorParamsParser:
@classmethod
def _convert_value(cls, value_str: str, param_type: str) -> Any:
"""转换字符串值为对应类型"""
"""Convert string value to corresponding type"""
try:
param_type = param_type.lower()
if param_type == 'int':
@@ -97,14 +97,14 @@ class IndicatorParamsParser:
@classmethod
def merge_params(cls, declared_params: List[Dict], user_params: Dict[str, Any]) -> Dict[str, Any]:
"""
合并声明的参数和用户提供的参数
Merge declared parameters with user-supplied parameters
Args:
declared_params: 从代码中解析的参数声明
user_params: 用户提供的参数值
declared_params: parameter declarations parsed from code
user_params: user-provided parameter values
Returns:
合并后的参数字典使用用户值或默认值
Merged parameter dictionary (using user values or default values)
"""
result = {}
for param in declared_params:
@@ -113,10 +113,10 @@ class IndicatorParamsParser:
default = param['default']
if name in user_params:
# 用户提供了值,转换为正确类型
# User supplied value, converted to correct type
result[name] = cls._convert_value(str(user_params[name]), param_type)
else:
# 使用默认值
# Use default value
result[name] = default
return result
@@ -124,58 +124,58 @@ class IndicatorParamsParser:
class IndicatorCaller:
"""
指标调用器 - 允许一个指标调用另一个指标
Indicator caller - allows one indicator to call another indicator
使用方式在指标代码中
# 按ID调用
Usage (in indicator code):
# Call by ID
rsi_df = call_indicator(5, df)
# 按名称调用(自己的指标)
# Call by name (own indicator)
macd_df = call_indicator('My MACD', df)
"""
# 最大调用深度,防止循环依赖
# Maximum call depth to prevent circular dependencies
MAX_CALL_DEPTH = 5
def __init__(self, user_id: int, current_indicator_id: int = None):
self.user_id = user_id
self.current_indicator_id = current_indicator_id
self._call_stack = [] # 调用栈,用于检测循环依赖
self._call_stack = [] # Call stack for detecting circular dependencies
def call_indicator(
self,
indicator_ref: Any, # int (ID) str (名称)
indicator_ref: Any, # int (ID) or str (name)
df: 'pd.DataFrame',
params: Dict[str, Any] = None,
_depth: int = 0
) -> Optional['pd.DataFrame']:
"""
调用另一个指标并返回结果
Call another indicator and return the result
Args:
indicator_ref: 指标ID或名称
df: 输入的K线数据
params: 传递给被调用指标的参数
_depth: 内部使用跟踪调用深度
indicator_ref: indicator ID or name
df: input K-line data
params: parameters passed to the called indicator
_depth: used internally to track call depth
Returns:
执行后的DataFrame包含被调用指标计算的列
DataFrame after execution, containing columns calculated by the called indicator
"""
import pandas as pd
import numpy as np
# 检查调用深度
# Check call depth
if _depth >= self.MAX_CALL_DEPTH:
logger.error(f"Indicator call depth exceeded {self.MAX_CALL_DEPTH}")
return df.copy()
# 获取指标代码
# Get indicator code
indicator_code, indicator_id = self._get_indicator_code(indicator_ref)
if not indicator_code:
logger.warning(f"Indicator not found: {indicator_ref}")
return df.copy()
# 检查循环依赖
# Check for circular dependencies
if indicator_id in self._call_stack:
logger.error(f"Circular dependency detected: {self._call_stack} -> {indicator_id}")
return df.copy()
@@ -183,11 +183,11 @@ class IndicatorCaller:
self._call_stack.append(indicator_id)
try:
# 解析并合并参数
# Parse and merge parameters
declared_params = IndicatorParamsParser.parse_params(indicator_code)
merged_params = IndicatorParamsParser.merge_params(declared_params, params or {})
# 准备执行环境
# Prepare execution environment
df_copy = df.copy()
local_vars = {
'df': df_copy,
@@ -200,11 +200,11 @@ class IndicatorCaller:
'np': np,
'pd': pd,
'params': merged_params,
# 递归调用支持
# Recursive call support
'call_indicator': lambda ref, d, p=None: self.call_indicator(ref, d, p, _depth + 1)
}
# 安全执行
# Safe execution
import builtins
def safe_import(name, *args, **kwargs):
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'time']
@@ -236,19 +236,19 @@ class IndicatorCaller:
self._call_stack.pop()
def _get_indicator_code(self, indicator_ref: Any) -> Tuple[Optional[str], Optional[int]]:
"""获取指标代码"""
"""Get indicator code"""
try:
with get_db_connection() as db:
cursor = db.cursor()
if isinstance(indicator_ref, int):
# 按ID查询
# Query by ID
cursor.execute("""
SELECT id, code FROM qd_indicator_codes
WHERE id = %s AND (user_id = %s OR publish_to_community = 1)
""", (indicator_ref, self.user_id))
else:
# 按名称查询(优先自己的指标)
# Query by name (priority to own indicators)
cursor.execute("""
SELECT id, code FROM qd_indicator_codes
WHERE name = %s AND user_id = %s
+36 -36
View File
@@ -1,5 +1,5 @@
"""
K线数据服务
K-line data service
"""
from typing import Dict, List, Any, Optional
@@ -12,7 +12,7 @@ logger = get_logger(__name__)
class KlineService:
"""K线数据服务"""
"""K-line data service"""
def __init__(self):
self.cache = CacheManager()
@@ -27,27 +27,27 @@ class KlineService:
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
获取K线数据
Get K-line data
Args:
market: 市场类型 (Crypto, USStock, Forex, Futures)
symbol: 交易对/股票代码
timeframe: 时间周期
limit: 数据条数
before_time: 获取此时间之前的数据
market: market type (Crypto, USStock, Forex, Futures)
symbol: trading pair/stock code
timeframe: time period
limit: number of data items
before_time: Get data before this time
Returns:
K线数据列表
K-line data list
"""
# 构建缓存键(历史数据不缓存)
# Build a cache key (historical data is not cached)
if not before_time:
cache_key = f"kline:{market}:{symbol}:{timeframe}:{limit}"
cached = self.cache.get(cache_key)
if cached:
# logger.info(f"命中缓存: {cache_key}")
# logger.info(f"Hit cache: {cache_key}")
return cached
# 获取数据
# Get data
klines = DataSourceFactory.get_kline(
market=market,
symbol=symbol,
@@ -56,16 +56,16 @@ class KlineService:
before_time=before_time
)
# 设置缓存(仅最新数据)
# Set cache (latest data only)
if klines and not before_time:
ttl = self.cache_ttl.get(timeframe, 300)
self.cache.set(cache_key, klines, ttl)
# logger.info(f"缓存设置: {cache_key}, TTL: {ttl}s")
# logger.info(f"Cache settings: {cache_key}, TTL: {ttl}s")
return klines
def get_latest_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""获取最新价格(使用1分钟K线,已弃用,建议使用 get_realtime_price"""
"""Get the latest price (use 1-minute K-line, deprecated, it is recommended to use get_realtime_price)"""
klines = self.get_kline(market, symbol, '1m', 1)
if klines:
return klines[-1]
@@ -73,29 +73,29 @@ class KlineService:
def get_realtime_price(self, market: str, symbol: str, force_refresh: bool = False) -> Dict[str, Any]:
"""
获取实时价格优先使用 ticker API降级使用分钟 K 线
Get real-time prices (priority to use ticker API, downgrade to minute K-line)
Args:
market: 市场类型 (Crypto, USStock, Forex, Futures)
symbol: 交易对/股票代码
force_refresh: 是否强制刷新跳过缓存
market: market type (Crypto, USStock, Forex, Futures)
symbol: trading pair/stock code
force_refresh: whether to force refresh (skip cache)
Returns:
实时价格数据: {
'price': 最新价格,
'change': 涨跌额,
'changePercent': 涨跌幅,
'high': 最高价,
'low': 最低价,
'open': 开盘价,
'previousClose': 昨收价,
'source': 数据来源 ('ticker' 'kline')
Real-time price data: {
'price': latest price,
'change': change amount,
'changePercent': increase or decrease,
'high': highest price,
'low': lowest price,
'open': opening price,
'previousClose': yesterday's closing price,
'source': data source ('ticker' or 'kline')
}
"""
# 构建缓存键(短时间缓存,避免频繁请求)
# Build a cache key (short-term cache to avoid frequent requests)
cache_key = f"realtime_price:{market}:{symbol}"
# 如果不是强制刷新,尝试使用缓存
# If it is not a forced refresh, try using caching
if not force_refresh:
cached = self.cache.get(cache_key)
if cached:
@@ -112,7 +112,7 @@ class KlineService:
'source': 'unknown'
}
# 优先尝试使用 ticker API 获取实时价格
# First try to use the ticker API to get real-time prices
try:
ticker = DataSourceFactory.get_ticker(market, symbol)
if ticker and ticker.get('last', 0) > 0:
@@ -126,13 +126,13 @@ class KlineService:
'previousClose': ticker.get('previousClose', 0),
'source': 'ticker'
}
# 缓存 30 秒
# Cache for 30 seconds
self.cache.set(cache_key, result, 30)
return result
except Exception as e:
logger.debug(f"Ticker API failed for {market}:{symbol}, falling back to kline: {e}")
# 降级:使用 1 分钟 K 线
# Downgrade: Use 1 minute candlestick
try:
klines = self.get_kline(market, symbol, '1m', 2)
if klines and len(klines) > 0:
@@ -153,13 +153,13 @@ class KlineService:
'previousClose': prev_close,
'source': 'kline_1m'
}
# 缓存 30 秒
# Cache for 30 seconds
self.cache.set(cache_key, result, 30)
return result
except Exception as e:
logger.debug(f"1m kline failed for {market}:{symbol}, trying daily: {e}")
# 最后降级:使用日线数据(适用于非交易时间)
# Last downgrade: using daily data (applies to non-trading hours)
try:
klines = self.get_kline(market, symbol, '1D', 2)
if klines and len(klines) > 0:
@@ -180,7 +180,7 @@ class KlineService:
'previousClose': prev_close,
'source': 'kline_1d'
}
# 日线数据缓存 5 分钟
# Daily data cache for 5 minutes
self.cache.set(cache_key, result, 300)
return result
except Exception as e:
@@ -41,37 +41,37 @@ MT5Client = None
def _normalize_symbol_for_order(symbol: str, market_type: str = "swap") -> str:
"""
规范化符号格式确保符号符合交易所要求
Standardize symbol formats to ensure symbols comply with exchange requirements.
处理各种输入格式
Handles various input formats:
- BTC/USDT -> BTC/USDT
- BTCUSDT -> BTC/USDT
- BTC/USDT:USDT -> BTC/USDT
- PI, TRX -> PI/USDT, TRX/USDT (默认添加 /USDT)
- PI, TRX -> PI/USDT, TRX/USDT (/USDT is added by default)
Args:
symbol: 原始符号
market_type: 市场类型 (spot/swap)
symbol: original symbol
market_type: market type (spot/swap)
Returns:
规范化后的符号
normalized symbols
"""
if not symbol:
return symbol
sym = symbol.strip()
# 移除 swap/futures 后缀
# Remove swap/futures suffix
if ':' in sym:
sym = sym.split(':', 1)[0]
sym = sym.upper()
# 如果已经有分隔符,直接返回(假设格式正确)
# If there is already a separator, return it directly (assuming the format is correct)
if '/' in sym:
return sym
# 尝试从常见报价货币中识别
# Try to identify from common quote currencies
common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC']
for quote in common_quotes:
if sym.endswith(quote) and len(sym) > len(quote):
@@ -79,7 +79,7 @@ def _normalize_symbol_for_order(symbol: str, market_type: str = "swap") -> str:
if base:
return f"{base}/{quote}"
# 如果无法识别,默认使用 USDT
# If not recognized, USDT will be used by default.
return f"{sym}/USDT"
@@ -148,7 +148,7 @@ def place_order_from_signal(
if mt == "spot" and ("short" in (signal_type or "").lower()):
raise LiveTradingError("spot market does not support short signals")
# 规范化符号格式(统一处理裸符号如 PI, TRX 等)
# Standardized symbol format (unified processing of bare symbols such as PI, TRX, etc.)
symbol = _normalize_symbol_for_order(symbol, market_type=mt)
if isinstance(client, BinanceFuturesClient):
@@ -15,18 +15,18 @@ from typing import Dict, Tuple
def _split_base_quote(symbol: str) -> Tuple[str, str]:
"""
分割符号为基础货币和报价货币
The split symbols are base currency and quote currency.
处理各种格式
Handle various formats:
- BTC/USDT -> (BTC, USDT)
- BTCUSDT -> (BTCUSDT, "") - 需要进一步处理
- PI, TRX -> (PI, "") - 需要进一步处理
- BTCUSDT -> (BTCUSDT, "") - requires further processing
- PI, TRX -> (PI, "") - requires further processing
"""
s = (symbol or "").strip()
if ":" in s:
s = s.split(":", 1)[0]
if "/" not in s:
# 尝试识别报价货币(常见格式:BASEQUOTE
# Try to identify the quote currency (common format: BASEQUOTE)
s_upper = s.upper()
common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB']
for quote in common_quotes:
@@ -34,7 +34,7 @@ def _split_base_quote(symbol: str) -> Tuple[str, str]:
base = s_upper[:-len(quote)]
if base:
return base, quote
# 无法识别,返回原符号和空报价
# Unrecognized, the original symbol and empty quote are returned.
return s_upper, ""
base, quote = s.split("/", 1)
return base.strip().upper(), quote.strip().upper()
@@ -1,17 +1,17 @@
"""
市场数据采集服务 - AI分析专用
Market data collection service - dedicated to AI analysis
设计理念
1. 数据为王 - 先把数据获取做好做稳定
2. 统一数据源 - 完全复用 DataSourceFactory kline_service
3. 复用全球金融板块 - 宏观数据情绪数据复用 global_market.py 的缓存
4. 快速稳定 - 不依赖慢速外部服务如Jina Reader
Design concept:
1. Data is king - first obtain data well and make it stable
2. Unified data source - completely reuse DataSourceFactory and kline_service
3. Reuse the global financial sector - reuse the cache of global_market.py for macro data and sentiment data
4. Fast and stable - does not rely on slow external services (such as Jina Reader)
数据源映射
- 价格/K线: DataSourceFactory (已验证与K线模块自选列表一致)
- 宏观数据: 复用 global_market.py (VIX, DXY, TNX, Fear&Greed等带缓存)
- 新闻: Finnhub API (结构化数据无需深度阅读)
- 基本面: Finnhub (美股) / 固定描述 (加密)
Data source mapping:
- Price/K-line: DataSourceFactory (verified, consistent with K-line module and self-selected list)
- Macro data: reuse global_market.py (VIX, DXY, TNX, Fear&Greed, etc., with cache)
- News: Finnhub API (structured data, no in-depth reading required)
- Fundamentals: Finnhub (US stocks) / Fixed description (crypto)
"""
import time
@@ -32,15 +32,15 @@ logger = get_logger(__name__)
class MarketDataCollector:
"""
市场数据采集器
Market data collector
职责为AI分析提供完整准确及时的市场数据
Responsibilities: Provide complete, accurate and timely market data for AI analysis
数据层次
1. 核心数据 (必须成功): 价格K线
2. 分析数据 (增强): 技术指标基本面
3. 宏观数据 (可选): 复用 global_market.py (VIX, DXY, TNX, Fear&Greed等)
4. 情绪数据 (可选): 新闻市场情绪
Data level:
1. Core data (must succeed): price, K-line
2. Analyze data (enhanced): technical indicators, fundamentals
3. Macro data (optional): reuse global_market.py (VIX, DXY, TNX, Fear&Greed, etc.)
4. Sentiment data (optional): news, market sentiment
"""
def __init__(self):
@@ -50,7 +50,7 @@ class MarketDataCollector:
self._init_clients()
def _init_clients(self):
"""初始化外部API客户端"""
"""Initialize external API client"""
# Finnhub
finnhub_key = APIKeys.FINNHUB_API_KEY
if finnhub_key:
@@ -74,23 +74,23 @@ class MarketDataCollector:
timeframe: str = "1D",
include_macro: bool = True,
include_news: bool = True,
include_polymarket: bool = True, # 新增:是否包含预测市场数据
include_polymarket: bool = True, # New: Whether to include prediction market data
timeout: int = 30
) -> Dict[str, Any]:
"""
采集所有市场数据
Collect all market data
Args:
market: 市场类型 (USStock, Crypto, Forex, Futures)
symbol: 标的代码
timeframe: K线周期
include_macro: 是否包含宏观数据
include_news: 是否包含新闻
include_polymarket: 是否包含预测市场数据
timeout: 总超时时间()
market: market type (USStock, Crypto, Forex, Futures)
symbol: target code
timeframe: K-line cycle
include_macro: whether to include macro data
include_news: whether to include news
include_polymarket: whether to include prediction market data
timeout: total timeout (seconds)
Returns:
完整的市场数据字典
Complete Market Data Dictionary
"""
start_time = time.time()
@@ -99,21 +99,21 @@ class MarketDataCollector:
"symbol": symbol,
"timeframe": timeframe,
"collected_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
# 核心数据
# core data
"price": None,
"kline": None,
"indicators": {},
# 基本面
# Fundamentals
"fundamental": {},
"company": {},
# 宏观
# Macro
"macro": {},
# 情绪
# mood
"news": [],
"sentiment": {},
# 预测市场
# prediction market
"polymarket": [],
# 元数据
# metadata
"_meta": {
"success_items": [],
"failed_items": [],
@@ -121,19 +121,19 @@ class MarketDataCollector:
}
}
# === 阶段1: 核心数据 (并行获取) ===
# === Phase 1: Core data (parallel acquisition) ===
with ThreadPoolExecutor(max_workers=4) as executor:
core_futures = {
executor.submit(self._get_price, market, symbol): "price",
executor.submit(self._get_kline, market, symbol, timeframe, 60): "kline",
}
# 如果需要基本面,也并行获取
# If fundamentals are needed, also obtain them in parallel
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':
# 加密货币的"基本面"是固定描述
# Cryptocurrency 'fundamentals' are fixed description
core_futures[executor.submit(self._get_crypto_info, symbol)] = "fundamental"
try:
@@ -152,12 +152,12 @@ class MarketDataCollector:
except TimeoutError:
logger.warning(f"Core data fetch timed out for {market}:{symbol}")
# 计算技术指标 (本地计算,不需要外部API)
# Calculate technical indicators (local calculation, no external API required)
if data.get("kline"):
data["indicators"] = self._calculate_indicators(data["kline"])
data["_meta"]["success_items"].append("indicators")
# === 阶段2: 宏观数据 (如果需要) ===
# === Stage 2: Macro data (if required) ===
if include_macro:
try:
data["macro"] = self._get_macro_data(market, timeout=10)
@@ -167,10 +167,10 @@ class MarketDataCollector:
logger.warning(f"Macro data fetch failed: {e}")
data["_meta"]["failed_items"].append("macro")
# === 阶段3: 新闻/情绪 (如果需要) ===
# === Stage 3: News/Sentiment (if needed) ===
if include_news:
try:
# 获取公司名称以改善搜索
# Get company name to improve search
company_name = None
if data.get("company"):
company_name = data["company"].get("name")
@@ -185,7 +185,7 @@ class MarketDataCollector:
logger.warning(f"News fetch failed: {e}")
data["_meta"]["failed_items"].append("news")
# === 阶段4: 预测市场数据 (如果需要) ===
# === Stage 4: Prediction market data (if required) ===
if include_polymarket:
try:
polymarket_events = self._get_polymarket_events(symbol, market)
@@ -196,7 +196,7 @@ class MarketDataCollector:
logger.debug(f"Polymarket data fetch failed: {e}")
data["_meta"]["failed_items"].append("polymarket")
# 记录总耗时
# Record the total time spent
data["_meta"]["duration_ms"] = int((time.time() - start_time) * 1000)
logger.info(f"Market data collection completed for {market}:{symbol} in {data['_meta']['duration_ms']}ms")
logger.info(f" Success: {data['_meta']['success_items']}")
@@ -204,16 +204,16 @@ class MarketDataCollector:
return data
# ==================== 核心数据获取 ====================
# ==================== Core data acquisition ====================
def _get_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""
获取实时价格 - 使用 kline_service (与自选列表一致)
Get live prices - use kline_service (same as watchlist)
"""
try:
price_data = self.kline_service.get_realtime_price(market, symbol, force_refresh=True)
if price_data and price_data.get('price', 0) > 0:
# 安全转换为 float,处理 None 值
# Safe conversion to float, handling None values
def safe_float(val, default=0.0):
if val is None:
return default
@@ -236,7 +236,7 @@ class MarketDataCollector:
except Exception as e:
logger.warning(f"Price fetch failed for {market}:{symbol}: {e}")
# 如果 kline_service 失败,尝试从 K 线最后一根获取价格
# If kline_service fails, try to get the price from the last K-line
try:
klines = DataSourceFactory.get_kline(market, symbol, "1D", 2)
if klines and len(klines) > 0:
@@ -267,7 +267,7 @@ class MarketDataCollector:
self, market: str, symbol: str, timeframe: str, limit: int = 60
) -> Optional[List[Dict[str, Any]]]:
"""
获取K线数据 - 使用 DataSourceFactory (与K线模块一致)
Get K-line data - use DataSourceFactory (consistent with K-line module)
"""
try:
klines = DataSourceFactory.get_kline(market, symbol, timeframe, limit)
@@ -279,14 +279,14 @@ class MarketDataCollector:
def _calculate_indicators(self, klines: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
计算技术指标 (本地计算无外部依赖)
Calculate technical indicators (local calculation, no external dependencies)
返回格式符合前端 FastAnalysisReport.vue 的期望
口径说明与常见行情终端对齐
- RSI(14)Wilder 平滑首段均幅为前 14 期简单平均其后递推
- MACD收盘 EMA12/EMA26首值= N SMA信号线=MACD EMA9SMA 种子
- MASMA枢轴上一根 K H/L/C摆动高低 20 H/L 窗口极值
- 布林20 收盘 SMA ± 2×总体标准差ATR(14)Wilder ATR= 14 TR 简单平均其后递推
The return format meets the expectations of the front-end FastAnalysisReport.vue.
Caliber description (aligned with common market terminals):
- RSI(14): Wilder smoothing (the average amplitude of the first period is the simple average of the previous 14 periods, and then recursively).
- MACD: Closing EMA12/EMA26 (first value = SMA of the previous N days), signal line = EMA9 of MACD (SMA seed).
- MA: SMA. Pivot: H/L/C of the previous king. Swing High and Low: Near 20 H/L window extremes.
- Bollinger: 20 closing SMA ± 2× population standard deviation. ATR(14): Wilder (first ATR = simple average of TR in the first 14 periods, followed by recursion).
"""
if not klines or len(klines) < 5:
return {}
@@ -317,7 +317,7 @@ class MarketDataCollector:
'signal': rsi_signal,
}
# ========== MACDSMA 种子 EMA,与常见终端一致)==========
# ========== MACD (SMA seed EMA, consistent with common terminals) ==========
if len(closes) >= 34:
macd_raw = self._calc_macd(closes)
macd_val = macd_raw.get('MACD', 0)
@@ -342,7 +342,7 @@ class MarketDataCollector:
'trend': macd_trend,
}
# ========== 移动平均线 ==========
# ========== Moving Average ==========
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else current_price
ma10 = sum(closes[-10:]) / 10 if len(closes) >= 10 else current_price
ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else current_price
@@ -365,39 +365,39 @@ class MarketDataCollector:
'trend': ma_trend,
}
# 先算布林带,供下方合成支撑/阻力使用(键名 BB_upper / BB_lower
# Calculate the Bollinger Bands first and use them to synthesize support/resistance below (key name BB_upper / BB_lower)
bb_for_levels: Dict[str, Any] = {}
if len(closes) >= 20:
bb_for_levels = self._calc_bollinger(closes, 20, 2) or {}
# ========== 支撑/阻力位 (多种方法综合) ==========
# 方法1: 枢轴点 (Pivot Points) - 使用前一日数据
# ========== Support/Resistance Level (Several Methods Comprehensive) ==========
# Method 1: Pivot Points - Use previous day's data
if len(klines) >= 2:
prev_high = float(klines[-2].get('high', highs[-2]) if len(highs) >= 2 else current_price * 1.02)
prev_low = float(klines[-2].get('low', lows[-2]) if len(lows) >= 2 else current_price * 0.98)
prev_close = float(klines[-2].get('close', closes[-2]) if len(closes) >= 2 else current_price)
pivot = (prev_high + prev_low + prev_close) / 3
r1 = 2 * pivot - prev_low # 阻力位1
s1 = 2 * pivot - prev_high # 支撑位1
r2 = pivot + (prev_high - prev_low) # 阻力位2
s2 = pivot - (prev_high - prev_low) # 支撑位2
r1 = 2 * pivot - prev_low # Resistance level 1
s1 = 2 * pivot - prev_high # Support level 1
r2 = pivot + (prev_high - prev_low) # Resistance Level 2
s2 = pivot - (prev_high - prev_low) # Support level 2
else:
pivot = current_price
r1 = r2 = current_price * 1.02
s1 = s2 = current_price * 0.98
# 方法2: 近期高低点
# Method 2: Recent highs and lows
recent_highs = highs[-20:] if len(highs) >= 20 else highs
recent_lows = lows[-20:] if len(lows) >= 20 else lows
swing_high = max(recent_highs) if recent_highs else current_price * 1.05
swing_low = min(recent_lows) if recent_lows else current_price * 0.95
# 方法3: 布林上下轨(与 _calc_bollinger 返回字段一致)
# Method 3: Bollinger upper and lower rails (consistent with the _calc_bollinger return field)
bb_upper = bb_for_levels.get('BB_upper', swing_high)
bb_lower = bb_for_levels.get('BB_lower', swing_low)
# 综合取值: 取多种方法的平均/加权
# Comprehensive value: average/weighted by multiple methods
resistance = round((r1 + swing_high + bb_upper) / 3, 6)
support = round((s1 + swing_low + bb_lower) / 3, 6)
@@ -411,10 +411,10 @@ class MarketDataCollector:
'r2': round(r2, 6),
'swing_high': round(swing_high, 6),
'swing_low': round(swing_low, 6),
'method': 'pivot_swing_bb_avg' # 标注计算方法
'method': 'pivot_swing_bb_avg' # Label calculation method
}
# ========== ATR 和波动率(Wilder ATR,全序列递推至最新一根)==========
# ========== ATR and volatility (Wilder ATR, the whole sequence is recursively recursed to the latest one) ==========
atr = 0.0
if len(klines) >= 14:
atr = float(self._calc_atr_wilder(klines, period=14))
@@ -433,21 +433,21 @@ class MarketDataCollector:
indicators['volatility'] = {
'level': volatility_level,
'pct': round(volatility_pct, 2),
'atr': round(atr, 6), # 添加 ATR 绝对值
'atr': round(atr, 6), # Add ATR absolute value
}
# ========== 止盈止损建议 (基于 ATR 和支撑/阻力) ==========
# 止损: 基于 2x ATR 或支撑位,取更保守的
# ========== Take Profit and Stop Loss Recommendations (Based on ATR and Support/Resistance) ==========
# Stop Loss: Based on 2x ATR or support, whichever is more conservative
atr_stop_loss = current_price - (2 * atr) if atr > 0 else current_price * 0.95
support_stop = indicators['levels']['support']
suggested_stop_loss = max(atr_stop_loss, support_stop * 0.99) # 略低于支撑位
suggested_stop_loss = max(atr_stop_loss, support_stop * 0.99) # Just below support
# 止盈: 基于 3x ATR 或阻力位,考虑风险回报比
# Take Profit: Based on 3x ATR or resistance level, considering risk reward ratio
atr_take_profit = current_price + (3 * atr) if atr > 0 else current_price * 1.05
resistance_tp = indicators['levels']['resistance']
suggested_take_profit = min(atr_take_profit, resistance_tp * 1.01) # 略高于阻力位
suggested_take_profit = min(atr_take_profit, resistance_tp * 1.01) # Just above resistance
# 风险回报比
# risk reward ratio
risk = current_price - suggested_stop_loss
reward = suggested_take_profit - current_price
risk_reward_ratio = round(reward / risk, 2) if risk > 0 else 0
@@ -456,21 +456,21 @@ class MarketDataCollector:
'suggested_stop_loss': round(suggested_stop_loss, 6),
'suggested_take_profit': round(suggested_take_profit, 6),
'risk_reward_ratio': risk_reward_ratio,
'atr_multiplier_sl': 2.0, # 止损使用 2x ATR
'atr_multiplier_tp': 3.0, # 止盈使用 3x ATR
'atr_multiplier_sl': 2.0, # Stop loss using 2x ATR
'atr_multiplier_tp': 3.0, # Take profit using 3x ATR
'method': 'atr_support_resistance'
}
# ========== 布林带 (附加,与 bb_for_levels 同一次计算) ==========
# ========== Bollinger Bands (additional, same calculation as bb_for_levels) ==========
if bb_for_levels:
indicators['bollinger'] = bb_for_levels
# ========== 成交量 (附加) ==========
# ========== Volume (Additional) ==========
if len(volumes) >= 20:
avg_vol = sum(volumes[-20:]) / 20
indicators['volume_ratio'] = round(volumes[-1] / avg_vol, 2) if avg_vol > 0 else 1.0
# ========== 价格位置 (附加) ==========
# ========== Price Position (Additional) ==========
if len(closes) >= 20:
high_20 = max(highs[-20:])
low_20 = min(lows[-20:])
@@ -479,7 +479,7 @@ class MarketDataCollector:
else:
indicators['price_position'] = 50.0
# ========== 整体趋势 (附加) ==========
# ========== Overall Trends (Additional) ==========
indicators['trend'] = ma_trend
indicators['current_price'] = round(current_price, 6)
@@ -490,7 +490,7 @@ class MarketDataCollector:
return {}
def _calc_rsi(self, closes: List[float], period: int = 14) -> float:
"""Wilder RSI:首段均幅为前 period 期涨跌简单平均,之后按 Wilder 平滑递推。"""
"""Wilder RSI: The average amplitude of the first period is a simple average of the rise and fall of the previous period, and then it is smoothed by Wilder."""
if len(closes) < period + 1:
return 50.0
@@ -516,8 +516,8 @@ class MarketDataCollector:
def _ema_series_sma_seed(self, data: List[float], period: int) -> List[Optional[float]]:
"""
标准 EMA首值 = period 根简单平均SMA之后 EMA_t = (P_t - EMA_{t-1}) * k + EMA_{t-1}k=2/(period+1)
period-1 根无定义返回 None
Standard EMA: first value = first period root simple average (SMA), then EMA_t = (P_t - EMA_{t-1}) * k + EMA_{t-1}, k=2/(period+1).
The first period-1 root is undefined and None is returned.
"""
n = len(data)
out: List[Optional[float]] = [None] * n
@@ -535,7 +535,7 @@ class MarketDataCollector:
def _calc_macd(self, closes: List[float]) -> Dict[str, float]:
"""
MACD(12,26,9)DIF = EMA12(close) EMA26(close)DEA = EMA9(DIF) = DIF DEA
EMA 均采用 SMA 种子DIF 自第 26 K 起有定义信号线对 DIF 子序列再算 EMA9
Each EMA uses SMA seeds; DIF is defined from the 26th K onwards, and EMA9 is calculated for the DIF subsequence of the signal line pair.
"""
n = len(closes)
ema12 = self._ema_series_sma_seed(closes, 12)
@@ -566,7 +566,7 @@ class MarketDataCollector:
}
def _true_ranges(self, klines: List[Dict[str, Any]]) -> List[float]:
"""每根 K 的 True Range(首根仅 HL)。"""
"""True Range for each root of K (the first root is only HL)."""
trs: List[float] = []
for i, k in enumerate(klines):
h = float(k.get('high', 0))
@@ -582,7 +582,7 @@ class MarketDataCollector:
return trs
def _calc_atr_wilder(self, klines: List[Dict[str, Any]], period: int = 14) -> float:
"""Wilder ATR:首 ATR = 前 period 期 TR 简单平均,之后 ATR_t = (ATR_{t-1}*(period-1)+TR_t)/period"""
"""Wilder ATR: First ATR = simple average of TR in the previous period, then ATR_t = (ATR_{t-1}*(period-1)+TR_t)/period."""
trs = self._true_ranges(klines)
if len(trs) < period:
return 0.0
@@ -592,7 +592,7 @@ class MarketDataCollector:
return atr
def _calc_bollinger(self, closes: List[float], period: int = 20, std_dev: int = 2) -> Dict[str, float]:
"""布林带:中轨为 period 收盘 SMA,σ 为总体标准差(方差/period),上下轨=中轨±std_dev×σ"""
"""Bollinger Bands: The middle rail is the period closing SMA, σ is the overall standard deviation (variance/period), the upper and lower rails = middle rail ±std_dev×σ."""
if len(closes) < period:
return {}
@@ -609,10 +609,10 @@ class MarketDataCollector:
'BB_width': round((std_dev * std * 2) / middle * 100, 2) if middle > 0 else 0
}
# ==================== 基本面数据 ====================
# ==================== Fundamental data ====================
def _get_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""获取基本面数据"""
"""Get fundamental data"""
try:
if market == 'USStock':
return self._get_us_fundamental(symbol)
@@ -622,12 +622,12 @@ class MarketDataCollector:
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
美股基本面 - Finnhub + yfinance
包括基础财务指标 + 财报数据资产负债表利润表现金流量表
US stock fundamentals - Finnhub + yfinance
Includes: basic financial indicators + financial report data (balance sheet, income statement, cash flow statement)
"""
result = {}
# === 1. 基础财务指标 (Finnhub) ===
# === 1. Basic financial indicators (Finnhub) ===
if self._finnhub_client:
try:
metrics = self._finnhub_client.company_basic_financials(symbol, 'all')
@@ -653,12 +653,12 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"Finnhub fundamental failed for {symbol}: {e}")
# === 2. yfinance 补充基础指标 ===
# === 2. yfinance supplements basic indicators ===
try:
ticker = yf.Ticker(symbol)
info = ticker.info or {}
# 补充缺失的基础指标
# Supplement missing basic indicators
if not result.get('pe_ratio'):
result['pe_ratio'] = info.get('trailingPE') or info.get('forwardPE')
if not result.get('pb_ratio'):
@@ -678,7 +678,7 @@ class MarketDataCollector:
if not result.get('eps'):
result['eps'] = info.get('trailingEps')
# 补充更多财务指标
# Add more financial indicators
result.update({
'revenue': info.get('totalRevenue'),
'gross_profit': info.get('grossProfits'),
@@ -695,12 +695,12 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"yfinance fundamental failed for {symbol}: {e}")
# === 3. 获取财报数据(资产负债表、利润表、现金流量表)===
# === 3. Obtain financial report data (balance sheet, income statement, cash flow statement) ===
financial_statements = self._get_financial_statements(symbol)
if financial_statements:
result['financial_statements'] = financial_statements
# === 4. 获取盈利报告(Earnings===
# === 4. Get Earnings ===
earnings_data = self._get_earnings_data(symbol)
if earnings_data:
result['earnings'] = earnings_data
@@ -709,19 +709,19 @@ class MarketDataCollector:
def _get_financial_statements(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
获取财务报表数据资产负债表利润表现金流量表
Obtain financial statement data (balance sheet, income statement, cash flow statement)
使用 yfinance 获取包含最近几个季度的数据
Use yfinance to obtain, including data for recent quarters
"""
try:
ticker = yf.Ticker(symbol)
statements = {}
# 资产负债表 (Balance Sheet)
# Balance Sheet
try:
balance_sheet = ticker.balance_sheet
if balance_sheet is not None and not balance_sheet.empty:
# 获取最近4个季度
# Get the last 4 quarters
latest_quarters = balance_sheet.columns[:4] if len(balance_sheet.columns) >= 4 else balance_sheet.columns
statements['balance_sheet'] = {
'latest_date': str(latest_quarters[0]) if len(latest_quarters) > 0 else None,
@@ -736,7 +736,7 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"Balance sheet fetch failed for {symbol}: {e}")
# 利润表 (Income Statement)
# Income Statement
try:
income_stmt = ticker.financials
if income_stmt is not None and not income_stmt.empty:
@@ -752,7 +752,7 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"Income statement fetch failed for {symbol}: {e}")
# 现金流量表 (Cash Flow Statement)
# Cash Flow Statement
try:
cashflow = ticker.cashflow
if cashflow is not None and not cashflow.empty:
@@ -775,10 +775,10 @@ class MarketDataCollector:
def _get_earnings_data(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
获取盈利报告数据Earnings
Get earnings report data (Earnings)
使用 quarterly_income_stmt 替代已弃用的 Ticker.earnings / quarterly_earnings
历史季度摘要从利润表推导盈利日历仍用 ticker.calendar若可用
Use quarterly_income_stmt instead of deprecated Ticker.earnings / quarterly_earnings,
Historical quarterly summaries are derived from the income statement; the earnings calendar still uses ticker.calendar (if available).
"""
def _pick_float(stmt: pd.DataFrame, row_names: tuple, col) -> Optional[float]:
for name in row_names:
@@ -796,7 +796,7 @@ class MarketDataCollector:
ticker = yf.Ticker(symbol)
earnings_data: Dict[str, Any] = {}
# 季度利润表(yfinance 推荐路径,避免 fundamentals.Ticker.earnings 弃用告警)
# Quarterly income statement (yfinance recommended path to avoid fundamentals.Ticker.earnings deprecation warning)
try:
q_inc = ticker.quarterly_income_stmt
if q_inc is not None and not q_inc.empty and len(q_inc.columns) > 0:
@@ -824,7 +824,7 @@ class MarketDataCollector:
"earnings": ni,
}
# 最近若干季度 EPS(来自利润表行,非一致预期)
# EPS for recent quarters (from income statement lines, not consensus estimates)
earnings_data["history"] = []
for col in cols:
eps = _pick_float(q_inc, ("Diluted EPS", "Basic EPS"), col)
@@ -837,7 +837,7 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"Quarterly income statement (earnings) fetch failed for {symbol}: {e}")
# 盈利日历(未来盈利日期与一致预期)
# Earnings Calendar (Future Earnings Dates and Consensus Expectations)
try:
earnings_calendar = ticker.calendar
if earnings_calendar is not None and not earnings_calendar.empty:
@@ -861,8 +861,8 @@ class MarketDataCollector:
return None
def _get_crypto_info(self, symbol: str) -> Optional[Dict[str, Any]]:
"""加密货币信息 (固定描述为主)"""
# 常见加密货币的描述
"""Cryptocurrency information (mainly fixed description)"""
# Descriptions of common cryptocurrencies
crypto_info = {
'BTC': {
'name': 'Bitcoin',
@@ -896,7 +896,7 @@ class MarketDataCollector:
},
}
# 提取基础代币名
# Extract base token name
base = symbol.split('/')[0] if '/' in symbol else symbol
base = base.upper()
@@ -910,7 +910,7 @@ class MarketDataCollector:
}
def _get_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""获取公司信息"""
"""Get company information"""
try:
if market == 'USStock' and self._finnhub_client:
profile = self._finnhub_client.company_profile2(symbol=symbol)
@@ -930,19 +930,19 @@ class MarketDataCollector:
return None
# ==================== 宏观数据 (复用全球金融板块) ====================
# ==================== Macro data (reusing the global financial sector) ====================
def _get_macro_data(self, market: str, timeout: int = 10) -> Dict[str, Any]:
"""
获取宏观经济数据 - 复用 global_market.py 的函数和缓存
Get macroeconomic data - reuse global_market.py functions and cache
优势
1. 数据与全球金融页面一致
2. 复用30秒/5分钟缓存降低API调用
3. 已有完整的数据解读和级别判断
Advantages:
1. The data is consistent with the global financial page
2. Reuse 30 seconds/5 minutes cache to reduce API calls
3. Have complete data interpretation and level judgment
"""
try:
# 复用 global_market.py 的市场情绪数据 (有5分钟缓存)
# Reuse market sentiment data from global_market.py (with 5-minute cache)
from app.routes.global_market import (
_fetch_vix, _fetch_dollar_index, _fetch_yield_curve,
_fetch_fear_greed_index,
@@ -951,12 +951,12 @@ class MarketDataCollector:
result = {}
# 1) 尝试从缓存获取 (global_market 的缓存, 6小时有效)
# 1) Try to get it from cache (global_market cache, valid for 6 hours)
MACRO_CACHE_TTL = 21600 # 6 hours
cached_sentiment = _get_cached("market_sentiment", MACRO_CACHE_TTL)
if cached_sentiment:
logger.info("Using cached sentiment data from global_market (6h cache)")
# 转换格式
# Convert format
if cached_sentiment.get('vix'):
vix = cached_sentiment['vix']
result['VIX'] = {
@@ -1004,7 +1004,7 @@ class MarketDataCollector:
if result:
return result
# 2) 如果没有缓存,快速并行获取关键指标
# 2) If there is no cache, quickly obtain key indicators in parallel
logger.info("Fetching macro data from global_market functions")
with ThreadPoolExecutor(max_workers=4) as executor:
@@ -1021,7 +1021,7 @@ class MarketDataCollector:
try:
data = future.result(timeout=5)
if data:
# 转换为统一格式
# Convert to unified format
if key == 'VIX':
result[key] = {
'name': 'VIX恐慌指数',
@@ -1063,9 +1063,9 @@ class MarketDataCollector:
except TimeoutError:
logger.warning("Macro data fetch timed out")
# 注:黄金等大宗商品数据不再作为宏观指标获取
# 原因:1) 如果分析的是黄金,价格已在 _get_price 中获取
# 2) 减少 API 调用,提高稳定性
# Note: Gold and other commodity data are no longer available as macro indicators
# Reasons: 1) If the analysis is gold, the price has been obtained in _get_price
# 2) Reduce API calls and improve stability
pass
return result
@@ -1077,23 +1077,23 @@ class MarketDataCollector:
logger.error(f"_get_macro_data failed: {e}")
return {}
# ==================== 新闻/情绪数据 ====================
# ==================== News/Sentiment Data ====================
def _get_news(
self, market: str, symbol: str, company_name: str = None, timeout: int = 8
) -> Dict[str, Any]:
"""
获取新闻和情绪数据
Get news and sentiment data
策略按优先级
1. 结构化API (Finnhub) - 美股首选
2. 搜索引擎 (Tavily/Google/Bing/SerpAPI) - 补充搜索
3. 情绪分析 - Finnhub 社交媒体情绪
Strategies (by priority):
1. Structured API (Finnhub) - the first choice for US stocks
2. Search Engine (Tavily/Google/Bing/SerpAPI) - Supplementary Search
3. Sentiment Analysis - Finnhub Social Media Sentiment
"""
news_list = []
sentiment = {}
# === 1) Finnhub 新闻 (美股首选) ===
# === 1) Finnhub News (Preferred for US stocks) ===
if self._finnhub_client:
try:
end_date = datetime.now().strftime('%Y-%m-%d')
@@ -1104,10 +1104,10 @@ class MarketDataCollector:
if market == 'USStock':
raw_news = self._finnhub_client.company_news(symbol, _from=start_date, to=end_date)
elif market == 'Crypto':
# 加密货币通用新闻
# General Cryptocurrency News
raw_news = self._finnhub_client.general_news('crypto', min_id=0)
else:
# 其他市场通用新闻
# Other general market news
raw_news = self._finnhub_client.general_news('general', min_id=0)
if raw_news:
@@ -1126,7 +1126,7 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"Finnhub news fetch failed: {e}")
# === 2) Finnhub 情绪分数 (美股社交媒体情绪) ===
# === 2) Finnhub Sentiment Score (U.S. stock social media sentiment) ===
if self._finnhub_client and market == 'USStock':
try:
social = self._finnhub_client.stock_social_sentiment(symbol)
@@ -1136,19 +1136,19 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"Finnhub sentiment fetch failed: {e}")
# === 3) 搜索引擎补充 (如果新闻太少) ===
# === 3) Search engine supplement (if there is too little news) ===
if len(news_list) < 5:
search_news = self._get_news_from_search(market, symbol, company_name)
news_list.extend(search_news)
# === 4) 获取全球重大事件新闻(地缘政治、战争等) ===
# 这些事件会影响所有市场,特别是加密货币
# === 4) Get news on major global events (geopolitics, wars, etc.) ===
# These events affect all markets, especially cryptocurrencies
global_events = self._get_global_major_events()
if global_events:
news_list.extend(global_events)
logger.info(f"Added {len(global_events)} global major events to news list")
# 去重(按标题)
# Remove duplicates (by title)
seen_titles = set()
unique_news = []
for item in news_list:
@@ -1157,11 +1157,11 @@ class MarketDataCollector:
seen_titles.add(title)
unique_news.append(item)
# 按时间排序
# Sort by time
unique_news.sort(key=lambda x: x.get('datetime', ''), reverse=True)
return {
"news": unique_news[:15], # 最多15条
"news": unique_news[:15], # Maximum 15 articles
"sentiment": sentiment,
}
@@ -1169,9 +1169,9 @@ class MarketDataCollector:
self, market: str, symbol: str, company_name: str = None
) -> List[Dict[str, Any]]:
"""
从搜索引擎获取新闻
Get news from search engines
使用增强的搜索服务 (Tavily/Google/Bing/SerpAPI)
Use enhanced search services (Tavily/Google/Bing/SerpAPI)
"""
news_list = []
@@ -1182,10 +1182,10 @@ class MarketDataCollector:
if not search_service.is_available:
return news_list
# 构建搜索名称
# Build search name
search_name = company_name or symbol
# 搜索股票新闻
# Search stock news
response = search_service.search_stock_news(
stock_code=symbol,
stock_name=search_name,
@@ -1211,11 +1211,11 @@ class MarketDataCollector:
def _get_global_major_events(self) -> List[Dict]:
"""
获取全球重大事件新闻地缘政治战争重大政策等
这些事件会影响所有市场特别是加密货币
Get news on major global events (geopolitics, wars, major policies, etc.)
These events affect all markets, especially cryptocurrencies
Returns:
全球重大事件新闻列表
News list of major global events
"""
news_list = []
@@ -1226,10 +1226,10 @@ class MarketDataCollector:
if not search_service.is_available:
return news_list
# 搜索全球重大事件(最近24小时)
# 优化:减少搜索次数,只搜索最重要的查询
# Search for major global events (last 24 hours)
# Optimize: Reduce the number of searches and search only the most important queries
global_event_queries = [
"war conflict breaking news today" # 只搜索最重要的查询,减少API调用
"war conflict breaking news today" # Search only the most important queries and reduce API calls
]
for query in global_event_queries:
@@ -1237,17 +1237,17 @@ class MarketDataCollector:
response = search_service.search_with_fallback(
query=query,
max_results=2,
days=1 # 只搜索最近1天的新闻
days=1 # Search only the news of the last 1 day
)
if response.success and response.results:
for result in response.results:
# 检查是否是重大事件(包含关键词)
# Check if it is a major event (including keywords)
title_lower = result.title.lower()
snippet_lower = (result.snippet or "").lower()
text = f"{title_lower} {snippet_lower}"
# 重大事件关键词
# Major event keywords
major_event_keywords = [
"war", "conflict", "military", "attack", "strike", "sanctions",
"geopolitical", "crisis", "tension", "iran", "israel", "russia",
@@ -1263,14 +1263,14 @@ class MarketDataCollector:
"source": f"全球事件:{result.source}",
"url": result.url,
"sentiment": "negative" if any(kw in text for kw in ["war", "conflict", "attack", "战争", "冲突", "袭击"]) else "neutral",
"is_global_event": True # 标记为全球事件
"is_global_event": True # Flag as global event
})
logger.info(f"Found global major event: {result.title[:60]}")
except Exception as e:
logger.debug(f"Failed to search global events with query '{query}': {e}")
continue
# 去重
# Remove duplicates
seen_titles = set()
unique_events = []
for item in news_list:
@@ -1279,7 +1279,7 @@ class MarketDataCollector:
seen_titles.add(title)
unique_events.append(item)
return unique_events[:5] # 最多返回5条全球重大事件
return unique_events[:5] # Returns up to 5 major global events
except Exception as e:
logger.debug(f"Failed to get global major events: {e}")
@@ -1287,33 +1287,33 @@ class MarketDataCollector:
def _get_polymarket_events(self, symbol: str, market: str) -> List[Dict]:
"""
获取与资产相关的预测市场事件
直接调用Polymarket API获取实时数据不依赖本地数据库
Get prediction market events related to an asset
Directly call Polymarket API to obtain real-time data without relying on local database
Args:
symbol: 资产符号
market: 市场类型
symbol: asset symbol
market: market type
Returns:
相关预测市场事件列表
List of related prediction market events
"""
try:
from app.data_sources.polymarket import PolymarketDataSource
polymarket_source = PolymarketDataSource()
# 提取关键词
# Extract keywords
keywords = self._extract_polymarket_keywords(symbol, market)
logger.info(f"Extracted Polymarket keywords for {symbol}: {keywords}")
# 优化:使用缓存加速,减少API调用时间
# 对于AI分析,使用短期缓存(5分钟)即可,既保证时效性又提升性能
# 进一步优化:限制关键词数量,只搜索最重要的关键词(最多2个)
# Optimization: Use cache acceleration to reduce API call time
# For AI analysis, use short-term cache (5 minutes) to ensure timeliness and improve performance.
# Further optimization: limit the number of keywords and search only the most important keywords (up to 2)
related_markets = []
max_keywords = 2 # 最多只搜索2个关键词,减少API调用
max_keywords = 2 # Only search for 2 keywords at most, reducing API calls
for keyword in keywords[:max_keywords]:
try:
# 使用use_cache=True启用缓存,减少API调用时间
# Use use_cache=True to enable caching and reduce API call time
markets = polymarket_source.search_markets(keyword, limit=5, use_cache=True)
logger.info(f"Found {len(markets)} markets for keyword '{keyword}' (cached)")
related_markets.extend(markets)
@@ -1321,23 +1321,23 @@ class MarketDataCollector:
logger.warning(f"Failed to search Polymarket for keyword '{keyword}': {e}")
continue
# 去重
# Remove duplicates
seen = set()
result = []
for market_data in related_markets:
market_id = market_data.get('market_id')
if market_id and market_id not in seen:
seen.add(market_id)
# 构建正确的 Polymarket URL
# 优先使用已有的 polymarket_url,如果没有则根据 slug market_id 构建
# Build the correct Polymarket URL
# Prioritize using the existing polymarket_url, if not, build it based on slug or market_id
polymarket_url = market_data.get('polymarket_url')
if not polymarket_url:
slug = market_data.get('slug')
if slug and not str(slug).isdigit() and ('-' in str(slug) or any(c.isalpha() for c in str(slug))):
# 使用有效的 slug
# Use a valid slug
polymarket_url = f"https://polymarket.com/event/{slug}"
else:
# 使用 markets 端点(更可靠)
# Use markets endpoint (more reliable)
polymarket_url = f"https://polymarket.com/markets/{market_id}"
result.append({
@@ -1358,19 +1358,19 @@ class MarketDataCollector:
def _extract_polymarket_keywords(self, symbol: str, market: str) -> List[str]:
"""
提取用于搜索预测市场的关键词
优化只保留最重要的关键词减少API调用次数
Extract keywords used to search prediction markets
Optimization: Only retain the most important keywords and reduce the number of API calls
"""
keywords = []
# 基础符号(最重要)
# Basic symbols (most important)
if '/' in symbol:
base = symbol.split('/')[0]
keywords.append(base)
else:
keywords.append(symbol)
# 加密货币全名映射(只保留一个最重要的全名,避免重复)
# Cryptocurrency full name mapping (retain only the most important full name to avoid duplication)
crypto_names = {
'BTC': 'Bitcoin',
'ETH': 'Ethereum',
@@ -1386,13 +1386,13 @@ class MarketDataCollector:
base_symbol = symbol.split('/')[0] if '/' in symbol else symbol
if base_symbol in crypto_names:
# 只添加一个全名,避免大小写重复
# Add only one full name to avoid duplication of upper and lower case
keywords.append(crypto_names[base_symbol])
# 优化:移除通用关键词(如 '$100k', 'ETF', 'approval'),这些会匹配到很多不相关的市场
# 只保留与资产直接相关的关键词,最多2-3
# Optimization: Remove generic keywords (such as '$100k', 'ETF', 'approval'), which will match many irrelevant markets
# Only keep keywords directly related to assets, up to 2-3
# 去重并限制数量(最多3个关键词)
# Remove duplicates and limit the number (maximum 3 keywords)
unique_keywords = []
seen = set()
for kw in keywords:
@@ -1400,18 +1400,18 @@ class MarketDataCollector:
if kw_lower not in seen:
seen.add(kw_lower)
unique_keywords.append(kw)
if len(unique_keywords) >= 3: # 最多3个关键词
if len(unique_keywords) >= 3: # Up to 3 keywords
break
logger.info(f"Extracted {len(unique_keywords)} Polymarket keywords (optimized from {len(keywords)}): {unique_keywords}")
return unique_keywords
# 全局实例
# global instance
_collector: Optional[MarketDataCollector] = None
def get_market_data_collector() -> MarketDataCollector:
"""获取市场数据采集器单例"""
"""Get market data collector singleton"""
global _collector
if _collector is None:
_collector = MarketDataCollector()
@@ -206,8 +206,8 @@ class PendingOrderWorker:
try:
sc = load_strategy_configs(int(sid))
exec_mode = (sc.get("execution_mode") or "").strip().lower()
# 修改:即使signal模式,如果指定了target_strategy_id(策略启动时调用),也要同步
# 这样可以清理用户在交易所手动平仓但数据库记录还在的"幽灵持仓"
# Modification: Even in signal mode, if target_strategy_id (called when the strategy is started) is specified, it must be synchronized
# This can clear up "ghost positions" where users manually close positions on the exchange but still have database records.
if exec_mode != "live" and not target_strategy_id:
logger.debug(f"[PositionSync] Strategy {sid} skipped: execution_mode='{exec_mode}' (needs 'live' or explicit target)")
continue
@@ -215,7 +215,7 @@ class PendingOrderWorker:
exchange_config = resolve_exchange_config(sc.get("exchange_config") or {}, user_id=sync_user_id)
safe_cfg = safe_exchange_config_for_log(exchange_config)
# 检查 exchange_id 是否有效,如果为空或无效则跳过同步(signal模式可能没有配置交易所)
# Check if exchange_id is valid, skip synchronization if it is empty or invalid (signal mode may not have an exchange configured)
exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower()
if not exchange_id:
logger.debug(f"[PositionSync] Strategy {sid} skipped: exchange_id is empty (signal mode or no exchange config)")
@@ -248,7 +248,7 @@ class PendingOrderWorker:
except ImportError:
pass
# 尝试创建客户端,如果失败则跳过(可能是配置错误)
# Try to create client, skip if it fails (probably misconfiguration)
try:
client = create_client(exchange_config, market_type=market_type)
except Exception as e:
@@ -1,6 +1,6 @@
"""
Polymarket预测市场分析器
分析预测市场生成AI预测和交易机会推荐
Polymarket Prediction Market Analyzer
Analyze prediction markets and generate AI predictions and trading opportunity recommendations
"""
import json
import re
@@ -17,7 +17,7 @@ logger = get_logger(__name__)
class PolymarketAnalyzer:
"""预测市场AI分析器"""
"""Prediction Market AI Analyzer"""
def __init__(self):
self.llm_service = LLMService()
@@ -26,19 +26,19 @@ class PolymarketAnalyzer:
def analyze_market(self, market_id: str, user_id: int = None, use_cache: bool = True, language: str = 'zh-CN', model: str = None) -> Dict:
"""
分析单个预测市场
Analyze a single prediction market
Args:
market_id: 市场ID
user_id: 用户ID可选用于用户特定分析
use_cache: 是否使用缓存的分析结果默认True
language: 语言设置'zh-CN' 'en-US'用于生成对应语言的AI分析结果
market_id: Market ID
user_id: User ID (optional, for user-specific analysis)
use_cache: whether to use cached analysis results (default True)
language: language setting ('zh-CN' or 'en-US'), used to generate AI analysis results in the corresponding language
Returns:
分析结果字典
Analysis results dictionary
"""
try:
# 1. 获取市场数据
# 1. Get market data
market = self.polymarket_source.get_market_details(market_id)
if not market:
return {
@@ -46,21 +46,21 @@ class PolymarketAnalyzer:
"market_id": market_id
}
# 2. 如果使用缓存,检查是否有缓存的分析结果(30分钟有效)
# 2. If cache is used, check whether there are cached analysis results (valid for 30 minutes)
if use_cache:
cached_analysis = self._get_cached_analysis(market_id, user_id)
if cached_analysis:
cache_minutes = 30 # 缓存30分钟
cache_minutes = 30 # Cache for 30 minutes
if self._is_analysis_fresh(cached_analysis, max_age_minutes=cache_minutes):
logger.debug(f"Using cached analysis for market {market_id}")
return cached_analysis
# 3. 收集相关数据
# 3. Collect relevant data
related_news = self._get_related_news(market['question'])
related_assets = self._identify_related_assets(market['question'])
asset_data = self._get_asset_data(related_assets)
# 4. AI分析
# 4. AI analysis
ai_result = self._ai_predict_probability(
question=market['question'],
current_market_prob=market['current_probability'],
@@ -69,20 +69,20 @@ class PolymarketAnalyzer:
language=language
)
# 5. 计算机会评分
# 5. Calculate opportunity scores
opportunity_score = self._calculate_opportunity_score(
ai_prob=ai_result['predicted_probability'],
market_prob=market['current_probability'],
confidence=ai_result['confidence']
)
# 6. 生成推荐
# 6. Generate recommendations
recommendation = self._generate_recommendation(
divergence=ai_result['predicted_probability'] - market['current_probability'],
confidence=ai_result['confidence']
)
# 7. 构建分析结果
# 7. Construct analysis results
analysis_result = {
"market_id": market_id,
"ai_predicted_probability": ai_result['predicted_probability'],
@@ -98,7 +98,7 @@ class PolymarketAnalyzer:
"opportunity_score": opportunity_score
}
# 8. 保存到数据库
# 8. Save to database
self._save_analysis_to_db(analysis_result, user_id)
return analysis_result
@@ -112,54 +112,54 @@ class PolymarketAnalyzer:
def generate_asset_trading_opportunities(self, market_id: str) -> List[Dict]:
"""
基于预测市场生成相关资产的交易机会
Generate trading opportunities for related assets based on prediction markets
Args:
market_id: 预测市场ID
market_id: prediction market ID
Returns:
资产交易机会列表
List of asset trading opportunities
"""
try:
# 1. 分析预测市场
# 1. Analyze prediction markets
market_analysis = self.analyze_market(market_id)
if market_analysis.get('error'):
return []
# 2. 识别相关资产
# 2. Identify relevant assets
related_assets = market_analysis.get('related_assets', [])
if not related_assets:
return []
# 3. 对每个资产进行技术分析
# 3. Perform technical analysis on each asset
opportunities = []
for asset in related_assets:
try:
# 推断市场类型
# Infer market type
market_type = self._infer_market(asset)
# 获取资产数据
# Get asset data
asset_data = self.data_collector.collect_all(
market=market_type,
symbol=asset,
timeframe="1D",
include_polymarket=False # 避免循环
include_polymarket=False # avoid loops
)
# 技术分析
# technical analysis
technical_analysis = self._analyze_technical(asset_data)
# 结合预测市场信号
# Incorporate Predictive Market Signals
if market_analysis['recommendation'] == "YES":
# 预测事件发生概率高 → 相关资产可能上涨
# Predicted event probability is high → related assets may rise
signal = "BUY" if technical_analysis.get('trend') == "bullish" else "HOLD"
elif market_analysis['recommendation'] == "NO":
# 预测事件发生概率低 → 相关资产可能下跌
# The probability of the predicted event is low → the related assets may fall
signal = "SELL" if technical_analysis.get('trend') == "bearish" else "HOLD"
else:
signal = "HOLD"
# 计算综合置信度
# Calculate overall confidence
confidence = (
market_analysis['confidence_score'] * 0.6 +
technical_analysis.get('confidence', 50) * 0.4
@@ -184,7 +184,7 @@ class PolymarketAnalyzer:
logger.debug(f"Failed to analyze asset {asset} for market {market_id}: {e}")
continue
# 保存机会到数据库
# Save opportunity to database
if opportunities:
self._save_opportunities_to_db(market_id, opportunities)
@@ -196,12 +196,12 @@ class PolymarketAnalyzer:
def _ai_predict_probability(self, question: str, current_market_prob: float,
related_news: List, asset_data: Dict, language: str = 'zh-CN') -> Dict:
"""使用AI预测事件概率"""
"""Using AI to predict event probabilities"""
try:
# 根据语言设置构建prompt
# Build prompt based on language settings
is_english = language.lower() in ['en', 'en-us', 'en_us']
# 构建prompt
# build prompt
news_text = "\n".join([f"- {n.get('title', '')[:100]}" for n in related_news[:5]])
asset_text = ""
@@ -287,7 +287,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
system_prompt = "你是一个专业的市场分析师,擅长分析预测市场事件。请基于提供的数据,客观评估事件发生的概率。请使用中文回答。"
# 调用LLM
# Call LLM
messages = [
{
"role": "system",
@@ -305,13 +305,13 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
temperature=0.3
)
# 解析结果
# Parse results
if isinstance(result, str):
result = json.loads(result)
# 验证和规范化
# Validation and normalization
predicted_prob = float(result.get('predicted_probability', current_market_prob))
predicted_prob = max(0, min(100, predicted_prob)) # 限制在0-100
predicted_prob = max(0, min(100, predicted_prob)) # Limit to 0-100
confidence = float(result.get('confidence', 70))
confidence = max(0, min(100, confidence))
@@ -326,7 +326,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
except Exception as e:
logger.error(f"AI prediction failed: {e}", exc_info=True)
# 返回默认值
# Return to default value
return {
'predicted_probability': current_market_prob,
'confidence': 50.0,
@@ -338,28 +338,28 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
def _calculate_opportunity_score(self, ai_prob: float, market_prob: float,
confidence: float) -> float:
"""
计算机会评分0-100
Calculate opportunity rating (0-100)
逻辑
- AI与市场差异越大机会越好
- 置信度越高机会越好
logic:
- The greater the difference between AI and the market, the better the opportunity
- The higher the confidence, the better the chance
"""
divergence = abs(ai_prob - market_prob)
# 差异越大,机会越好(最大40分)
# The bigger the difference, the better the chance (maximum 40 points)
divergence_score = min(divergence * 2, 40)
# 置信度越高,机会越好(最大60分)
# The higher the confidence level, the better the chance (maximum 60 points)
confidence_score = confidence * 0.6
return round(divergence_score + confidence_score, 2)
def _generate_recommendation(self, divergence: float, confidence: float) -> str:
"""
生成推荐YES/NO/HOLD
Generate recommendations: YES/NO/HOLD
逻辑
- AI概率 > 市场概率 + 5% 且置信度 > 60 YES
- AI概率 < 市场概率 - 5% 且置信度 > 60 NO
- 其他 HOLD
logic:
- AI Probability > Market Probability + 5% and Confidence > 60 YES
- AI probability < market probability - 5% and confidence level > 60 NO
- Others HOLD
"""
if divergence > 5 and confidence > 60:
return "YES"
@@ -369,7 +369,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
return "HOLD"
def _assess_risk(self, market: Dict, ai_result: Dict) -> str:
"""评估风险等级"""
"""Assess risk level"""
confidence = ai_result.get('confidence', 50)
divergence = abs(ai_result.get('predicted_probability', 50) - market.get('current_probability', 50))
@@ -381,19 +381,19 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
return "low"
def _get_related_news(self, question: str) -> List[Dict]:
"""获取相关问题相关的新闻"""
# 提取关键词
"""Get news on relevant issues"""
# Extract keywords
keywords = self._extract_keywords(question)
# 这里可以调用新闻API,暂时返回空列表
# 实际实现时可以调用现有的新闻服务
# Here you can call the news API and temporarily return an empty list
# In actual implementation, existing news services can be called
return []
def _identify_related_assets(self, question: str) -> List[str]:
"""识别问题中提到的相关资产"""
"""Identify related assets mentioned in the question"""
assets = []
# 加密货币关键词映射
# Cryptocurrency Keyword Mapping
crypto_keywords = {
'BTC': ['BTC', 'Bitcoin', 'bitcoin', 'btc'],
'ETH': ['ETH', 'Ethereum', 'ethereum', 'eth'],
@@ -412,11 +412,11 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
if any(kw in question_upper for kw in keywords):
assets.append(f"{symbol}/USDT")
# 去重
# Remove duplicates
return list(set(assets))
def _get_asset_data(self, assets: List[str]) -> Optional[Dict]:
"""获取资产数据(取第一个资产)"""
"""Get asset data (get the first asset)"""
if not assets:
return None
@@ -433,7 +433,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
return None
def _analyze_technical(self, asset_data: Dict) -> Dict:
"""简单的技术分析"""
"""simple technical analysis"""
if not asset_data:
return {
'trend': 'neutral',
@@ -445,7 +445,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
indicators = asset_data.get('indicators', {})
price_data = asset_data.get('price', {})
# 简单的趋势判断
# Simple trend judgment
rsi = indicators.get('rsi', {}).get('value', 50)
macd_signal = indicators.get('macd', {}).get('signal', 'neutral')
@@ -465,22 +465,22 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
}
def _infer_market(self, symbol: str) -> str:
"""推断市场类型"""
"""Infer market type"""
if '/' in symbol:
return "Crypto"
elif len(symbol) <= 5 and symbol.isupper():
return "USStock"
else:
return "Crypto" # 默认
return "Crypto" # default
def _extract_keywords(self, text: str) -> List[str]:
"""提取关键词"""
# 简单的关键词提取
"""Extract keywords"""
# Simple keyword extraction
words = re.findall(r'\b[A-Z][a-z]+\b|\b[A-Z]{2,}\b', text)
return [w.lower() for w in words if len(w) > 2]
def _get_cached_analysis(self, market_id: str, user_id: int = None) -> Optional[Dict]:
"""获取缓存的分析结果"""
"""Get cached analysis results"""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -506,7 +506,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
cur.close()
if row:
# RealDictCursor返回字典,使用键访问
#RealDictCursor returns the dictionary, accessed using keys
key_factors_raw = row.get('key_factors')
key_factors = []
if key_factors_raw:
@@ -551,19 +551,19 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
def _save_analysis_to_db(self, analysis: Dict, user_id: int = None, language: str = 'en-US', model: str = None):
"""
保存分析结果到数据库
Save analysis results to database
Args:
analysis: 分析结果字典
user_id: 用户ID
language: 语言设置
model: 使用的模型
analysis: dictionary of analysis results
user_id: user ID
language: language settings
model: model used
"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 1. 保存到 qd_polymarket_ai_analysis 表(Polymarket专用表)
# 1. Save to qd_polymarket_ai_analysis table (Polymarket special table)
cur.execute("""
INSERT INTO qd_polymarket_ai_analysis
(market_id, user_id, ai_predicted_probability, market_probability,
@@ -584,7 +584,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
analysis.get('related_assets', [])
))
# 2. 同时保存到 qd_analysis_tasks 表(用于管理员统计和统一的历史记录查看)
# 2. Save to the qd_analysis_tasks table at the same time (for administrator statistics and unified historical record viewing)
market_info = analysis.get('market', {})
market_title = market_info.get('question', '') or market_info.get('title', '') or f"Polymarket Market {analysis['market_id']}"
result_json = json.dumps({
@@ -592,7 +592,7 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
'market_title': market_title,
'analysis': analysis,
'market': market_info,
'type': 'polymarket' # 标记为Polymarket分析
'type': 'polymarket' # Mark as Polymarket analysis
}, ensure_ascii=False)
cur.execute("""
@@ -602,8 +602,8 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors)
RETURNING id
""", (
int(user_id) if user_id else 1,
'Polymarket', # market字段
str(analysis['market_id']), # symbol字段存储market_id
'Polymarket', # market field
str(analysis['market_id']), # symbol field stores market_id
str(model) if model else '',
str(language),
'completed',
@@ -1,6 +1,6 @@
"""
Polymarket批量分析器
一次性分析多个市场由AI筛选出有交易机会的市场
Polymarket Batch Analyzer
Analyze multiple markets at once and use AI to screen out markets with trading opportunities.
"""
import json
from typing import List, Dict, Optional
@@ -13,7 +13,7 @@ logger = get_logger(__name__)
class PolymarketBatchAnalyzer:
"""批量分析预测市场,由AI筛选交易机会"""
"""Batch analysis predicts the market, and AI screens trading opportunities"""
def __init__(self):
self.llm_service = LLMService()
@@ -21,20 +21,20 @@ class PolymarketBatchAnalyzer:
def batch_analyze_markets(self, markets: List[Dict], max_opportunities: int = 20) -> List[Dict]:
"""
批量分析市场由AI筛选出有交易机会的市场
Analyze the market in batches and use AI to screen out markets with trading opportunities.
Args:
markets: 市场列表
max_opportunities: 最多返回多少个交易机会
markets: list of markets
max_opportunities: The maximum number of trading opportunities returned
Returns:
筛选后的市场列表包含AI分析结果
Filtered market list (including AI analysis results)
"""
if not markets:
return []
try:
# 1. 构建批量分析的prompt
# 1. Build prompts for batch analysis
markets_summary = self._build_markets_summary(markets)
prompt = f"""你是一个专业的预测市场分析师。请分析以下预测市场列表,筛选出最有交易机会的市场。
@@ -69,7 +69,7 @@ class PolymarketBatchAnalyzer:
- 优先选择高交易量 + 明显概率偏差 + 高置信度
- 简要说明原因不要冗长"""
# 2. 调用LLM进行批量分析
# 2. Call LLM for batch analysis
messages = [
{
"role": "system",
@@ -88,7 +88,7 @@ class PolymarketBatchAnalyzer:
temperature=0.3
)
# 3. 解析结果
# 3. Parse the results
if isinstance(result, str):
try:
result = json.loads(result)
@@ -101,7 +101,7 @@ class PolymarketBatchAnalyzer:
logger.warning("LLM returned no opportunities, using fallback")
return self._fallback_analysis(markets, max_opportunities)
# 4. 将AI分析结果合并到市场数据中
# 4. Incorporate AI analysis results into market data
opportunities_map = {opp.get('market_id'): opp for opp in opportunities}
analyzed_markets = []
@@ -112,24 +112,24 @@ class PolymarketBatchAnalyzer:
opp = opportunities_map.get(market_id)
if opp:
# 获取AI预测的概率
# Get the probability predicted by AI
predicted_prob = float(opp.get('predicted_probability', market.get('current_probability', 50.0)))
market_prob = market.get('current_probability', 50.0)
divergence = predicted_prob - market_prob
# 合并AI分析结果
# Merge AI analysis results
market['ai_analysis'] = {
'predicted_probability': predicted_prob, # 使用AI预测的概率
'predicted_probability': predicted_prob, # Probability predicted using AI
'recommendation': opp.get('recommendation', 'HOLD'),
'confidence_score': float(opp.get('confidence', 0)),
'opportunity_score': float(opp.get('opportunity_score', 0)),
'divergence': divergence, # AI预测概率 - 市场概率
'divergence': divergence, # AI Predicted Probability - Market Probability
'reasoning': opp.get('reason', ''),
'key_factors': opp.get('key_factors', [])
}
analyzed_markets.append(market)
# 5. 按机会评分排序
# 5. Sort by opportunity score
analyzed_markets.sort(
key=lambda x: x.get('ai_analysis', {}).get('opportunity_score', 0),
reverse=True
@@ -158,12 +158,12 @@ class PolymarketBatchAnalyzer:
return self._fallback_analysis(markets, max_opportunities)
def _build_markets_summary(self, markets: List[Dict]) -> str:
"""构建市场摘要,用于批量分析"""
"""Build market summaries for batch analysis"""
summary_lines = []
for i, market in enumerate(markets[:50], 1): # 限制最多50个,避免prompt过长
for i, market in enumerate(markets[:50], 1): # Limit to 50 to avoid prompts that are too long
market_id = market.get('market_id', '')
question = market.get('question', '')[:100] # 限制长度
question = market.get('question', '')[:100] # Limit length
prob = market.get('current_probability', 50.0)
volume = market.get('volume_24h', 0)
category = market.get('category', 'other')
@@ -179,14 +179,14 @@ class PolymarketBatchAnalyzer:
return "\n\n".join(summary_lines)
def _fallback_analysis(self, markets: List[Dict], max_opportunities: int) -> List[Dict]:
"""回退分析:基于简单规则筛选"""
"""Fallback analysis: filtering based on simple rules"""
opportunities = []
for market in markets:
prob = market.get('current_probability', 50.0)
volume = market.get('volume_24h', 0)
# 简单规则:交易量大 + 概率偏离50%
# Simple rule: large trading volume + 50% probability of deviation
if volume > 10000 and abs(prob - 50.0) > 10:
opportunity_score = min(60 + abs(prob - 50.0) * 0.5, 90)
@@ -201,7 +201,7 @@ class PolymarketBatchAnalyzer:
}
opportunities.append(market)
# 按机会评分排序
# Sort by opportunity score
opportunities.sort(
key=lambda x: x.get('ai_analysis', {}).get('opportunity_score', 0),
reverse=True
@@ -210,7 +210,7 @@ class PolymarketBatchAnalyzer:
return opportunities[:max_opportunities]
def save_batch_analysis(self, markets: List[Dict]):
"""保存批量分析结果到数据库"""
"""Save batch analysis results to database"""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -223,13 +223,13 @@ class PolymarketBatchAnalyzer:
continue
try:
# 先删除该市场的旧分析记录(user_id为NULL的通用分析)
# First delete the old analysis records of this market (general analysis with user_id as NULL)
cur.execute("""
DELETE FROM qd_polymarket_ai_analysis
WHERE market_id = %s AND user_id IS NULL
""", (market_id,))
# 插入新的分析记录
#Insert new analysis record
cur.execute("""
INSERT INTO qd_polymarket_ai_analysis
(market_id, user_id, ai_predicted_probability, market_probability,
@@ -238,7 +238,7 @@ class PolymarketBatchAnalyzer:
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
""", (
market_id,
None, # 通用分析
None, # General analysis
float(ai_analysis.get('predicted_probability', market.get('current_probability', 50.0))),
market.get('current_probability', 50.0),
float(ai_analysis.get('divergence', 0)),
@@ -1,6 +1,6 @@
"""
Polymarket后台任务
每30分钟更新一次市场数据并批量分析市场机会
Polymarket background tasks
Update market data every 30 minutes and analyze market opportunities in batches
"""
import threading
import time
@@ -15,15 +15,15 @@ logger = get_logger(__name__)
class PolymarketWorker:
"""Polymarket数据更新和分析后台任务"""
"""Polymarket data update and analysis background tasks"""
def __init__(self, update_interval_minutes: int = 30, analysis_cache_minutes: int = 1440): # 24小时缓存
def __init__(self, update_interval_minutes: int = 30, analysis_cache_minutes: int = 1440): # 24 hours cache
"""
初始化后台任务
Initialize background tasks
Args:
update_interval_minutes: 市场数据更新间隔分钟
analysis_cache_minutes: AI分析结果缓存时间分钟
update_interval_minutes: market data update interval (minutes)
analysis_cache_minutes: AI analysis result cache time (minutes)
"""
self.update_interval_minutes = update_interval_minutes
self.analysis_cache_minutes = analysis_cache_minutes
@@ -35,7 +35,7 @@ class PolymarketWorker:
self._last_update_ts = 0.0
def start(self) -> bool:
"""启动后台任务"""
"""Start background task"""
with self._lock:
if self._thread and self._thread.is_alive():
return True
@@ -46,7 +46,7 @@ class PolymarketWorker:
return True
def stop(self, timeout_sec: float = 5.0) -> None:
"""停止后台任务"""
"""Stop background tasks"""
with self._lock:
if not self._thread or not self._thread.is_alive():
return
@@ -58,36 +58,36 @@ class PolymarketWorker:
logger.info("PolymarketWorker stopped")
def _run_loop(self) -> None:
"""主循环"""
"""main loop"""
logger.info("PolymarketWorker loop started")
# 启动时立即执行一次
# Execute once immediately on startup
self._update_markets_and_analyze()
while not self._stop_event.is_set():
try:
# 等待指定时间间隔
# Wait for specified time interval
wait_seconds = self.update_interval_minutes * 60
if self._stop_event.wait(wait_seconds):
break # 如果收到停止信号,退出循环
break # If a stop signal is received, exit the loop
# 执行更新和分析
# Perform updates and analysis
self._update_markets_and_analyze()
except Exception as e:
logger.error(f"PolymarketWorker loop error: {e}", exc_info=True)
# 出错后等待1分钟再重试
# After an error, wait 1 minute and try again
self._stop_event.wait(60)
logger.info("PolymarketWorker loop stopped")
def _update_markets_and_analyze(self) -> None:
"""更新市场数据并分析"""
"""Update market data and analyze"""
try:
logger.info("Starting Polymarket data update and analysis...")
start_time = time.time()
# 1. 更新市场数据(从所有主要分类获取)
# 1. Update market data (obtained from all major categories)
categories = ["crypto", "politics", "economics", "sports", "tech", "finance", "geopolitics", "culture", "climate", "entertainment"]
all_markets = []
@@ -99,7 +99,7 @@ class PolymarketWorker:
except Exception as e:
logger.warning(f"Failed to fetch markets for category {category}: {e}")
# 去重(按market_id
# Deduplication (by market_id)
unique_markets = {}
for market in all_markets:
market_id = market.get('market_id')
@@ -108,28 +108,28 @@ class PolymarketWorker:
logger.info(f"Total unique markets: {len(unique_markets)}")
# 2. 批量分析市场(一次性分析所有市场,由AI筛选机会)
# 2. Analyze the market in batches (analyze all markets at once, and use AI to screen opportunities)
markets_list = list(unique_markets.values())
logger.info(f"Starting batch analysis for {len(markets_list)} markets...")
# 优化策略:先用规则筛选,只对高价值机会调用LLM
# 这样可以大幅减少LLM调用次数,节省token
# Optimization strategy: first use rules to filter and only call LLM on high-value opportunities
# This can greatly reduce the number of LLM calls and save tokens.
# 1. 先用规则筛选出最有价值的机会(不调用LLM
# 1. First use rules to filter out the most valuable opportunities (without calling LLM)
rule_based_opportunities = []
for market in markets_list:
prob = market.get('current_probability', 50.0)
volume = market.get('volume_24h', 0)
divergence = abs(prob - 50.0)
# 规则筛选:高交易量 + 明显概率偏差
# Rule screening: high trading volume + obvious probability deviation
if volume > 5000 and divergence > 8:
rule_based_opportunities.append(market)
# 2. 只对规则筛选出的机会调用LLM(最多30个,节省token
# 2. Only call LLM for opportunities filtered out by rules (up to 30, saving tokens)
if rule_based_opportunities:
logger.info(f"Rule-based filtering: {len(rule_based_opportunities)} opportunities, analyzing top 30 with LLM")
# 按交易量和概率偏差排序,取前30
# Sort by transaction volume and probability deviation, take the top 30
rule_based_opportunities.sort(
key=lambda x: (x.get('volume_24h', 0) * abs(x.get('current_probability', 50) - 50)),
reverse=True
@@ -138,13 +138,13 @@ class PolymarketWorker:
analyzed_markets = self.batch_analyzer.batch_analyze_markets(
top_opportunities,
max_opportunities=30 # 只分析30个最有价值的机会
max_opportunities=30 # Analyze only the 30 most valuable opportunities
)
else:
logger.info("No rule-based opportunities found, skipping LLM analysis")
analyzed_markets = []
# 3. 保存分析结果到数据库
# 3. Save the analysis results to the database
if analyzed_markets:
self.batch_analyzer.save_batch_analysis(analyzed_markets)
analyzed_count = len(analyzed_markets)
@@ -160,18 +160,18 @@ class PolymarketWorker:
def force_update(self) -> None:
"""强制立即更新(用于手动触发)"""
"""Force immediate update (for manual triggering)"""
logger.info("Force update triggered")
self._update_markets_and_analyze()
# 全局单例
# Global singleton
_polymarket_worker: Optional[PolymarketWorker] = None
_worker_lock = threading.Lock()
def get_polymarket_worker() -> PolymarketWorker:
"""获取PolymarketWorker单例"""
"""Get PolymarketWorker singleton"""
global _polymarket_worker
with _worker_lock:
if _polymarket_worker is None:
@@ -184,5 +184,5 @@ def get_polymarket_worker() -> PolymarketWorker:
return _polymarket_worker
# 需要导入os
# Need to import os
import os
@@ -26,7 +26,7 @@ DEFAULT_USER_ID = 1
_monitor_thread: Optional[threading.Thread] = None
_stop_event = threading.Event()
# 多语言消息模板
# Multilingual message templates
ALERT_MESSAGES = {
'zh-CN': {
'price_above': '🔔 价格突破预警: {symbol} 当前价格 ${current_price:.4f} 已突破 ${threshold:.4f}',
@@ -67,9 +67,9 @@ def _now_ts() -> int:
def _resolve_notification_delivery(user_id: int, notification_config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""
合并个人中心保存的 notification_settings targets并规范化 channels
前端创建监控常只传 channelsemail/telegram/webhook不传 targets若不合并则外发渠道全部跳过且无任何送达
若当前 channels 均无法送达无邮箱/Chat ID 则追加 browser 保证站内通知
Merge notification_settings saved in the personal center to targets and normalize channels.
When creating monitoring on the front end, only channels (email/telegram/webhook) are usually passed, and targets are not passed; if not merged, all outgoing channels will be skipped and no delivery will be made.
If the current channels cannot be delivered (no email/Chat ID, etc.), add a browser to ensure on-site notification.
"""
cfg: Dict[str, Any] = dict(notification_config) if isinstance(notification_config, dict) else {}
raw_ch = cfg.get('channels')
@@ -784,19 +784,19 @@ def _build_telegram_report(
# ── Header / Overview ──
if is_zh:
lines: List[str] = ["<b>📊 AI资产分析报告</b>", ""]
overview = ["<b>📈 概览</b>"]
lines: List[str] = ["<b>📊 AI Asset Analysis Report</b>", ""]
overview = ["<b>📈 Overview</b>"]
if held:
overview.append(f" 持仓: {len(held)}")
overview.append(f" 总成本: ${total_cost:,.2f}")
overview.append(f" 总盈亏: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
overview.append(f"Positions: {len(held)}")
overview.append(f"Total cost: ${total_cost:,.2f}")
overview.append(f"Total profit and loss: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
if watched:
overview.append(f"观察: {len(watched)}")
overview.append(f"Observations: {len(watched)}")
lines.extend(overview)
lines.extend([
"",
"<b>🤖 AI建议汇总</b>",
f"🟢 买入: {buy_count} | 🔴 卖出: {sell_count} | 🟡 持有: {hold_count}",
"<b>🤖 Summary of AI suggestions</b>",
f"🟢 Buy: {buy_count} | 🔴 Sell: {sell_count} | 🟡 Hold: {hold_count}",
])
else:
lines = ["<b>📊 AI Asset Analysis Report</b>", ""]
@@ -820,7 +820,7 @@ def _build_telegram_report(
emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '')
d_text = decision
if is_zh:
d_text = {'BUY': '买入', 'SELL': '卖出', 'HOLD': '持有'}.get(decision, '持有')
d_text = {'BUY': 'Buy', 'SELL': 'Sell', 'HOLD': 'Hold'}.get(decision, 'Hold')
lines.append(f"\n{emoji} <b>{pa.get('name', pa.get('symbol'))}</b> ({pa.get('market')}/{pa.get('symbol')})")
if show_pnl:
pnl = pa.get('pnl', 0)
@@ -828,13 +828,13 @@ def _build_telegram_report(
ps = '+' if pnl >= 0 else ''
lines.append(
f" 💰 ${pa.get('current_price', 0):,.2f} | "
f"{'盈亏' if is_zh else 'P&L'}: {ps}${pnl:,.2f} ({ps}{pnl_pct:.1f}%)"
f"{'profit and loss' if is_zh else 'P&L'}: {ps}${pnl:,.2f} ({ps}{pnl_pct:.1f}%)"
)
else:
lines.append(f" 💰 {'现价' if is_zh else 'Price'}: ${pa.get('current_price', 0):,.2f}")
lines.append(
f" 🎯 {'建议' if is_zh else 'Rec'}: <b>{d_text}</b> "
f"({'置信度' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)"
f"({'confidence' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)"
)
reasoning = pa.get('reasoning', '')
if reasoning:
@@ -842,20 +842,20 @@ def _build_telegram_report(
# ── Holdings section ──
if held:
lines.extend(["", f"<b>📋 {'持仓分析' if is_zh else 'Holdings'}</b>"])
lines.extend(["", f"<b>📋 {'position analysis' if is_zh else 'Holdings'}</b>"])
for pa in held:
_render_pa(pa, show_pnl=True)
# ── Watchlist section ──
if watched:
lines.extend(["", f"<b>👁 {'观察列表' if is_zh else 'Watchlist'}</b>"])
lines.extend(["", f"<b>👁 {'Watchlist' if is_zh else 'Watchlist'}</b>"])
for pa in watched:
_render_pa(pa, show_pnl=False)
# ── Errors ──
for pa in errored:
label = pa.get('name') or pa.get('symbol') or '?'
lines.append(f"\n⚠️ <b>{label}</b>: {'分析失败' if is_zh else 'Analysis failed'}")
lines.append(f"\n⚠️ <b>{label}</b>: {'Analysis failed' if is_zh else 'Analysis failed'}")
if custom_prompt:
lines.extend(["", f"<b>👤 {'关注点' if is_zh else 'Focus'}:</b> {custom_prompt}"])
@@ -864,7 +864,7 @@ def _build_telegram_report(
"",
"─────────────────────",
f"<i>⏰ {time.strftime('%Y-%m-%d %H:%M')}</i>",
f"<i>{' QuantDinger 多智能体系统生成' if is_zh else 'Generated by QuantDinger Multi-Agent System'}</i>",
f"<i>{'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System'}</i>",
])
return '\n'.join(lines)
@@ -893,11 +893,11 @@ def _build_batch_telegram_report(
for pa in m_analyses:
if pa.get('error'):
label = pa.get('name') or pa.get('symbol') or '?'
section_lines.append(f" ⚠️ {label}: {'分析失败' if is_zh else 'Failed'}")
section_lines.append(f" ⚠️ {label}: {'Analysis failed' if is_zh else 'Failed'}")
continue
decision = pa.get('final_decision', 'HOLD')
emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '')
d_text = ({'BUY': '买入', 'SELL': '卖出', 'HOLD': '持有'}.get(decision, '持有')) if is_zh else decision
d_text = ({'BUY': 'Buy', 'SELL': 'Sell', 'HOLD': 'Hold'}.get(decision, 'Hold')) if is_zh else decision
cur_price = pa.get('current_price', 0)
section_lines.append(
f"{emoji} <b>{pa.get('name', pa.get('symbol'))}</b> ({pa.get('market')}/{pa.get('symbol')})"
@@ -913,7 +913,7 @@ def _build_batch_telegram_report(
section_lines.append(f" 💰 {'现价' if is_zh else 'Price'}: ${cur_price:,.2f}")
section_lines.append(
f" 🎯 {'建议' if is_zh else 'Rec'}: <b>{d_text}</b> "
f"({'置信度' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)"
f"({'confidence' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)"
)
reasoning = pa.get('reasoning', '')
if reasoning:
@@ -932,20 +932,20 @@ def _build_batch_telegram_report(
if is_zh:
header = [
"<b>📊 定时资产监测报告</b>",
"<b>📊 Regular asset monitoring report</b>",
"",
"<b>📈 综合概览</b>",
f" 监控任务: {len(monitor_results)}",
f"标的数量: {len(all_analyses)}",
"<b>📈 General Overview</b>",
f"Monitoring tasks: {len(monitor_results)}",
f"Number of targets: {len(all_analyses)}",
]
if held:
header.append(f"持仓: {len(held)} 个 | 总成本: ${total_cost:,.2f} | 盈亏: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
header.append(f"Positions: {len(held)} | Total cost: ${total_cost:,.2f} | Profit and loss: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
if watched:
header.append(f"观察: {len(watched)}")
header.append(f"Observations: {len(watched)}")
header.extend([
"",
"<b>🤖 AI建议汇总</b>",
f"🟢 买入: {buy_c} | 🔴 卖出: {sell_c} | 🟡 持有: {hold_c}",
"<b>🤖 Summary of AI suggestions</b>",
f"🟢 Buy: {buy_c} | 🔴 Sell: {sell_c} | 🟡 Hold: {hold_c}",
])
else:
header = [
@@ -969,7 +969,7 @@ def _build_batch_telegram_report(
"",
"─────────────────────",
f"<i>⏰ {time.strftime('%Y-%m-%d %H:%M')}</i>",
f"<i>{' QuantDinger 多智能体系统生成' if is_zh else 'Generated by QuantDinger Multi-Agent System'}</i>",
f"<i>{'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System'}</i>",
]
return '\n'.join(header + monitor_sections + footer)
@@ -1043,7 +1043,7 @@ def _send_batch_notification(
is_zh = language.startswith('zh')
names = ', '.join(r.get('_meta', {}).get('monitor_name', '?') for r in successful)
title = f"📊 定时资产监测: {names}" if is_zh else f"📊 Scheduled Report: {names}"
title = f"📊 Scheduled Asset Monitoring: {names}" if is_zh else f"📊 Scheduled Report: {names}"
if len(title) > 255:
title = title[:252] + '...'
@@ -1117,12 +1117,12 @@ def _send_monitor_notification(
channels = notification_config.get('channels') or ['browser']
targets = notification_config.get('targets', {})
title = f"📊 资产监测: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}"
title = f"📊 Asset Monitor: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}"
if len(title) > 255:
title = title[:252] + '...'
if not result.get('success'):
error_title = f"⚠️ 资产监测失败: {monitor_name}" if language.startswith('zh') else f"⚠️ Monitor Failed: {monitor_name}"
error_title = f"⚠️ Asset monitoring failed: {monitor_name}" if language.startswith('zh') else f"⚠️ Monitor Failed: {monitor_name}"
if len(error_title) > 255:
error_title = error_title[:252] + '...'
error_msg = f"分析失败: {result.get('error', 'Unknown error')}" if language.startswith('zh') else f"Analysis failed: {result.get('error', 'Unknown error')}"
@@ -1549,7 +1549,7 @@ def _check_position_alerts():
db.commit()
cur.close()
# Send notification(合并个人中心通知配置,与资产监控任务一致)
# Send notification (merge personal center notification configuration, consistent with asset monitoring tasks)
resolved = _resolve_notification_delivery(alert_user_id, notification_config)
channels = resolved.get('channels') or ['browser']
targets = resolved.get('targets', {})
@@ -1639,16 +1639,16 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
quantity = float(pos.get('quantity') or 0)
entry_price = float(pos.get('entry_price') or 0)
title = f"🔗 策略信号联动: {pos_name}"
message = f"""策略发出 {signal_type} 信号!
title = f"🔗Strategy signal linkage: {pos_name}"
message = f"""The strategy emits {signal_type} signal!
标的: {market}/{symbol}
您的持仓: {pos_side.upper()} {quantity} @ {entry_price:.4f}
Target: {market}/{symbol}
Your position: {pos_side.upper()} {quantity} @ {entry_price:.4f}
信号详情:
Signal details:
{signal_detail}
请注意检查您的持仓是否需要调整"""
Please check whether your position needs adjustment. """
# Save browser notification
with get_db_connection() as db:
+106 -106
View File
@@ -1,13 +1,13 @@
"""
Search service v2.0 - 增强版搜索服务
整合多个搜索引擎支持 API Key 轮换和故障转移
Search service v2.0 - Enhanced version of search service
Integrate multiple search engines and support API Key rotation and failover
支持的搜索引擎按优先级
1. Tavily - 专为AI设计免费1000次/
2. SerpAPI - Google/Bing 结果抓取
3. Google CSE - 自定义搜索引擎
Supported search engines (in order of priority):
1. Tavily - specially designed for AI, free 1000 times/month
2. SerpAPI - Google/Bing result crawling
3. Google CSE Custom Search Engine
4. Bing Search API
5. DuckDuckGo - 免费兜底
5. DuckDuckGo Get the scoop for free
参考daily_stock_analysis-main/src/search_service.py
"""
@@ -34,21 +34,21 @@ _google_quota_reset_time = 0
@dataclass
class SearchResult:
"""搜索结果数据类"""
"""Search result data class"""
title: str
snippet: str # 摘要
snippet: str # summary
url: str
source: str # 来源网站
source: str # Source website
published_date: Optional[str] = None
sentiment: str = 'neutral' # 情绪标签
sentiment: str = 'neutral' # Emotion tags
def to_text(self) -> str:
"""转换为文本格式"""
"""Convert to text format"""
date_str = f" ({self.published_date})" if self.published_date else ""
return f"{self.source}{self.title}{date_str}\n{self.snippet}"
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
"""Convert to dictionary"""
return {
'title': self.title,
'link': self.url,
@@ -61,16 +61,16 @@ class SearchResult:
@dataclass
class SearchResponse:
"""搜索响应"""
"""search response"""
query: str
results: List[SearchResult]
provider: str # 使用的搜索引擎
provider: str # search engine used
success: bool = True
error_message: Optional[str] = None
search_time: float = 0.0 # 搜索耗时(秒)
search_time: float = 0.0 # Search time (seconds)
def to_context(self, max_results: int = 5) -> str:
"""将搜索结果转换为可用于 AI 分析的上下文"""
"""Transform search results into context that can be used for AI analysis"""
if not self.success or not self.results:
return f"搜索 '{self.query}' 未找到相关结果。"
@@ -81,20 +81,20 @@ class SearchResponse:
return "\n".join(lines)
def to_list(self) -> List[Dict[str, Any]]:
"""转换为列表格式(兼容旧接口)"""
"""Convert to list format (compatible with old interface)"""
return [r.to_dict() for r in self.results]
class BaseSearchProvider(ABC):
"""搜索引擎基类"""
"""Search engine base class"""
def __init__(self, api_keys: List[str], name: str):
"""
初始化搜索引擎
Initialize search engine
Args:
api_keys: API Key 列表支持多个 key 负载均衡
name: 搜索引擎名称
api_keys: API Key list (supports multiple key load balancing)
name: search engine name
"""
self._api_keys = api_keys
self._name = name
@@ -108,58 +108,58 @@ class BaseSearchProvider(ABC):
@property
def is_available(self) -> bool:
"""检查是否有可用的 API Key"""
"""Check if there is an available API Key"""
return bool(self._api_keys)
def _get_next_key(self) -> Optional[str]:
"""
获取下一个可用的 API Key负载均衡
Get the next available API Key (load balancing)
策略轮询 + 跳过错误过多的 key
Strategy: polling + skip keys with too many errors
"""
if not self._key_cycle:
return None
# 最多尝试所有 key
# Try at most all keys
for _ in range(len(self._api_keys)):
key = next(self._key_cycle)
# 跳过错误次数过多的 key(超过 3 次)
# Skip keys with too many errors (more than 3 times)
if self._key_errors.get(key, 0) < 3:
return key
# 所有 key 都有问题,重置错误计数并返回第一个
# There is a problem with all keys, reset the error count and return the first one
logger.warning(f"[{self._name}] 所有 API Key 都有错误记录,重置错误计数")
self._key_errors = {key: 0 for key in self._api_keys}
return self._api_keys[0] if self._api_keys else None
def _record_success(self, key: str) -> None:
"""记录成功使用"""
"""Record successful use"""
self._key_usage[key] = self._key_usage.get(key, 0) + 1
# 成功后减少错误计数
# Decrement error count on success
if key in self._key_errors and self._key_errors[key] > 0:
self._key_errors[key] -= 1
def _record_error(self, key: str) -> None:
"""记录错误"""
"""Log errors"""
self._key_errors[key] = self._key_errors.get(key, 0) + 1
logger.warning(f"[{self._name}] API Key {key[:8]}... 错误计数: {self._key_errors[key]}")
@abstractmethod
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行搜索(子类实现)"""
"""Perform a search (subclass implementation)"""
pass
def search(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
"""
执行搜索
Perform a search
Args:
query: 搜索关键词
max_results: 最大返回结果数
days: 搜索最近几天的时间范围默认7天
query: search keyword
max_results: Maximum number of results returned
days: Search the time range of the last few days (default 7 days)
Returns:
SearchResponse 对象
SearchResponse object
"""
api_key = self._get_next_key()
if not api_key:
@@ -199,7 +199,7 @@ class BaseSearchProvider(ABC):
@staticmethod
def _extract_domain(url: str) -> str:
"""从 URL 提取域名作为来源"""
"""Extract domain name from URL as source"""
try:
parsed = urlparse(url)
domain = parsed.netloc.replace('www.', '')
@@ -210,31 +210,31 @@ class BaseSearchProvider(ABC):
class TavilySearchProvider(BaseSearchProvider):
"""
Tavily 搜索引擎
Tavily search engine
特点
- 专为 AI/LLM 优化的搜索 API
- 免费版每月 1000 次请求
- 返回结构化的搜索结果
Features:
- Search API optimized for AI/LLM
- Free version 1000 requests per month
- Return structured search results
文档https://docs.tavily.com/
Documentation: https://docs.tavily.com/
"""
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "Tavily")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 Tavily 搜索"""
"""Perform a Tavily search"""
try:
from tavily import TavilyClient
except ImportError:
# 如果未安装 tavily-python,使用 REST API
# If tavily-python is not installed, use the REST API
return self._do_search_rest(query, api_key, max_results, days)
try:
client = TavilyClient(api_key=api_key)
# 执行搜索
# Perform a search
response = client.search(
query=query,
search_depth="advanced",
@@ -244,7 +244,7 @@ class TavilySearchProvider(BaseSearchProvider):
days=days,
)
# 解析结果
# Parse results
results = []
for item in response.get('results', []):
results.append(SearchResult(
@@ -276,7 +276,7 @@ class TavilySearchProvider(BaseSearchProvider):
)
def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""使用 REST API 执行 Tavily 搜索(备选方案)"""
"""Performing Tavily searches using the REST API (alternative)"""
try:
url = "https://api.tavily.com/search"
headers = {
@@ -332,20 +332,20 @@ class TavilySearchProvider(BaseSearchProvider):
class SerpAPISearchProvider(BaseSearchProvider):
"""
SerpAPI 搜索引擎
SerpAPI search engine
特点
- 支持 GoogleBing百度等多种搜索引擎
- 免费版每月 100 次请求
Features:
-Support multiple search engines such as Google, Bing, Baidu, etc.
- Free version 100 requests per month
文档https://serpapi.com/
Documentation: https://serpapi.com/
"""
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "SerpAPI")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 SerpAPI 搜索"""
"""Perform a SerpAPI search"""
try:
from serpapi import GoogleSearch
except ImportError:
@@ -405,7 +405,7 @@ class SerpAPISearchProvider(BaseSearchProvider):
)
def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""使用 REST API 执行 SerpAPI 搜索"""
"""Performing SerpAPI searches using the REST API"""
try:
tbs = "qdr:w"
if days <= 1:
@@ -467,14 +467,14 @@ class SerpAPISearchProvider(BaseSearchProvider):
class GoogleSearchProvider(BaseSearchProvider):
"""Google Custom Search (CSE) 搜索引擎"""
"""Google Custom Search (CSE) search engine"""
def __init__(self, api_key: str, cx: str):
super().__init__([api_key] if api_key else [], "Google")
self._cx = cx
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 Google 搜索"""
"""Perform a Google search"""
global _google_quota_exhausted, _google_quota_reset_time
if not self._cx:
@@ -495,7 +495,7 @@ class GoogleSearchProvider(BaseSearchProvider):
'num': min(max_results, 10),
}
# 添加时间限制
# Add time limit
if days <= 1:
params['dateRestrict'] = 'd1'
elif days <= 7:
@@ -550,13 +550,13 @@ class GoogleSearchProvider(BaseSearchProvider):
class BingSearchProvider(BaseSearchProvider):
"""Bing Search API 搜索引擎"""
"""Bing Search API search engine"""
def __init__(self, api_key: str):
super().__init__([api_key] if api_key else [], "Bing")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 Bing 搜索"""
"""Perform a Bing search"""
try:
url = "https://api.bing.microsoft.com/v7.0/search"
headers = {"Ocp-Apim-Subscription-Key": api_key}
@@ -600,15 +600,15 @@ class BingSearchProvider(BaseSearchProvider):
class DuckDuckGoSearchProvider(BaseSearchProvider):
"""DuckDuckGo 搜索引擎(免费,无需 API Key"""
"""DuckDuckGo search engine (free, no API Key required)"""
def __init__(self):
super().__init__(['free'], "DuckDuckGo")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 DuckDuckGo 搜索"""
"""Perform a DuckDuckGo search"""
try:
# 使用 DuckDuckGo Instant Answer API
# Using DuckDuckGo Instant Answer API
url = "https://api.duckduckgo.com/"
params = {
'q': query,
@@ -623,7 +623,7 @@ class DuckDuckGoSearchProvider(BaseSearchProvider):
results = []
# 获取 RelatedTopics
# Get RelatedTopics
related_topics = data.get('RelatedTopics', [])
for topic in related_topics[:max_results]:
if isinstance(topic, dict):
@@ -646,7 +646,7 @@ class DuckDuckGoSearchProvider(BaseSearchProvider):
source='DuckDuckGo',
))
# 检查 AbstractURL
# Check AbstractURL
if data.get('AbstractURL') and len(results) < max_results:
results.insert(0, SearchResult(
title=data.get('Heading', query),
@@ -655,7 +655,7 @@ class DuckDuckGoSearchProvider(BaseSearchProvider):
source='DuckDuckGo',
))
# 如果没有结果,尝试 HTML 版本
# If no results, try the HTML version
if not results:
results = self._search_html(query, max_results)
@@ -676,7 +676,7 @@ class DuckDuckGoSearchProvider(BaseSearchProvider):
)
def _search_html(self, query: str, max_results: int) -> List[SearchResult]:
"""DuckDuckGo HTML 搜索备选"""
"""DuckDuckGo HTML search alternatives"""
try:
url = "https://lite.duckduckgo.com/lite/"
headers = {
@@ -715,12 +715,12 @@ class DuckDuckGoSearchProvider(BaseSearchProvider):
class SearchService:
"""
搜索服务
Search service
功能
1. 管理多个搜索引擎
2. 自动故障转移
3. 结果聚合和格式化
Function:
1. Manage multiple search engines
2. Automatic failover
3. Result aggregation and formatting
"""
def __init__(self):
@@ -730,17 +730,17 @@ class SearchService:
self._init_providers()
def _load_config(self):
"""加载配置"""
"""Load configuration"""
config = load_addon_config()
self._config = config.get('search', {})
self.provider = self._config.get('provider', 'google')
self.max_results = int(self._config.get('max_results', 10))
def _init_providers(self):
"""初始化搜索引擎(按优先级排序)"""
"""Initialize search engines (sorted by priority)"""
from app.config import APIKeys
# 1. TavilyAI优化搜索)
# 1. Tavily (AI optimized search)
tavily_keys = APIKeys.TAVILY_API_KEYS
if tavily_keys:
self._providers.append(TavilySearchProvider(tavily_keys))
@@ -765,7 +765,7 @@ class SearchService:
self._providers.append(BingSearchProvider(bing_api_key))
logger.info("已配置 Bing 搜索")
# 5. DuckDuckGo(免费兜底)
# 5. DuckDuckGo (Free Tips)
self._providers.append(DuckDuckGoSearchProvider())
logger.info("已配置 DuckDuckGo 搜索(免费兜底)")
@@ -774,25 +774,25 @@ class SearchService:
@property
def is_available(self) -> bool:
"""检查是否有可用的搜索引擎"""
"""Check if a search engine is available"""
return any(p.is_available for p in self._providers)
def search(self, query: str, num_results: int = None, date_restrict: str = None, days: int = 7) -> List[Dict[str, Any]]:
"""
执行搜索兼容旧接口
Perform a search (compatible with old interface)
Args:
query: 搜索关键词
num_results: 最大返回结果数
date_restrict: 时间限制Google 格式 'd7'
days: 搜索最近几天优先级高于 date_restrict
query: search keyword
num_results: Maximum number of results returned
date_restrict: time restriction (Google format, such as 'd7')
days: Search the last few days (priority higher than date_restrict)
Returns:
搜索结果列表
Search result list
"""
limit = num_results if num_results else self.max_results
# 解析 date_restrict days
# Parse date_restrict to days
if date_restrict and not days:
if date_restrict.startswith('d'):
days = int(date_restrict[1:])
@@ -806,17 +806,17 @@ class SearchService:
def search_with_fallback(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
"""
执行搜索带自动故障转移
Perform a search (with automatic failover)
Args:
query: 搜索关键词
max_results: 最大返回结果数
days: 搜索最近几天
query: search keyword
max_results: Maximum number of results returned
days: Search the last few days
Returns:
SearchResponse 对象
SearchResponse object
"""
# 依次尝试各个搜索引擎
# Try each search engine in sequence
for provider in self._providers:
if not provider.is_available:
continue
@@ -828,7 +828,7 @@ class SearchService:
else:
logger.warning(f"{provider.name} 搜索失败: {response.error_message},尝试下一个引擎")
# 所有引擎都失败
# all engines fail
return SearchResponse(
query=query,
results=[],
@@ -845,27 +845,27 @@ class SearchService:
max_results: int = 5
) -> SearchResponse:
"""
搜索股票相关新闻
Search stock related news
Args:
stock_code: 股票代码
stock_name: 股票名称
market: 市场类型
max_results: 最大返回结果数
stock_code: stock code
stock_name: stock name
market: market type
max_results: Maximum number of results returned
Returns:
SearchResponse 对象
SearchResponse object
"""
# 智能确定搜索时间范围
# Intelligently determine search time range
today_weekday = datetime.now().weekday()
if today_weekday == 0: # 周一
if today_weekday == 0: # on Monday
search_days = 3
elif today_weekday >= 5: # 周末
elif today_weekday >= 5: # weekend
search_days = 2
else:
search_days = 1
# 根据市场类型构建搜索查询
# Build search queries based on market type
if market == "USStock":
query = f"{stock_name} {stock_code} stock news latest"
elif market == "Crypto":
@@ -886,7 +886,7 @@ class SearchService:
event_types: Optional[List[str]] = None
) -> SearchResponse:
"""
搜索股票特定事件年报预告减持等
Search for specific stock events (annual report preview, shareholding reduction, etc.)
"""
if event_types is None:
event_types = ["年报预告", "减持公告", "业绩快报"]
@@ -897,12 +897,12 @@ class SearchService:
return self.search_with_fallback(query, max_results=5, days=30)
# 单例实例
# Singleton instance
_search_service: Optional[SearchService] = None
def get_search_service() -> SearchService:
"""获取搜索服务单例"""
"""Get the search service singleton"""
global _search_service
if _search_service is None:
_search_service = SearchService()
@@ -910,6 +910,6 @@ def get_search_service() -> SearchService:
def reset_search_service() -> None:
"""重置搜索服务(用于测试或配置更新后)"""
"""Reset the search service (for testing or after configuration updates)"""
global _search_service
_search_service = None
@@ -92,17 +92,17 @@ class SignalNotifier:
"""
Notify signal events across channels.
通知配置说明:
- 用户在个人中心配置自己的通知设置telegram_bot_token, telegram_chat_id, email
- 创建策略/监控时系统自动使用用户配置的通知目标
Notification configuration instructions:
- Users configure their own notification settings (telegram_bot_token, telegram_chat_id, email, etc.) in the personal center
- When creating policies/monitors, the system automatically uses user-configured notification targets
公共服务配置管理员在系统设置中配置:
Public service configuration (configured by administrator in system settings):
- SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_USE_TLS
(邮件服务所有用户共用)
(Mail service, shared by all users)
- TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER
(短信服务所有用户共用)
(SMS service, shared by all users)
可选的环境变量:
Optional environment variables:
- SIGNAL_NOTIFY_TIMEOUT_SEC: HTTP timeout (default: 6)
"""
@@ -112,7 +112,7 @@ class SignalNotifier:
except Exception:
self.timeout_sec = 6.0
# 公共 SMTP 配置(管理员在系统设置中配置)
# Public SMTP configuration (configured by administrator in system settings)
self.smtp_host = (os.getenv("SMTP_HOST") or "").strip()
try:
self.smtp_port = int(os.getenv("SMTP_PORT") or "587")
@@ -663,10 +663,10 @@ class SignalNotifier:
token_override: str = "",
parse_mode: str = "",
) -> Tuple[bool, str]:
# 用户必须在个人中心配置自己的 telegram_bot_token
# Users must configure their own telegram_bot_token in the personal center
token = (token_override or "").strip()
if not token:
return False, "missing_telegram_bot_token (请在个人中心配置 Telegram Bot Token)"
return False, "missing_telegram_bot_token (please configure Telegram Bot Token in the personal center)"
if not chat_id:
return False, "missing_telegram_chat_id"
url = f"https://api.telegram.org/bot{token}/sendMessage"
@@ -860,4 +860,3 @@ class SignalNotifier:
return results
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,10 @@
"""
USDT Payment Service (方案B每单独立地址 + 自动对账)
USDT Payment Service (Plan B: independent address for each order + automatic reconciliation)
MVP:
- 只支持 USDT-TRC20
- 使用 XPUB 派生地址服务端只保存 xpub不保存私钥
- 后台 Worker 线程自动轮询链上到账 + 前端轮询双保险
- Only supports USDT-TRC20
- Use XPUB derived address (the server only saves xpub, not the private key)
- The background Worker thread automatically polls the on-chain account + front-end polling for double insurance
"""
import os
@@ -189,13 +189,13 @@ class UserService:
def get_token_version(self, user_id: int) -> int:
"""
获取用户当前的 token 版本号
Get the user's current token version number.
Args:
user_id: 用户ID
user_id: user ID
Returns:
当前 token 版本号默认为 1
Current token version number, default is 1
"""
try:
with get_db_connection() as db:
@@ -215,19 +215,19 @@ class UserService:
def increment_token_version(self, user_id: int) -> int:
"""
递增用户的 token 版本号使旧的 token 失效
用于实现单一客户端登录踢出其他设备
Increment the user's token version number and invalidate the old token.
Used to implement single client login (kick out other devices).
Args:
user_id: 用户ID
user_id: user ID
Returns:
新的 token 版本号
New token version number
"""
try:
with get_db_connection() as db:
cur = db.cursor()
# 递增 token_version
# Increment token_version
cur.execute(
"""
UPDATE qd_users
@@ -238,7 +238,7 @@ class UserService:
)
db.commit()
# 获取新的 token_version
# Get new token_version
cur.execute(
"SELECT token_version FROM qd_users WHERE id = ?",
(user_id,)
+1 -1
View File
@@ -1,5 +1,5 @@
"""
工具模块
tool module
"""
from app.utils.logger import get_logger
from app.utils.cache import CacheManager
+8 -8
View File
@@ -35,7 +35,7 @@ def generate_token(user_id: int, username: str, role: str = 'user', token_versio
'sub': username,
'user_id': user_id,
'role': role,
'token_version': token_version, # 用于单一客户端登录控制
'token_version': token_version, # For single client login control
}
return jwt.encode(
payload,
@@ -60,12 +60,12 @@ def verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256'])
# 验证 token_version(单一客户端登录控制)
# Verify token_version (single client login control)
user_id = payload.get('user_id')
token_version = payload.get('token_version')
if user_id and token_version is not None:
# 检查数据库中的 token_version 是否匹配
# Check if token_version in database matches
if not _verify_token_version(user_id, token_version):
logger.debug(f"Token version mismatch for user {user_id}: expected current, got {token_version}")
return None
@@ -81,12 +81,12 @@ def verify_token(token: str) -> dict:
def _verify_token_version(user_id: int, token_version: int) -> bool:
"""
验证 token 版本是否与数据库中存储的版本匹配
用于实现单一客户端登录踢出重复登录
Verify that the token version matches the version stored in the database.
Used to implement single client login (kick out duplicate logins).
Args:
user_id: 用户ID
token_version: Token中的版本号
user_id: user ID
token_version: version number in Token
Returns:
True if version matches, False otherwise
@@ -109,7 +109,7 @@ def _verify_token_version(user_id: int, token_version: int) -> bool:
return int(token_version) == int(db_token_version)
except Exception as e:
logger.error(f"_verify_token_version failed: {e}")
# 如果验证失败,为了安全起见,返回 False
# If validation fails, for security reasons, return False
return False
+5 -5
View File
@@ -15,7 +15,7 @@ logger = get_logger(__name__)
class MemoryCache:
"""内存缓存(Redis 不可用时的备选方案)"""
"""In-memory cache (an alternative if Redis is unavailable)"""
def __init__(self):
self._cache = {}
@@ -47,7 +47,7 @@ class MemoryCache:
class CacheManager:
"""缓存管理器"""
"""cache manager"""
_instance = None
_lock = threading.Lock()
@@ -98,7 +98,7 @@ class CacheManager:
self._use_redis = False
def get(self, key: str) -> Optional[Any]:
"""获取缓存"""
"""Get cache"""
try:
data = self._client.get(key)
if data:
@@ -109,14 +109,14 @@ class CacheManager:
return None
def set(self, key: str, value: Any, ttl: int = 300):
"""设置缓存"""
"""Set up cache"""
try:
self._client.setex(key, ttl, json.dumps(value))
except Exception as e:
logger.error(f"Cache write failed: {e}")
def delete(self, key: str):
"""删除缓存"""
"""Delete cache"""
try:
self._client.delete(key)
except Exception as e:
+11 -11
View File
@@ -17,7 +17,7 @@ from app.utils.logger import get_logger
logger = get_logger(__name__)
# 配置缓存
# Configure cache
_config_cache: Optional[Dict[str, Any]] = None
@@ -32,7 +32,7 @@ def load_addon_config() -> Dict[str, Any]:
"""
global _config_cache
# 如果缓存存在,直接返回
# If the cache exists, return directly
if _config_cache is not None:
return _config_cache
@@ -147,16 +147,16 @@ def load_addon_config() -> Dict[str, Any]:
def _convert_config_value(value: str, value_type: str) -> Any:
"""
根据类型转换配置值与PHP端convertConfigValue方法保持一致
Convert configuration values according to type (consistent with the convertConfigValue method on the PHP side)
Args:
value: 配置值字符串可能为None
value_type: 配置类型
value: configuration value string (may be None)
value_type: configuration type
Returns:
转换后的配置值
Converted configuration value
"""
# 处理 None 或空值
# Handling None or null values
if value is None or value == '':
if value_type == 'int':
return 0
@@ -186,7 +186,7 @@ def _convert_config_value(value: str, value_type: str) -> Any:
return str(value) if value is not None else ''
except (ValueError, TypeError) as e:
logger.warning(f"Config value type conversion failed: value={value}, type={value_type}, error={str(e)}")
# 转换失败时返回默认值
# Returns default value if conversion fails
if value_type == 'int':
return 0
elif value_type == 'float':
@@ -201,10 +201,10 @@ def _convert_config_value(value: str, value_type: str) -> Any:
def get_internal_api_key() -> Optional[str]:
"""
获取内部API密钥优先从环境变量读取
Get the internal API key (preferably read from environment variables)
Returns:
内部API密钥如果未配置则返回None
Internal API key, returns None if not configured
"""
try:
env_val = os.getenv('INTERNAL_API_KEY', '').strip()
@@ -227,7 +227,7 @@ def get_internal_api_key() -> Optional[str]:
def clear_config_cache():
"""
清除配置缓存配置更新后调用
Clear configuration cache (called after configuration update)
"""
global _config_cache
_config_cache = None
+2 -2
View File
@@ -223,7 +223,7 @@ class PostgresConnection:
def _ensure_session_utc(conn) -> None:
"""每条连接检出后固定为 UTC,与 API 序列化约定一致。"""
"""Each connection is fixed to UTC after checkout, consistent with the API serialization convention."""
try:
cur = conn.cursor()
cur.execute("SET TIME ZONE 'UTC'")
@@ -250,7 +250,7 @@ def get_pg_connection():
conn.rollback()
except Exception:
pass
# 记录更详细的错误信息
# Log more detailed error information
error_msg = str(e) if e else repr(e)
error_type = type(e).__name__
logger.error(f"PostgreSQL operation error ({error_type}): {error_msg}", exc_info=True)
+7 -7
View File
@@ -1,5 +1,5 @@
"""
HTTP 工具模块
HTTP tool module
"""
import requests
from requests.adapters import HTTPAdapter
@@ -12,15 +12,15 @@ def get_retry_session(
status_forcelist: tuple = (500, 502, 503, 504)
) -> requests.Session:
"""
获取带重试机制的 HTTP Session
Get HTTP Session with retry mechanism
Args:
retries: 重试次数
backoff_factor: 重试间隔因子
status_forcelist: 需要重试的 HTTP 状态码
retries: number of retries
backoff_factor: retry interval factor
status_forcelist: HTTP status codes that need to be retried
Returns:
配置好的 Session 实例
Configured Session instance
"""
session = requests.Session()
retry = Retry(
@@ -36,6 +36,6 @@ def get_retry_session(
return session
# 全局共享 Session
# Global shared session
global_session = get_retry_session()
+10 -10
View File
@@ -7,7 +7,7 @@ from logging.handlers import RotatingFileHandler
def setup_logger():
"""配置全局日志"""
"""Configure global logs"""
log_level = os.getenv('LOG_LEVEL', 'INFO')
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
@@ -16,22 +16,22 @@ def setup_logger():
format=log_format
)
# 过滤 werkzeug INFO 级别日志(减少噪音)
# 只保留 WARNING 及以上级别
# Filter werkzeug's INFO level logs (reduce noise)
# Only keep WARNING and above levels
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.setLevel(logging.WARNING)
# 过滤 kline 路由的 INFO 级别日志(减少噪音)
# 只保留 WARNING 及以上级别
# Filter INFO level logs for kline routes (reduce noise)
# Only keep WARNING and above levels
kline_logger = logging.getLogger('app.routes.kline')
kline_logger.setLevel(logging.WARNING)
# 创建日志目录
# Create log directory
log_dir = 'logs'
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# 添加文件处理器
# Add file handler
file_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10*1024*1024, # 10MB
@@ -44,13 +44,13 @@ def setup_logger():
def get_logger(name: str) -> logging.Logger:
"""
获取指定名称的日志记录器
Get the logger with the specified name
Args:
name: 日志记录器名称
name: logger name
Returns:
Logger 实例
Logger instance
"""
return logging.getLogger(name)
+61 -61
View File
@@ -1,6 +1,6 @@
"""
安全的代码执行工具
提供超时资源限制和沙箱环境
Secure code execution tools
Provides timeouts, resource limits and a sandbox environment
"""
import signal
import sys
@@ -16,36 +16,36 @@ logger = get_logger(__name__)
class TimeoutError(Exception):
"""代码执行超时异常"""
"""Code execution timeout exception"""
pass
@contextmanager
def timeout_context(seconds: int):
"""
代码执行超时上下文管理器
Code execution timeout context manager
注意
- 仅在Unix/Linux系统上有效
- 仅在主线程中有效非主线程会降级为不限制超时
- Windows上会降级为不限制超时
Notice:
- Only works on Unix/Linux systems
- Only valid in the main thread, non-main threads will be downgraded to unlimited timeout
- Will downgrade to unlimited timeout on Windows
Args:
seconds: 超时时间
seconds: timeout (seconds)
"""
# 检查是否在主线程中
# Check if in main thread
is_main_thread = threading.current_thread() is threading.main_thread()
if sys.platform == 'win32':
# Windows不支持signal.alarm,只能记录警告
# signal.alarm is not supported on Windows, only warnings can be logged
logger.warning("Windows does not support signal-based timeouts; execution time limits may not work")
yield
return
if not is_main_thread:
# 非主线程不能使用signal,记录警告但不限制超时
# logger.warning(f"当前在非主线程中运行(线程: {threading.current_thread().name}),"
# f"signal超时不可用,代码执行可能无法限制时间")
# Non-main threads cannot use signal. Warnings are logged but timeouts are not limited.
# logger.warning(f"Currently running in a non-main thread (thread: {threading.current_thread().name}),"
# f"signal timeout is not available, code execution may not limit the time")
yield
return
@@ -53,18 +53,18 @@ def timeout_context(seconds: int):
raise TimeoutError(f"代码执行超时(超过{seconds}秒)")
try:
# 设置信号处理器
# Set up signal handler
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(seconds)
try:
yield
finally:
# 恢复原来的信号处理器
# Restore original signal handler
signal.alarm(0)
signal.signal(signal.SIGALRM, old_handler)
except ValueError as e:
# 如果signal设置失败(比如在某些环境中),记录警告但不中断执行
# If signal setup fails (such as in some environments), log a warning but do not interrupt execution
logger.warning(f"Failed to set signal timeout: {str(e)}; execution will continue without timeout enforcement")
yield
@@ -77,35 +77,35 @@ def safe_exec_code(
max_memory_mb: Optional[int] = None
) -> Dict[str, Any]:
"""
安全执行Python代码
Safe execution of Python code
Args:
code: 要执行的Python代码
exec_globals: 全局变量字典
exec_locals: 局部变量字典如果为None则使用exec_globals
timeout: 超时时间默认30秒
max_memory_mb: 最大内存限制MB默认500MB
code: Python code to execute
exec_globals: dictionary of global variables
exec_locals: dictionary of local variables (if None, exec_globals is used)
timeout: timeout (seconds), default 30 seconds
max_memory_mb: Maximum memory limit (MB), default 500MB
Returns:
执行结果字典包含
- success: bool是否执行成功
- error: str错误信息如果失败
- result: Any执行结果如果有
Execution result dictionary, including:
- success: bool, whether the execution was successful
- error: str, error message (if failure)
- result: Any, execution result (if any)
Raises:
TimeoutError: 如果代码执行超时
TimeoutError: If the code execution times out
"""
if exec_locals is None:
exec_locals = exec_globals
# 设置内存限制(如果支持)
# Set memory limit (if supported)
if max_memory_mb is None:
max_memory_mb = 500 # 默认500MB
max_memory_mb = 500 # Default 500MB
try:
# 注意:resource.setrlimit 是进程级别,会影响整个 API 进程。
# 之前全局限制为 500MB 可能导致并行策略/线程无法分配内存。
# 仅当显式开启 SAFE_EXEC_ENABLE_RLIMIT 时才设置。
# Note: resource.setrlimit is process level and affects the entire API process.
# The previous global limit of 500MB could prevent parallel strategies/threads from allocating memory.
# Only set if SAFE_EXEC_ENABLE_RLIMIT is explicitly turned on.
if sys.platform != 'win32' and os.getenv('SAFE_EXEC_ENABLE_RLIMIT', 'false').lower() == 'true':
try:
import resource
@@ -117,8 +117,8 @@ def safe_exec_code(
else:
logger.debug("No resource memory limit (SAFE_EXEC_ENABLE_RLIMIT disabled or unsupported platform)")
# Windows上,timeout_context不会真正限制时间
# 但会记录警告
# On Windows, timeout_context doesn't really limit the time
# But a warning will be logged
with timeout_context(timeout):
exec(code, exec_globals, exec_locals)
@@ -157,12 +157,12 @@ def safe_exec_code(
def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
"""
验证代码安全性基本检查
Verify code security (basic check)
检查代码中是否包含危险的函数调用或导入
Check your code for dangerous function calls or imports
Args:
code: 要检查的Python代码
code: Python code to check
Returns:
(is_safe: bool, error_message: Optional[str])
@@ -170,9 +170,9 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
import ast
import re
# 危险的关键字和函数名
# Dangerous keywords and function names
dangerous_patterns = [
# 系统命令执行
# System command execution
r'\bos\.system\b',
r'\bos\.popen\b',
r'\bos\.spawn\b',
@@ -180,16 +180,16 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
r'\bos\.fork\b',
r'\bsubprocess\b',
r'\bcommands\b',
# 代码执行
# code execution
r'\b__import__\s*\(',
r'\beval\s*\(',
r'\bexec\s*\(',
r'\bcompile\s*\(',
# 文件操作
# File operations
r'\bopen\s*\(',
r'\bfile\s*\(',
r'\b__builtins__\b',
# 模块导入
# module import
r'\bimport\s+os\b',
r'\bimport\s+sys\b',
r'\bimport\s+subprocess\b',
@@ -208,7 +208,7 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
r'\bimport\s+multiprocessing\b',
r'\bimport\s+threading\b',
r'\bimport\s+concurrent\b',
# 反射和元编程(可能用于绕过限制)
# Reflection and metaprogramming (possibly used to bypass restrictions)
r'\bgetattr\s*\(.*__import__',
r'\bgetattr\s*\(.*eval',
r'\bgetattr\s*\(.*exec',
@@ -219,14 +219,14 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
r'\bglobals\s*\(',
r'\blocals\s*\(',
r'\bdir\s*\(',
r'\btype\s*\(.*\)\s*\(', # type() 可能用于创建新类型
r'\btype\s*\(.*\)\s*\(', # type() may be used to create new types
r'\b__class__\b',
r'\b__bases__\b',
r'\b__subclasses__\b',
r'\b__mro__\b',
r'\b__init__\b.*__import__',
r'\b__new__\b.*__import__',
# 其他危险操作
# Other dangerous operations
r'\b__builtins__\s*\[',
r'\b__builtins__\s*\.',
r'\b__import__\s*\(',
@@ -234,16 +234,16 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
r'\bimp\b',
]
# 检查代码中是否包含危险模式
# Check your code for dangerous patterns
for pattern in dangerous_patterns:
if re.search(pattern, code):
return False, f"检测到危险代码模式: {pattern}"
# 尝试解析AST,检查是否有危险的节点
# Try parsing the AST, checking for dangerous nodes
try:
tree = ast.parse(code)
# 危险模块列表(扩展)
# List of dangerous modules (extended)
dangerous_modules = [
'os', 'sys', 'subprocess', 'pymysql', 'sqlite3',
'requests', 'urllib', 'http', 'socket', 'ftplib', 'telnetlib',
@@ -252,38 +252,38 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
'importlib', 'imp', 'builtins'
]
# 危险函数列表(扩展)
# 注意:hasattr 是安全的,只用于检查属性,不用于访问
# List of dangerous functions (extended)
# Note: hasattr is safe and is only used to check attributes, not to access
dangerous_functions = [
'eval', 'exec', 'compile', '__import__',
'getattr', 'setattr', 'delattr', # hasattr 已移除,它是安全的
'getattr', 'setattr', 'delattr', # hasattr has been removed, it is safe
'globals', 'locals', 'vars', 'dir', 'type'
]
# 检查是否有危险的函数调用
# Check for dangerous function calls
for node in ast.walk(tree):
# 检查是否有对危险函数的调用
# Check for calls to dangerous functions
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name):
func_name = node.func.id
if func_name in dangerous_functions:
return False, f"检测到危险函数调用: {func_name}()"
# 检查是否有os.system等调用
# Check if there are calls to os.system etc.
if isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
if node.func.value.id in dangerous_modules:
return False, f"检测到危险模块调用: {node.func.value.id}.{node.func.attr}"
# 检查是否有 getattr(builtins, '__import__') 等绕过方式
# Check if there are bypass methods such as getattr(builtins, '__import__')
if isinstance(node.func, ast.Name) and node.func.id == 'getattr':
# 检查 getattr 的参数
# Check the parameters of getattr
if len(node.args) >= 2:
if isinstance(node.args[0], ast.Name) and node.args[0].id in ['builtins', '__builtins__']:
if isinstance(node.args[1], ast.Constant) and node.args[1].value in dangerous_functions:
return False, f"检测到通过 getattr 绕过限制: getattr({node.args[0].id}, '{node.args[1].value}')"
# 检查导入语句:用户脚本中一律禁止使用 import(统一由平台注入安全依赖)
# Check the import statement: the use of import is prohibited in user scripts (security dependencies are uniformly injected by the platform)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
return False, "不允许在脚本中使用 import 语句,请直接使用平台提供的 pd/np 等对象"
@@ -291,19 +291,19 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
if isinstance(node, ast.ImportFrom):
return False, "不允许在脚本中使用 import 语句,请直接使用平台提供的 pd/np 等对象"
# 检查是否有访问 __builtins__ 的尝试
# Check if there is an attempt to access __builtins__
for node in ast.walk(tree):
if isinstance(node, ast.Attribute):
if isinstance(node.attr, str) and node.attr.startswith('__') and node.attr.endswith('__'):
if node.attr in ['__builtins__', '__import__', '__class__', '__bases__', '__subclasses__', '__mro__']:
# 检查是否在危险上下文中使用
# Check if used in dangerous context
if isinstance(node.value, ast.Name) and node.value.id in ['builtins', '__builtins__']:
return False, f"检测到访问危险属性: {node.value.id}.{node.attr}"
except SyntaxError as e:
return False, f"代码语法错误: {str(e)}"
except Exception as e:
# 如果AST解析失败,记录警告但允许继续(可能是代码不完整)
# If AST parsing fails, log a warning but allow to continue (possibly incomplete code)
logger.warning(f"AST parse failed; skipping safety checks: {str(e)}")
return True, None