Files
DinQuant/backend_api_python/app/data_sources/crypto.py
T
TIANHE 7c067fe61c v2.2.1
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-02-28 20:34:33 +08:00

376 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
加密货币数据源
使用 CCXT (Coinbase) 获取数据
"""
from typing import Dict, List, Any, Optional, Tuple
from datetime import datetime, timedelta
import ccxt
from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS
from app.utils.logger import get_logger
from app.config import CCXTConfig, APIKeys
logger = get_logger(__name__)
class CryptoDataSource(BaseDataSource):
"""加密货币数据源"""
name = "Crypto/CCXT"
# 时间周期映射
TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP
# 常见的报价货币列表(按优先级排序)
COMMON_QUOTES = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB', 'EUR', 'GBP']
def __init__(self):
config = {
'timeout': CCXTConfig.TIMEOUT,
'enableRateLimit': CCXTConfig.ENABLE_RATE_LIMIT
}
# 如果配置了代理
if CCXTConfig.PROXY:
config['proxies'] = {
'http': CCXTConfig.PROXY,
'https': CCXTConfig.PROXY
}
exchange_id = CCXTConfig.DEFAULT_EXCHANGE
# 动态加载交易所类
if not hasattr(ccxt, exchange_id):
logger.warning(f"CCXT exchange '{exchange_id}' not found, falling back to 'coinbase'")
exchange_id = 'coinbase'
exchange_class = getattr(ccxt, exchange_id)
self.exchange = exchange_class(config)
# 延迟加载 markets(首次使用时加载)
self._markets_loaded = False
self._markets_cache = None
def _ensure_markets_loaded(self) -> bool:
"""确保 markets 已加载(用于符号验证)"""
if self._markets_loaded and self._markets_cache is not None:
return True
try:
# 某些交易所需要显式加载 markets
if hasattr(self.exchange, 'load_markets'):
self.exchange.load_markets(reload=False)
self._markets_cache = getattr(self.exchange, 'markets', {})
self._markets_loaded = True
return True
except Exception as e:
logger.debug(f"Failed to load markets for {self.exchange.id}: {e}")
return False
def _normalize_symbol(self, symbol: str) -> Tuple[str, str]:
"""
规范化符号格式,返回 (normalized_symbol, base_currency)
处理各种输入格式:
- BTC/USDT -> BTC/USDT
- BTCUSDT -> BTC/USDT
- BTC/USDT:USDT -> BTC/USDT
- BTC -> BTC/USDT (默认)
- PI, TRX -> PI/USDT, TRX/USDT
"""
if not symbol:
return '', ''
sym = symbol.strip()
# 移除 swap/futures 后缀
if ':' in sym:
sym = sym.split(':', 1)[0]
sym = sym.upper()
# 如果已经有分隔符,直接解析
if '/' in sym:
parts = sym.split('/', 1)
base = parts[0].strip()
quote = parts[1].strip() if len(parts) > 1 else ''
if base and quote:
return f"{base}/{quote}", base
# 尝试从常见报价货币中识别
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
return f"{sym}/USDT", sym
def _find_valid_symbol(self, base: str, preferred_quote: str = 'USDT') -> Optional[str]:
"""
在交易所的 markets 中查找有效的符号
Args:
base: 基础货币(如 'PI', 'TRX'
preferred_quote: 首选的报价货币
Returns:
找到的有效符号,如果找不到则返回 None
"""
if not self._ensure_markets_loaded():
return None
markets = self._markets_cache or {}
if not markets:
return None
# 按优先级尝试不同的报价货币
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]
# 检查市场是否活跃
if market.get('active', True):
return candidate
return None
def _normalize_symbol_for_exchange(self, symbol: str) -> str:
"""
根据交易所特性规范化符号
不同交易所的符号格式要求:
- Binance: BTC/USDT (标准格式)
- OKX: BTC/USDT (标准格式,但某些币种可能不支持)
- Coinbase: BTC/USD (通常使用 USD 而不是 USDT)
- Kraken: XBT/USD (BTC 映射为 XBT)
- Bitfinex: tBTCUST (特殊格式)
"""
normalized, base = self._normalize_symbol(symbol)
if not normalized or not base:
return symbol
exchange_id = getattr(self.exchange, 'id', '').lower()
# 特殊处理:某些交易所的符号映射
if exchange_id == 'coinbase':
# Coinbase 通常使用 USD 而不是 USDT
if normalized.endswith('/USDT'):
usd_version = normalized.replace('/USDT', '/USD')
if self._ensure_markets_loaded():
markets = self._markets_cache or {}
if usd_version in markets:
return usd_version
# 尝试在交易所中查找有效符号
if self._ensure_markets_loaded():
valid_symbol = self._find_valid_symbol(base, normalized.split('/')[1] if '/' in normalized else 'USDT')
if valid_symbol:
return valid_symbol
return normalized
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
Get latest ticker for a crypto symbol via CCXT.
Accepts common formats:
- BTC/USDT, BTCUSDT, BTC/USDT:USDT
- PI, TRX (will be normalized and searched across exchanges)
- 自动适配不同交易所的符号格式要求
"""
if not symbol or not symbol.strip():
return {'last': 0, 'symbol': symbol}
# 规范化符号
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:
ticker = self.exchange.fetch_ticker(normalized)
if ticker and isinstance(ticker, dict):
return ticker
except Exception as e:
error_msg = str(e).lower()
is_symbol_error = any(keyword in error_msg for keyword in [
'does not have market symbol',
'symbol not found',
'invalid symbol',
'market does not exist',
'trading pair not found'
])
if is_symbol_error:
# 尝试查找替代符号
base = normalized.split('/')[0] if '/' in normalized else normalized
if self._ensure_markets_loaded():
valid_symbol = self._find_valid_symbol(base)
if valid_symbol and valid_symbol != normalized:
try:
logger.debug(f"Trying alternative symbol: {valid_symbol} (original: {symbol}, first attempt: {normalized})")
ticker = self.exchange.fetch_ticker(valid_symbol)
if ticker and isinstance(ticker, dict):
return ticker
except Exception as e2:
logger.debug(f"Alternative symbol {valid_symbol} also failed: {e2}")
# 如果所有尝试都失败,记录警告并返回默认值
logger.warning(
f"Symbol '{symbol}' (normalized: {normalized}) not found on {self.exchange.id}. "
f"Error: {str(e)[:100]}"
)
return {'last': 0, 'symbol': symbol}
def get_kline(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取加密货币K线数据"""
klines = []
try:
ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, '1d')
# 使用统一的符号规范化方法
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}")
ohlcv = self._fetch_ohlcv(symbol_pair, ccxt_timeframe, limit, before_time, timeframe)
if not ohlcv:
logger.warning(f"CCXT returned no K-lines: {symbol_pair}")
return []
# 转换数据格式
for candle in ohlcv:
if len(candle) < 6:
continue
klines.append(self.format_kline(
timestamp=int(candle[0] / 1000), # 毫秒转秒
open_price=candle[1],
high=candle[2],
low=candle[3],
close=candle[4],
volume=candle[5]
))
# 过滤和限制
klines = self.filter_and_limit(klines, limit, before_time)
# 记录结果
self.log_result(symbol, klines, timeframe)
except Exception as e:
logger.error(f"Failed to fetch crypto K-lines {symbol}: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return klines
def _fetch_ohlcv(
self,
symbol_pair: str,
ccxt_timeframe: str,
limit: int,
before_time: Optional[int],
timeframe: str
) -> List:
"""获取OHLCV数据(支持分页获取完整数据)"""
try:
if before_time:
# 计算时间范围
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}天")
# 分页获取数据,直到覆盖完整时间范围
all_ohlcv = []
batch_limit = 300 # Coinbase limit is often 300, safer than 1000
current_since = since
while current_since < end_ms:
batch = self.exchange.fetch_ohlcv(
symbol_pair,
ccxt_timeframe,
since=current_since,
limit=batch_limit
)
if not batch:
break
all_ohlcv.extend(batch)
# 获取最后一条数据的时间,作为下次请求的起始时间
last_timestamp = batch[-1][0]
# 如果最后一条数据时间超过了结束时间,或者返回数据少于请求量,说明已经获取完毕
# if last_timestamp >= end_ms or len(batch) < batch_limit:
if last_timestamp >= end_ms:
break
# 下次从最后一条的下一个时间点开始
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)}")
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} 条数据")
return ohlcv
except Exception as e:
logger.warning(f"CCXT fetch_ohlcv failed: {str(e)}; trying fallback")
return self._fetch_ohlcv_fallback(symbol_pair, ccxt_timeframe, limit, before_time, timeframe)
def _fetch_ohlcv_fallback(
self,
symbol_pair: str,
ccxt_timeframe: str,
limit: int,
before_time: Optional[int],
timeframe: str
) -> List:
"""备用获取方法"""
try:
total_seconds = self.calculate_time_range(timeframe, limit)
if before_time:
end_time = datetime.fromtimestamp(before_time)
start_time = end_time - timedelta(seconds=total_seconds)
since = int(start_time.timestamp() * 1000)
else:
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} 条数据")
return ohlcv
except Exception as e:
logger.error(f"CCXT fallback method also failed: {str(e)}")
return []