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
+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())