Files
DinQuant/backend_api_python/app/data_sources/forex.py
T
TIANHE 0b37aa4a67 V2.1.1
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-01-31 02:59:49 +08:00

479 lines
19 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.
"""
外汇数据源
使用 Tiingo 获取外汇数据
"""
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
import time
import requests
import threading
from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS
from app.utils.logger import get_logger
from app.config import TiingoConfig, APIKeys
logger = get_logger(__name__)
# 全局缓存 - 减少 Tiingo API 调用
_forex_cache: Dict[str, Dict[str, Any]] = {}
_forex_cache_lock = threading.Lock()
_FOREX_CACHE_TTL = 60 # 外汇价格缓存 60 秒 (Tiingo 免费 API 限制严格)
class ForexDataSource(BaseDataSource):
"""外汇数据源 (Tiingo)"""
name = "Forex/Tiingo"
# Tiingo resampleFreq 映射
# Tiingo 免费账户支持: 5min, 15min, 30min, 1hour, 4hour, 1day
# 注意: 1min 需要付费订阅, 1week/1month 不被 Tiingo FX API 支持
TIMEFRAME_MAP = {
'1m': '1min', # 需要付费订阅
'5m': '5min',
'15m': '15min',
'30m': '30min',
'1H': '1hour',
'4H': '4hour',
'1D': '1day',
'1W': None, # Tiingo 不支持,需要聚合
'1M': None # Tiingo 不支持,需要聚合
}
# 外汇对映射 (Tiingo 使用标准 ticker,如 eurusd, audusd)
# 大写也可以,Tiingo 通常不区分大小写,但建议统一
SYMBOL_MAP = {
# 贵金属 (Tiingo 不一定支持所有 OANDA 格式的贵金属,通常是 XAUUSD)
'XAUUSD': 'xauusd',
'XAGUSD': 'xagusd',
# 主要货币对
'EURUSD': 'eurusd',
'GBPUSD': 'gbpusd',
'USDJPY': 'usdjpy',
'AUDUSD': 'audusd',
'USDCAD': 'usdcad',
'USDCHF': 'usdchf',
'NZDUSD': 'nzdusd',
}
def __init__(self):
self.base_url = TiingoConfig.BASE_URL
if not APIKeys.TIINGO_API_KEY:
logger.warning("Tiingo API key is not configured; FX data will be unavailable")
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
获取外汇实时报价
使用 Tiingo FX Top-of-Book API 获取实时报价
带有 60 秒缓存以避免频繁触发 Tiingo 速率限制
Returns:
dict: {
'last': 当前价格 (mid price),
'bid': 买价,
'ask': 卖价,
'change': 涨跌额,
'changePercent': 涨跌幅
}
"""
api_key = APIKeys.TIINGO_API_KEY
if not api_key:
logger.warning("Tiingo API key not configured")
return {'last': 0, 'symbol': symbol}
# 检查缓存
cache_key = f"ticker_{symbol}"
with _forex_cache_lock:
cached = _forex_cache.get(cache_key)
if cached:
cache_time = cached.get('_cache_time', 0)
if time.time() - cache_time < _FOREX_CACHE_TTL:
logger.debug(f"Using cached forex ticker for {symbol}")
return cached
try:
# 解析 symbol
tiingo_symbol = self.SYMBOL_MAP.get(symbol)
if not tiingo_symbol:
tiingo_symbol = symbol.lower()
# Tiingo FX Top-of-Book API
# https://api.tiingo.com/tiingo/fx/top?tickers=eurusd&token=...
url = f"{self.base_url}/fx/top"
params = {
'tickers': tiingo_symbol,
'token': api_key
}
# 重试逻辑:处理 429 速率限制
for attempt in range(3):
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
if response.status_code == 429:
wait_time = 2 * (attempt + 1)
logger.warning(f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt+1}/3)")
time.sleep(wait_time)
continue
break
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")
# 返回缓存数据(如果有的话,即使已过期)
with _forex_cache_lock:
if cache_key in _forex_cache:
logger.info(f"Returning stale cache for {symbol} due to rate limit")
return _forex_cache[cache_key]
return {'last': 0, 'symbol': symbol}
response.raise_for_status()
data = response.json()
if data and isinstance(data, list) and len(data) > 0:
item = data[0]
# Tiingo FX top returns: ticker, quoteTimestamp, bidPrice, bidSize, askPrice, askSize, midPrice
bid = float(item.get('bidPrice', 0) or 0)
ask = float(item.get('askPrice', 0) or 0)
mid = float(item.get('midPrice', 0) or 0)
# 如果没有 midPrice,计算中间价
if not mid and bid and ask:
mid = (bid + ask) / 2
last_price = mid or bid or ask
# 获取前一天收盘价来计算涨跌(需要额外请求日线数据)
prev_close = 0
change = 0
change_pct = 0
try:
# 获取昨日收盘价
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"
price_params = {
'startDate': yesterday,
'endDate': today,
'resampleFreq': '1day',
'token': api_key
}
price_resp = requests.get(price_url, params=price_params, timeout=TiingoConfig.TIMEOUT)
if price_resp.status_code == 200:
price_data = price_resp.json()
if price_data and len(price_data) > 0:
prev_close = float(price_data[-1].get('close', 0) or 0)
if prev_close and last_price:
change = last_price - prev_close
change_pct = (change / prev_close) * 100
except Exception:
pass # 涨跌计算失败不影响主要功能
result = {
'last': round(last_price, 5),
'bid': round(bid, 5),
'ask': round(ask, 5),
'change': round(change, 5),
'changePercent': round(change_pct, 2),
'previousClose': round(prev_close, 5) if prev_close else 0,
'_cache_time': time.time()
}
# 缓存结果
with _forex_cache_lock:
_forex_cache[cache_key] = result
return result
except Exception as e:
logger.error(f"Failed to get forex ticker for {symbol}: {e}")
return {'last': 0, 'symbol': symbol}
def _get_timeframe_seconds(self, timeframe: str) -> int:
"""获取时间周期对应的秒数"""
return TIMEFRAME_SECONDS.get(timeframe, 86400)
def get_kline(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
获取外汇K线数据
Args:
symbol: 外汇对代码(如 XAUUSD, EURUSD
timeframe: 时间周期
limit: 数据条数
before_time: 结束时间戳
"""
# 动态获取 API Key
api_key = APIKeys.TIINGO_API_KEY
if not api_key:
logger.error("Tiingo API key is not configured")
return []
try:
# 1. 解析 Symbol
tiingo_symbol = self.SYMBOL_MAP.get(symbol)
if not tiingo_symbol:
# 尝试智能转换: EURUSD -> eurusd
tiingo_symbol = symbol.lower()
# 2. 解析 Resolution (resampleFreq)
resample_freq = self.TIMEFRAME_MAP.get(timeframe)
# 特殊处理:1W/1M 需要用日线聚合
aggregate_to_weekly = (timeframe == '1W')
aggregate_to_monthly = (timeframe == '1M')
original_limit = limit # 保存原始请求数量
if aggregate_to_weekly or aggregate_to_monthly:
# 用日线数据聚合
resample_freq = '1day'
# 限制周线/月线的最大请求数量(Tiingo 免费 API 有数据量限制)
# 周线最多请求 100 周 = 700 天 ≈ 2年
# 月线最多请求 36 月 = 1080 天 ≈ 3年
max_limit = 100 if aggregate_to_weekly else 36
original_limit = min(original_limit, max_limit)
# 需要更多日线数据来聚合(周线需要7天,月线需要30天)
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分钟数据需要付费订阅提示
if timeframe == '1m':
logger.info(f"Note: Tiingo 1-minute forex data requires a paid subscription")
# 3. 计算时间范围
if before_time:
end_dt = datetime.fromtimestamp(before_time)
else:
end_dt = datetime.now()
# 根据周期和数量计算开始时间
# 注意:聚合模式下使用日线秒数计算
if aggregate_to_weekly or aggregate_to_monthly:
tf_seconds = 86400 # 日线秒数
else:
tf_seconds = self._get_timeframe_seconds(timeframe)
# 多取一些缓冲时间(1.5倍,外汇周末不交易)
start_dt = end_dt - timedelta(seconds=limit * tf_seconds * 1.5)
# Tiingo 免费 API 最多支持约 5 年数据,限制最大时间范围
max_days = 365 * 3 # 最多 3 年
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 支持该格式)
start_date_str = start_dt.strftime('%Y-%m-%d')
end_date_str = end_dt.strftime('%Y-%m-%d')
# 4. API 请求(带重试逻辑)
# URL: https://api.tiingo.com/tiingo/fx/{ticker}/prices
url = f"{self.base_url}/fx/{tiingo_symbol}/prices"
params = {
'startDate': start_date_str,
'endDate': end_date_str,
'resampleFreq': resample_freq,
'token': api_key,
'format': 'json'
}
# logger.info(f"Tiingo Request: {url} params={params}")
# 重试逻辑:处理 429 速率限制
max_retries = 3
retry_delay = 2 # 秒
response = None
for attempt in range(max_retries):
try:
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
if response.status_code == 429:
# 速率限制,等待后重试
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 # 成功或其他错误,退出重试循环
except requests.exceptions.Timeout:
if attempt < max_retries - 1:
logger.warning(f"Tiingo request timeout, retrying ({attempt + 1}/{max_retries})")
time.sleep(retry_delay)
continue
raise
if response is None:
logger.error("Tiingo API request failed after all retries")
return []
if response.status_code == 429:
logger.error("Tiingo API rate limit exceeded. Please wait a moment before retrying.")
return []
if response.status_code == 403:
logger.error("Tiingo API permission error (403): check whether your API key is valid and has access to this dataset.")
return []
response.raise_for_status()
data = response.json()
# 5. 处理响应
# Tiingo returns a list of dicts:
# [
# {
# "date": "2023-01-01T00:00:00.000Z",
# "ticker": "eurusd",
# "open": 1.07,
# "high": 1.08,
# "low": 1.06,
# "close": 1.07
# "mid": ... (optional, depends on settings, usually OHLC are bid or mid)
# }, ...
# ]
# Note: Tiingo FX prices objects keys: date, open, high, low, close.
if not isinstance(data, list):
logger.warning(f"Tiingo response is not a list: {data}")
return []
klines = []
for item in data:
# 解析时间: "2023-01-01T00:00:00.000Z"
dt_str = item.get('date')
# Tiingo 返回的是 UTC 时间 ISO 格式,需要正确处理时区
# 将 UTC 时间转换为本地时间戳
if dt_str.endswith('Z'):
dt_str = dt_str[:-1] + '+00:00' # 替换 Z 为 +00:00 表示 UTC
dt = datetime.fromisoformat(dt_str)
ts = int(dt.timestamp()) # 现在会正确处理 UTC 时区
klines.append({
'time': ts,
'open': float(item.get('open')),
'high': float(item.get('high')),
'low': float(item.get('low')),
'close': float(item.get('close')),
'volume': 0.0 # Tiingo FX 通常没有 volume
})
# 按时间排序
klines.sort(key=lambda x: x['time'])
# 如果需要聚合到周线或月线
if aggregate_to_weekly:
klines = self._aggregate_to_weekly(klines)
logger.debug(f"Aggregated {len(klines)} weekly candles from daily data")
elif aggregate_to_monthly:
klines = self._aggregate_to_monthly(klines)
logger.debug(f"Aggregated {len(klines)} monthly candles from daily data")
# 过滤到原始请求数量
if len(klines) > original_limit:
klines = klines[-original_limit:]
# logger.info(f"获取到 {len(klines)} 条 Tiingo 外汇数据")
return klines
except requests.exceptions.RequestException as e:
logger.error(f"Tiingo API request failed: {e}")
return []
except Exception as e:
logger.error(f"Failed to process Tiingo data: {e}")
return []
def _aggregate_to_weekly(self, daily_klines: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""将日线数据聚合为周线"""
if not daily_klines:
return []
weekly_klines = []
current_week = None
week_data = None
for kline in daily_klines:
dt = datetime.fromtimestamp(kline['time'])
# 获取该日期所在周的周一
week_start = dt - timedelta(days=dt.weekday())
week_key = week_start.strftime('%Y-%W')
if week_key != current_week:
# 保存上一周的数据
if week_data:
weekly_klines.append(week_data)
# 开始新的一周
current_week = week_key
week_data = {
'time': int(week_start.timestamp()),
'open': kline['open'],
'high': kline['high'],
'low': kline['low'],
'close': kline['close'],
'volume': kline['volume']
}
else:
# 更新本周数据
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']
# 添加最后一周
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]]:
"""将日线数据聚合为月线"""
if not daily_klines:
return []
monthly_klines = []
current_month = None
month_data = None
for kline in daily_klines:
dt = datetime.fromtimestamp(kline['time'])
month_key = dt.strftime('%Y-%m')
if month_key != current_month:
# 保存上个月的数据
if month_data:
monthly_klines.append(month_data)
# 开始新的一月
current_month = month_key
month_start = dt.replace(day=1, hour=0, minute=0, second=0)
month_data = {
'time': int(month_start.timestamp()),
'open': kline['open'],
'high': kline['high'],
'low': kline['low'],
'close': kline['close'],
'volume': kline['volume']
}
else:
# 更新本月数据
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']
# 添加最后一月
if month_data:
monthly_klines.append(month_data)
return monthly_klines