feat: add real-time price fetching and fix data delays

- Add get_ticker() method for real-time quotes across all markets

- Add get_realtime_price() service with ticker/kline fallback chain

- Fix yfinance end date issue for US stocks and futures

- Fix forex timezone parsing for Tiingo UTC timestamps

- Add retry mechanism with exponential backoff for Tiingo API

- Add API rate limiting for portfolio (3 concurrent, 0.3s interval)

- Add force refresh option to bypass price cache on manual refresh
This commit is contained in:
TIANHE
2026-01-13 00:21:33 +08:00
parent ac932aebf0
commit 714dd47c86
10 changed files with 643 additions and 78 deletions
+143 -10
View File
@@ -55,6 +55,109 @@ class ForexDataSource(BaseDataSource):
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 获取实时报价
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}
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:
time.sleep(2 * (attempt + 1))
continue
break
if response.status_code == 429:
logger.warning("Tiingo rate limit exceeded for ticker request")
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 # 涨跌计算失败不影响主要功能
return {
'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
}
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)
@@ -140,7 +243,7 @@ class ForexDataSource(BaseDataSource):
start_date_str = start_dt.strftime('%Y-%m-%d')
end_date_str = end_dt.strftime('%Y-%m-%d')
# 4. API 请求
# 4. API 请求(带重试逻辑)
# URL: https://api.tiingo.com/tiingo/fx/{ticker}/prices
url = f"{self.base_url}/fx/{tiingo_symbol}/prices"
@@ -154,11 +257,42 @@ class ForexDataSource(BaseDataSource):
# logger.info(f"Tiingo Request: {url} params={params}")
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
# 重试逻辑:处理 429 速率限制
max_retries = 3
retry_delay = 2 # 秒
response = None
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 []
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()
@@ -186,14 +320,13 @@ class ForexDataSource(BaseDataSource):
for item in data:
# 解析时间: "2023-01-01T00:00:00.000Z"
dt_str = item.get('date')
# 简化处理,Tiingo 返回的是 UTC 时间 ISO 格式
# datetime.fromisoformat 在 Py3.7+ 支持,但要注意 Z 的处理
# 这里简单处理一下 Z
# Tiingo 返回的是 UTC 时间 ISO 格式,需要正确处理时区
# 将 UTC 时间转换为本地时间戳
if dt_str.endswith('Z'):
dt_str = dt_str[:-1]
dt_str = dt_str[:-1] + '+00:00' # 替换 Z 为 +00:00 表示 UTC
dt = datetime.fromisoformat(dt_str)
ts = int(dt.timestamp())
ts = int(dt.timestamp()) # 现在会正确处理 UTC 时区
klines.append({
'time': ts,