diff --git a/backend_api_python/app/data_sources/cn_stock.py b/backend_api_python/app/data_sources/cn_stock.py index bb6d603..802f5e2 100644 --- a/backend_api_python/app/data_sources/cn_stock.py +++ b/backend_api_python/app/data_sources/cn_stock.py @@ -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} diff --git a/backend_api_python/app/data_sources/factory.py b/backend_api_python/app/data_sources/factory.py index 4cc7220..0f70e4a 100644 --- a/backend_api_python/app/data_sources/factory.py +++ b/backend_api_python/app/data_sources/factory.py @@ -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} diff --git a/backend_api_python/app/data_sources/forex.py b/backend_api_python/app/data_sources/forex.py index 3bcd4bb..c2f572e 100644 --- a/backend_api_python/app/data_sources/forex.py +++ b/backend_api_python/app/data_sources/forex.py @@ -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, diff --git a/backend_api_python/app/data_sources/futures.py b/backend_api_python/app/data_sources/futures.py index 15775aa..29ff142 100644 --- a/backend_api_python/app/data_sources/futures.py +++ b/backend_api_python/app/data_sources/futures.py @@ -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 ) diff --git a/backend_api_python/app/data_sources/us_stock.py b/backend_api_python/app/data_sources/us_stock.py index d38256a..2ac1e77 100644 --- a/backend_api_python/app/data_sources/us_stock.py +++ b/backend_api_python/app/data_sources/us_stock.py @@ -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} 条数据") diff --git a/backend_api_python/app/routes/portfolio.py b/backend_api_python/app/routes/portfolio.py index 32d7b26..8209fd7 100644 --- a/backend_api_python/app/routes/portfolio.py +++ b/backend_api_python/app/routes/portfolio.py @@ -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 = {} diff --git a/backend_api_python/app/services/kline.py b/backend_api_python/app/services/kline.py index 50ab567..5fefa62 100644 --- a/backend_api_python/app/services/kline.py +++ b/backend_api_python/app/services/kline.py @@ -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 diff --git a/backend_api_python/app/services/portfolio_monitor.py b/backend_api_python/app/services/portfolio_monitor.py index 59760aa..05b7989 100644 --- a/backend_api_python/app/services/portfolio_monitor.py +++ b/backend_api_python/app/services/portfolio_monitor.py @@ -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 diff --git a/quantdinger_vue/src/api/portfolio.js b/quantdinger_vue/src/api/portfolio.js index 9462366..732508e 100644 --- a/quantdinger_vue/src/api/portfolio.js +++ b/quantdinger_vue/src/api/portfolio.js @@ -5,10 +5,11 @@ import request from '@/utils/request' // ==================== Positions ==================== -export function getPositions () { +export function getPositions (params = {}) { return request({ url: '/api/portfolio/positions', - method: 'get' + method: 'get', + params }) } @@ -35,10 +36,11 @@ export function deletePosition (id) { }) } -export function getPortfolioSummary () { +export function getPortfolioSummary (params = {}) { return request({ url: '/api/portfolio/summary', - method: 'get' + method: 'get', + params }) } diff --git a/quantdinger_vue/src/views/portfolio/index.vue b/quantdinger_vue/src/views/portfolio/index.vue index 5c99045..6f697c7 100644 --- a/quantdinger_vue/src/views/portfolio/index.vue +++ b/quantdinger_vue/src/views/portfolio/index.vue @@ -1076,14 +1076,14 @@ export default { filterByGroup () { // Filter is handled by computed property }, - // 刷新价格 + // 刷新价格(强制刷新,跳过缓存) async refreshPrices () { if (this.isSyncing) return this.isSyncing = true try { await Promise.all([ - this.loadPositions(), - this.loadSummary() + this.loadPositions(true), // 强制刷新 + this.loadSummary(true) // 强制刷新 ]) this.lastSyncTime = new Date() } finally { @@ -1146,10 +1146,11 @@ export default { ] } }, - async loadPositions () { + async loadPositions (forceRefresh = false) { this.loadingPositions = true try { - const res = await getPositions() + const params = forceRefresh ? { refresh: '1' } : {} + const res = await getPositions(params) if (res && res.code === 1) { this.positions = res.data || [] } @@ -1159,9 +1160,9 @@ export default { this.loadingPositions = false } }, - async loadSummary () { + async loadSummary (forceRefresh = false) { try { - const res = await getPortfolioSummary() + const res = await getPortfolioSummary(forceRefresh ? { refresh: '1' } : {}) if (res && res.code === 1) { this.summary = res.data || this.summary }