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:
@@ -386,6 +386,94 @@ class AShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return klines
|
||||
|
||||
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取A股实时报价
|
||||
|
||||
使用东方财富实时行情API获取实时报价
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'last': 当前价格,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
'high': 最高价,
|
||||
'low': 最低价,
|
||||
'open': 开盘价,
|
||||
'previousClose': 昨收价
|
||||
}
|
||||
"""
|
||||
symbol = (symbol or '').strip()
|
||||
|
||||
# 优先使用东方财富实时行情 API
|
||||
try:
|
||||
# 判断市场
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}" # 上海
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
secid = f"0.{symbol}" # 深圳
|
||||
elif symbol.startswith('4') or symbol.startswith('8'):
|
||||
secid = f"0.{symbol}" # 北交所
|
||||
else:
|
||||
secid = f"1.{symbol}"
|
||||
|
||||
# 东方财富实时行情接口
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
|
||||
# f43=最新价, f44=最高价, f45=最低价, f46=开盘价
|
||||
# f60=昨收价, f169=涨跌额, f170=涨跌幅
|
||||
}
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and data.get('data'):
|
||||
d = data['data']
|
||||
last_price = d.get('f43', 0)
|
||||
# 东方财富返回的价格是整数(分),需要除以100
|
||||
if last_price and last_price > 0:
|
||||
divisor = 100 if last_price > 1000 else 1 # 价格超过10元时用分表示
|
||||
return {
|
||||
'last': last_price / divisor,
|
||||
'high': d.get('f44', 0) / divisor,
|
||||
'low': d.get('f45', 0) / divisor,
|
||||
'open': d.get('f46', 0) / divisor,
|
||||
'previousClose': d.get('f60', 0) / divisor,
|
||||
'change': d.get('f169', 0) / divisor,
|
||||
'changePercent': d.get('f170', 0) / 100 # 涨跌幅是整数(%*100)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
|
||||
|
||||
# 降级使用腾讯实时报价
|
||||
try:
|
||||
tencent_symbol = self._to_tencent_symbol(symbol)
|
||||
if tencent_symbol:
|
||||
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
|
||||
response = requests.get(url, timeout=10)
|
||||
content = response.content.decode('gbk', errors='ignore')
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
'last': float(parts[3]) if parts[3] else 0,
|
||||
'change': float(parts[31]) if parts[31] else 0,
|
||||
'changePercent': float(parts[32]) if parts[32] else 0,
|
||||
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
'previousClose': float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
|
||||
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
|
||||
|
||||
class HShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
@@ -613,3 +701,79 @@ class HShareDataSource(BaseDataSource, TencentDataMixin):
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return klines
|
||||
|
||||
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取港股实时报价
|
||||
|
||||
使用腾讯财经实时行情API获取实时报价
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'last': 当前价格,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
'high': 最高价,
|
||||
'low': 最低价,
|
||||
'open': 开盘价,
|
||||
'previousClose': 昨收价
|
||||
}
|
||||
"""
|
||||
symbol = (symbol or '').strip()
|
||||
|
||||
# 使用腾讯财经实时报价
|
||||
try:
|
||||
tencent_symbol = self._to_tencent_symbol(symbol)
|
||||
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
|
||||
response = requests.get(url, timeout=10)
|
||||
content = response.content.decode('gbk', errors='ignore')
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
'last': float(parts[3]) if parts[3] else 0,
|
||||
'change': float(parts[31]) if parts[31] else 0,
|
||||
'changePercent': float(parts[32]) if parts[32] else 0,
|
||||
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
'previousClose': float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
|
||||
|
||||
# 降级使用东方财富
|
||||
try:
|
||||
hk_symbol = symbol.zfill(5)
|
||||
secid = f"116.{hk_symbol}"
|
||||
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
|
||||
}
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and data.get('data'):
|
||||
d = data['data']
|
||||
last_price = d.get('f43', 0)
|
||||
if last_price and last_price > 0:
|
||||
divisor = 1000 if last_price > 10000 else 100 if last_price > 1000 else 1
|
||||
return {
|
||||
'last': last_price / divisor,
|
||||
'high': d.get('f44', 0) / divisor,
|
||||
'low': d.get('f45', 0) / divisor,
|
||||
'open': d.get('f46', 0) / divisor,
|
||||
'previousClose': d.get('f60', 0) / divisor,
|
||||
'change': d.get('f169', 0) / divisor,
|
||||
'changePercent': d.get('f170', 0) / 100
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
|
||||
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
|
||||
@@ -103,4 +103,31 @@ class DataSourceFactory:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch K-lines {market}:{symbol} - {str(e)}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_ticker(cls, market: str, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取实时报价的便捷方法
|
||||
|
||||
Args:
|
||||
market: 市场类型
|
||||
symbol: 交易对/股票代码
|
||||
|
||||
Returns:
|
||||
实时报价数据: {
|
||||
'last': 最新价,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
source = cls.get_source(market)
|
||||
return source.get_ticker(symbol)
|
||||
except NotImplementedError:
|
||||
logger.warning(f"get_ticker not implemented for market: {market}")
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch ticker {market}:{symbol} - {str(e)}")
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -156,11 +156,14 @@ class FuturesDataSource(BaseDataSource):
|
||||
tf_seconds = self._get_timeframe_seconds(timeframe)
|
||||
start_time = end_time - timedelta(seconds=tf_seconds * limit * 1.5)
|
||||
|
||||
# yfinance 的 end 参数是不包含的(exclusive),需要加一天
|
||||
end_time_inclusive = end_time + timedelta(days=1)
|
||||
|
||||
# 获取数据
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
df = ticker.history(
|
||||
start=start_time,
|
||||
end=end_time,
|
||||
end=end_time_inclusive,
|
||||
interval=yf_interval
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +54,114 @@ class USStockDataSource(BaseDataSource):
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub init failed: {e}")
|
||||
|
||||
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取美股实时报价
|
||||
|
||||
优先使用 Finnhub(更实时),降级使用 yfinance fast_info
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'last': 当前价格,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
'high': 最高价,
|
||||
'low': 最低价,
|
||||
'open': 开盘价,
|
||||
'previousClose': 昨收价
|
||||
}
|
||||
"""
|
||||
symbol = (symbol or '').strip().upper()
|
||||
|
||||
# 优先使用 Finnhub(实时数据)
|
||||
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) # 昨收价
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub quote failed for {symbol}: {e}")
|
||||
|
||||
# 降级使用 yfinance
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
|
||||
# 尝试 fast_info(更快)
|
||||
try:
|
||||
fast_info = ticker.fast_info
|
||||
last_price = fast_info.get('lastPrice') or fast_info.get('last_price')
|
||||
prev_close = fast_info.get('previousClose') or fast_info.get('previous_close') or fast_info.get('regularMarketPreviousClose')
|
||||
|
||||
if last_price:
|
||||
change = (last_price - prev_close) if prev_close else 0
|
||||
change_pct = (change / prev_close * 100) if prev_close else 0
|
||||
return {
|
||||
'last': float(last_price),
|
||||
'change': round(change, 4),
|
||||
'changePercent': round(change_pct, 2),
|
||||
'high': float(fast_info.get('dayHigh') or fast_info.get('day_high') or last_price),
|
||||
'low': float(fast_info.get('dayLow') or fast_info.get('day_low') or last_price),
|
||||
'open': float(fast_info.get('open') or fast_info.get('regularMarketOpen') or last_price),
|
||||
'previousClose': float(prev_close) if prev_close else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"yfinance fast_info failed for {symbol}: {e}")
|
||||
|
||||
# 降级使用 info(较慢但数据更全)
|
||||
try:
|
||||
info = ticker.info
|
||||
last_price = info.get('regularMarketPrice') or info.get('currentPrice')
|
||||
prev_close = info.get('regularMarketPreviousClose') or info.get('previousClose')
|
||||
|
||||
if last_price:
|
||||
change = (last_price - prev_close) if prev_close else 0
|
||||
change_pct = (change / prev_close * 100) if prev_close else 0
|
||||
return {
|
||||
'last': float(last_price),
|
||||
'change': round(change, 4),
|
||||
'changePercent': round(change_pct, 2),
|
||||
'high': float(info.get('regularMarketDayHigh') or info.get('dayHigh') or last_price),
|
||||
'low': float(info.get('regularMarketDayLow') or info.get('dayLow') or last_price),
|
||||
'open': float(info.get('regularMarketOpen') or info.get('open') or last_price),
|
||||
'previousClose': float(prev_close) if prev_close else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"yfinance info failed for {symbol}: {e}")
|
||||
|
||||
# 最后降级:使用最近的 1 分钟 K 线
|
||||
try:
|
||||
hist = ticker.history(period='1d', interval='1m')
|
||||
if hist is not None and not hist.empty:
|
||||
last_row = hist.iloc[-1]
|
||||
first_row = hist.iloc[0]
|
||||
last_price = float(last_row['Close'])
|
||||
open_price = float(first_row['Open'])
|
||||
|
||||
return {
|
||||
'last': last_price,
|
||||
'change': round(last_price - open_price, 4),
|
||||
'changePercent': round((last_price - open_price) / open_price * 100, 2) if open_price else 0,
|
||||
'high': float(hist['High'].max()),
|
||||
'low': float(hist['Low'].min()),
|
||||
'open': open_price,
|
||||
'previousClose': open_price # 近似
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"yfinance history fallback failed for {symbol}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get ticker for {symbol}: {e}")
|
||||
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
@@ -108,9 +216,14 @@ class USStockDataSource(BaseDataSource):
|
||||
"""使用 yfinance 获取数据"""
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
|
||||
# yfinance 的 end 参数是不包含的(exclusive),所以需要加一天才能包含 end_date 当天的数据
|
||||
# 例如:end="2026-01-12" 实际只返回到 2026-01-11 的数据
|
||||
end_date_inclusive = end_date + timedelta(days=1)
|
||||
|
||||
df = ticker.history(
|
||||
start=start_date.strftime('%Y-%m-%d'),
|
||||
end=end_date.strftime('%Y-%m-%d'),
|
||||
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} 条数据")
|
||||
|
||||
@@ -6,6 +6,7 @@ from flask import Blueprint, request, jsonify
|
||||
import json
|
||||
import traceback
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from app.services.kline import KlineService
|
||||
@@ -22,7 +23,15 @@ kline_service = KlineService()
|
||||
cache = CacheManager()
|
||||
|
||||
# Thread pool for parallel price fetching
|
||||
executor = ThreadPoolExecutor(max_workers=10)
|
||||
# 降低并发数避免触发API限制(尤其是外汇/美股等有速率限制的API)
|
||||
executor = ThreadPoolExecutor(max_workers=3)
|
||||
|
||||
# 请求间隔(秒),避免请求过快
|
||||
REQUEST_INTERVAL = 0.3
|
||||
|
||||
# 速率限制相关
|
||||
_request_lock = threading.Lock()
|
||||
_last_request_time = {} # {market: timestamp}
|
||||
|
||||
DEFAULT_USER_ID = 1
|
||||
|
||||
@@ -51,49 +60,40 @@ def _safe_json_loads(value, default=None):
|
||||
return default
|
||||
|
||||
|
||||
def _get_single_price(market: str, symbol: str) -> dict:
|
||||
"""Get price data for a single symbol."""
|
||||
def _get_single_price(market: str, symbol: str, force_refresh: bool = False) -> dict:
|
||||
"""
|
||||
Get price data for a single symbol.
|
||||
|
||||
优先使用实时报价 API(ticker),降级使用分钟/日线 K 线数据。
|
||||
这样可以在交易时段获取更实时的价格,而不是只显示日线收盘价。
|
||||
|
||||
内置速率限制:同一市场的请求间隔至少 REQUEST_INTERVAL 秒,
|
||||
避免触发 API 限制(如 yfinance、Tiingo、Finnhub 等)。
|
||||
|
||||
Args:
|
||||
force_refresh: 是否强制刷新(跳过缓存)
|
||||
"""
|
||||
try:
|
||||
cache_key = f"portfolio_price:{market}:{symbol}"
|
||||
cached_data = cache.get(cache_key)
|
||||
# 速率限制:同一市场的请求间隔
|
||||
with _request_lock:
|
||||
now = time.time()
|
||||
last_time = _last_request_time.get(market, 0)
|
||||
wait_time = REQUEST_INTERVAL - (now - last_time)
|
||||
if wait_time > 0:
|
||||
time.sleep(wait_time)
|
||||
_last_request_time[market] = time.time()
|
||||
|
||||
if cached_data:
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': cached_data.get('price', 0),
|
||||
'change': cached_data.get('change', 0),
|
||||
'changePercent': cached_data.get('changePercent', 0)
|
||||
}
|
||||
# 使用新的 get_realtime_price 方法获取实时价格
|
||||
price_data = kline_service.get_realtime_price(market, symbol, force_refresh=force_refresh)
|
||||
|
||||
klines = kline_service.get_kline(market, symbol, '1D', 2)
|
||||
|
||||
if klines and len(klines) > 0:
|
||||
latest = klines[-1]
|
||||
prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0)
|
||||
current_price = latest.get('close', 0)
|
||||
|
||||
change = round(current_price - prev_close, 4) if prev_close else 0
|
||||
change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0
|
||||
|
||||
result = {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': current_price,
|
||||
'change': change,
|
||||
'changePercent': change_percent
|
||||
}
|
||||
|
||||
cache.set(cache_key, result, 60)
|
||||
return result
|
||||
else:
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': 0,
|
||||
'change': 0,
|
||||
'changePercent': 0
|
||||
}
|
||||
return {
|
||||
'market': market,
|
||||
'symbol': symbol,
|
||||
'price': price_data.get('price', 0),
|
||||
'change': price_data.get('change', 0),
|
||||
'changePercent': price_data.get('changePercent', 0),
|
||||
'source': price_data.get('source', 'unknown') # 记录数据来源,便于调试
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}")
|
||||
return {
|
||||
@@ -101,7 +101,8 @@ def _get_single_price(market: str, symbol: str) -> dict:
|
||||
'symbol': symbol,
|
||||
'price': 0,
|
||||
'change': 0,
|
||||
'changePercent': 0
|
||||
'changePercent': 0,
|
||||
'source': 'error'
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +112,9 @@ def _get_single_price(market: str, symbol: str) -> dict:
|
||||
def get_positions():
|
||||
"""Get all manual positions with current prices."""
|
||||
try:
|
||||
# 检查是否强制刷新(跳过缓存)
|
||||
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -155,13 +159,13 @@ def get_positions():
|
||||
}
|
||||
positions.append(pos)
|
||||
|
||||
# Submit price fetch task
|
||||
# Submit price fetch task (with force_refresh support)
|
||||
market = row.get('market')
|
||||
symbol = row.get('symbol')
|
||||
if market and symbol:
|
||||
key = f"{market}:{symbol}"
|
||||
if key not in price_futures:
|
||||
future = executor.submit(_get_single_price, market, symbol)
|
||||
future = executor.submit(_get_single_price, market, symbol, force_refresh)
|
||||
price_futures[key] = future
|
||||
|
||||
# Collect price results
|
||||
@@ -364,6 +368,9 @@ def delete_position(position_id):
|
||||
def get_portfolio_summary():
|
||||
"""Get portfolio summary with total value, PnL, and market distribution."""
|
||||
try:
|
||||
# 检查是否强制刷新
|
||||
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -391,14 +398,14 @@ def get_portfolio_summary():
|
||||
}
|
||||
})
|
||||
|
||||
# Fetch prices in parallel
|
||||
# Fetch prices in parallel (with force_refresh support)
|
||||
price_futures = {}
|
||||
for row in rows:
|
||||
market = row.get('market')
|
||||
symbol = row.get('symbol')
|
||||
key = f"{market}:{symbol}"
|
||||
if key not in price_futures:
|
||||
future = executor.submit(_get_single_price, market, symbol)
|
||||
future = executor.submit(_get_single_price, market, symbol, force_refresh)
|
||||
price_futures[key] = future
|
||||
|
||||
price_map = {}
|
||||
|
||||
@@ -65,9 +65,126 @@ class KlineService:
|
||||
return klines
|
||||
|
||||
def get_latest_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取最新价格"""
|
||||
"""获取最新价格(使用1分钟K线,已弃用,建议使用 get_realtime_price)"""
|
||||
klines = self.get_kline(market, symbol, '1m', 1)
|
||||
if klines:
|
||||
return klines[-1]
|
||||
return None
|
||||
|
||||
def get_realtime_price(self, market: str, symbol: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
获取实时价格(优先使用 ticker API,降级使用分钟 K 线)
|
||||
|
||||
Args:
|
||||
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
|
||||
symbol: 交易对/股票代码
|
||||
force_refresh: 是否强制刷新(跳过缓存)
|
||||
|
||||
Returns:
|
||||
实时价格数据: {
|
||||
'price': 最新价格,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
'high': 最高价,
|
||||
'low': 最低价,
|
||||
'open': 开盘价,
|
||||
'previousClose': 昨收价,
|
||||
'source': 数据来源 ('ticker' 或 'kline')
|
||||
}
|
||||
"""
|
||||
# 构建缓存键(短时间缓存,避免频繁请求)
|
||||
cache_key = f"realtime_price:{market}:{symbol}"
|
||||
|
||||
# 如果不是强制刷新,尝试使用缓存
|
||||
if not force_refresh:
|
||||
cached = self.cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
result = {
|
||||
'price': 0,
|
||||
'change': 0,
|
||||
'changePercent': 0,
|
||||
'high': 0,
|
||||
'low': 0,
|
||||
'open': 0,
|
||||
'previousClose': 0,
|
||||
'source': 'unknown'
|
||||
}
|
||||
|
||||
# 优先尝试使用 ticker API 获取实时价格
|
||||
try:
|
||||
ticker = DataSourceFactory.get_ticker(market, symbol)
|
||||
if ticker and ticker.get('last', 0) > 0:
|
||||
result = {
|
||||
'price': ticker.get('last', 0),
|
||||
'change': ticker.get('change', 0),
|
||||
'changePercent': ticker.get('changePercent', 0),
|
||||
'high': ticker.get('high', 0),
|
||||
'low': ticker.get('low', 0),
|
||||
'open': ticker.get('open', 0),
|
||||
'previousClose': ticker.get('previousClose', 0),
|
||||
'source': 'ticker'
|
||||
}
|
||||
# 缓存 30 秒
|
||||
self.cache.set(cache_key, result, 30)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"Ticker API failed for {market}:{symbol}, falling back to kline: {e}")
|
||||
|
||||
# 降级:使用 1 分钟 K 线
|
||||
try:
|
||||
klines = self.get_kline(market, symbol, '1m', 2)
|
||||
if klines and len(klines) > 0:
|
||||
latest = klines[-1]
|
||||
prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0)
|
||||
current_price = latest.get('close', 0)
|
||||
|
||||
change = round(current_price - prev_close, 4) if prev_close else 0
|
||||
change_pct = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0
|
||||
|
||||
result = {
|
||||
'price': current_price,
|
||||
'change': change,
|
||||
'changePercent': change_pct,
|
||||
'high': latest.get('high', 0),
|
||||
'low': latest.get('low', 0),
|
||||
'open': latest.get('open', 0),
|
||||
'previousClose': prev_close,
|
||||
'source': 'kline_1m'
|
||||
}
|
||||
# 缓存 30 秒
|
||||
self.cache.set(cache_key, result, 30)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"1m kline failed for {market}:{symbol}, trying daily: {e}")
|
||||
|
||||
# 最后降级:使用日线数据(适用于非交易时间)
|
||||
try:
|
||||
klines = self.get_kline(market, symbol, '1D', 2)
|
||||
if klines and len(klines) > 0:
|
||||
latest = klines[-1]
|
||||
prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0)
|
||||
current_price = latest.get('close', 0)
|
||||
|
||||
change = round(current_price - prev_close, 4) if prev_close else 0
|
||||
change_pct = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0
|
||||
|
||||
result = {
|
||||
'price': current_price,
|
||||
'change': change,
|
||||
'changePercent': change_pct,
|
||||
'high': latest.get('high', 0),
|
||||
'low': latest.get('low', 0),
|
||||
'open': latest.get('open', 0),
|
||||
'previousClose': prev_close,
|
||||
'source': 'kline_1d'
|
||||
}
|
||||
# 日线数据缓存 5 分钟
|
||||
self.cache.set(cache_key, result, 300)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"All price sources failed for {market}:{symbol}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -116,12 +116,11 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str,
|
||||
quantity = float(row.get('quantity') or 0)
|
||||
side = row.get('side') or 'long'
|
||||
|
||||
# Get current price
|
||||
# Get current price (use realtime price API)
|
||||
current_price = 0
|
||||
try:
|
||||
klines = kline_service.get_kline(market, symbol, '1D', 1)
|
||||
if klines:
|
||||
current_price = float(klines[-1].get('close') or 0)
|
||||
price_data = kline_service.get_realtime_price(market, symbol)
|
||||
current_price = float(price_data.get('price') or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -982,12 +981,11 @@ def _check_position_alerts():
|
||||
if not can_trigger:
|
||||
continue
|
||||
|
||||
# Get current price
|
||||
# Get current price (use realtime price API)
|
||||
current_price = 0
|
||||
try:
|
||||
klines = kline_service.get_kline(market, symbol, '1D', 1)
|
||||
if klines:
|
||||
current_price = float(klines[-1].get('close') or 0)
|
||||
price_data = kline_service.get_realtime_price(market, symbol)
|
||||
current_price = float(price_data.get('price') or 0)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
Reference in New Issue
Block a user