From 4f9e0231bd244e8e48f51fb8550db67d9279632f Mon Sep 17 00:00:00 2001 From: aszerW Date: Wed, 3 Jun 2026 09:14:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(datasource):=20yfinance=E6=97=B6=E5=8C=BA?= =?UTF-8?q?=E6=A0=87=E5=87=86=E5=8C=96=E4=B8=8ENaN=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yfinance_source.py: 用 tz_localize(None) 替代 pd.to_datetime(utc=True), 避免亚洲/欧洲市场因UTC转换导致日期回退一天(如日经225 5/25→5/24) - yfinance_source.py: 新增 _normalize_index() 静态方法统一处理时区剥除 - yfinance_source.py: fetch() 增加 close=NaN 行过滤(yfinance未收盘日返回不完整数据) - flask_api_source.py: 客户端同步增加 close=NaN 过滤防御 验证结果:N225 5/25-6/3 返回7个交易日数据,日期无偏移 --- datasource/flask_api_source.py | 9 +++++++++ datasource/yfinance_source.py | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/datasource/flask_api_source.py b/datasource/flask_api_source.py index 81902b9..5a7c704 100644 --- a/datasource/flask_api_source.py +++ b/datasource/flask_api_source.py @@ -167,6 +167,15 @@ class FlaskAPIDataSource: standard_cols = ['code'] + standard_cols df = df[standard_cols] + # 过滤 yfinance 返回的不完整数据(未收盘日 close=NaN, volume=0) + nan_count = df['close'].isna().sum() + if nan_count > 0: + df = df.dropna(subset=['close']) + actual_count = len(df) + if actual_count == 0: + print(f"⚠ {code}: 所有数据 close 均为 NaN") + return None + # 使用 API 返回的实际数据范围(而非请求参数) actual_start = validated.date_range.start if validated.date_range else start_date actual_end = validated.date_range.end if validated.date_range else end_date diff --git a/datasource/yfinance_source.py b/datasource/yfinance_source.py index 40054b1..1123e28 100644 --- a/datasource/yfinance_source.py +++ b/datasource/yfinance_source.py @@ -44,6 +44,25 @@ class YFinanceSource: self.use_ssh_tunnel = use_ssh_tunnel self._delay = 0.5 # 请求延迟(避免限流) + @staticmethod + def _normalize_index(index: pd.DatetimeIndex) -> pd.DatetimeIndex: + """ + 标准化日期索引,保留交易所本地日期 + + yfinance 返回的时间戳带有交易所本地时区: + - 美股: 00:00-04:00 (US/Eastern) → 剥 tz 后 00:00,日期不变 + - 日本: 00:00+09:00 (Asia/Tokyo) → 剥 tz 后 00:00,日期不变 + - 欧洲: 00:00+02:00 (CET) → 剥 tz 后 00:00,日期不变 + + 关键:直接 tz_localize(None) 剥除时区,不做 UTC 转换。 + 错误示范:pd.to_datetime(idx, utc=True) 会先把日本 00:00+09:00 转成 + 前一天 15:00 UTC,导致日期回退一天。 + """ + if index.tz is not None: + # tz_localize(None) 直接剥除时区,保留本地时间部分 + index = index.tz_localize(None) + return index.normalize() + def fetch(self, code: str, start_date: str, end_date: str, adj: str = 'raw') -> Optional[pd.DataFrame]: """ 获取数据(支持 adj 参数) @@ -108,10 +127,17 @@ class YFinanceSource: "Volume": "volume", }) - # 确保索引是日期格式 - df.index = pd.to_datetime(df.index, utc=True).tz_localize(None).normalize() + # 确保索引是日期格式(保留交易所本地日期,避免 UTC 转换导致跨日偏移) + df.index = self._normalize_index(df.index) df.index.name = "date" + # 过滤 yfinance 返回的不完整数据(未收盘日 close=NaN, volume=0) + nan_mask = df['close'].isna() + if nan_mask.any(): + df = df[~nan_mask] + if len(df) == 0: + return None + # 添加代码列 df["code"] = code @@ -189,8 +215,8 @@ class YFinanceSource: "Volume": "volume", }) - # 确保索引是日期格式 - df.index = pd.to_datetime(df.index, utc=True).tz_localize(None).normalize() + # 确保索引是日期格式(保留交易所本地日期,避免 UTC 转换导致跨日偏移) + df.index = self._normalize_index(df.index) df.index.name = "date" # 添加代码列和标记