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:
@@ -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',
|
||||
|
||||
@@ -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×86400s(48h)会在周一早盘误报;改为日线最多容忍约 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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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,
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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 month’s 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user