From 40908b51f87513cc528cd5e3e89952619ba566d6 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Mon, 24 Aug 2026 13:49:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(live):=20=E5=B8=82=E5=80=BC=E6=94=B9?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E4=BC=B0=E5=80=BC=E5=A7=94=E6=89=98=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=BC=80=E7=9B=98=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E7=9B=98=E4=B8=ADCapital=E4=B8=8B=E8=BD=BD(=E7=AD=96=E7=95=A5s?= =?UTF-8?q?ession=2008-24=E7=A7=BB=E4=BA=A4P0)=E2=80=94=E2=80=94=E5=AE=9E?= =?UTF-8?q?=E9=94=A4:=E5=BC=80=E7=9B=98miniQMT=20Capital=E8=A1=A8=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=B8=B8=E8=B6=85=E6=97=B6(=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E8=87=AA=E6=9B=9Dtrading=20hours=E5=B8=B8?= =?UTF-8?q?=E8=B6=85=E6=97=B6)=E2=86=92close=C3=97total=5Fcapital=3DNaN?= =?UTF-8?q?=E2=86=92=E7=AD=96=E7=95=A5sort=5Fvalues=E6=97=A0=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E4=BF=9D=E6=8C=81=E4=BB=A3=E7=A0=81=E5=BA=8F,?= =?UTF-8?q?=E5=B9=B3=E5=AE=89=E9=93=B6=E8=A1=8C(=E2=89=883800=E4=BA=BF)?= =?UTF-8?q?=E6=B7=B7=E8=BF=9Bsmall=5Fcap=E4=B9=B0=E5=85=A5=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E3=80=82=E4=BF=AE=3Dget=5Ffundamentals=5Fdf=E7=BB=84?= =?UTF-8?q?=E8=A3=85=E5=90=8E=E5=A2=9EStep3.5:=5Fapply=5Flocal=5Fmarket=5F?= =?UTF-8?q?caps=E6=8A=8Amarket=5Fcap/circulating=5Fmarket=5Fcap=E4=BC=98?= =?UTF-8?q?=E5=85=88=E5=A7=94=E6=89=98self.=5Funified.get=5Ffundamentals?= =?UTF-8?q?=5Fdf(fields=3D=E4=B8=A4=E5=88=97,=E4=BC=B0=E5=80=BCparquet=20E?= =?UTF-8?q?OD,=E4=BA=BF=E5=85=83=E5=90=8C=E5=8D=95=E4=BD=8D,=E5=85=A8?= =?UTF-8?q?=E6=B1=A0=E5=90=8C=E4=B8=80=E6=97=B6=E7=82=B9=E5=8F=A3=E5=BE=84?= =?UTF-8?q?);=E5=8D=95=E8=82=A1=E6=9C=AC=E5=9C=B0=E7=BC=BA(NaN/=E6=96=B0?= =?UTF-8?q?=E8=82=A1)=E4=BF=9D=E7=95=99xt=20Capital=E5=B7=B2=E7=AE=97?= =?UTF-8?q?=E5=80=BC,unified=E6=95=B4=E4=BD=93=E5=BC=82=E5=B8=B8=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=9B=9E=E9=80=80=E5=BC=80=E7=9B=98=E6=B0=B8=E4=B8=8D?= =?UTF-8?q?=E6=8C=82;date=3DNone=E4=BC=A0=E4=BB=8A=E5=A4=A9(EOD=E5=8F=96?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E2=89=A4=E4=BB=8A=E5=A4=A9);=5Ffetch=5Fclose?= =?UTF-8?q?=E6=9C=AC=E5=B0=B1=E8=AF=BBxtdata=E6=9C=AC=E5=9C=B0=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=B8=8D=E5=8A=A8=E3=80=82+6=E6=B5=8B=E8=AF=95(?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E4=BC=98=E5=85=88/=E8=A1=A5NaN/=E7=A9=BA?= =?UTF-8?q?=E5=9B=9E=E9=80=80/=E5=BC=82=E5=B8=B8=E5=9B=9E=E9=80=80/NaN?= =?UTF-8?q?=E4=B8=8D=E6=B8=85=E5=80=BC/date=3DNone=E4=BC=A0today);628?= =?UTF-8?q?=E7=BB=BF=E3=80=82=E9=AA=8C=E6=94=B6=3D=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E5=90=8E=E9=A6=96=E4=B8=AA9:30=20small=5Fcap=E4=B9=B0=E5=85=A5?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E4=B8=8D=E5=86=8D0000xx=E8=BF=9E=E5=8F=B7?= =?UTF-8?q?=E4=B8=8D=E5=90=AB=E8=B6=85=E5=A4=A7=E7=9B=98=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/sanguo_fundamentals.py | 40 ++++++++++ tests/portfolio/test_provider.py | 75 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/sanguo_portfolio/providers/sanguo_fundamentals.py b/sanguo_portfolio/providers/sanguo_fundamentals.py index 69e1b8c..057a4a1 100644 --- a/sanguo_portfolio/providers/sanguo_fundamentals.py +++ b/sanguo_portfolio/providers/sanguo_fundamentals.py @@ -483,6 +483,11 @@ class SanguoMiniQmtProvider(MiniQMTProvider): # type: ignore[misc] row = self._build_row(jq_code, qmt_code, stock_fin, close) rows.append(row) + # Step 3.5: 市值本地委托覆盖(2026-08-24 P0)。开盘 Capital 下载常超时 → + # close×total_capital=NaN → 策略 sort_values 无操作保持代码序, small_cap + # 买入清单混入超大盘(平安银行实锤)。本地估值 parquet(EOD)不依赖盘中下载。 + self._apply_local_market_caps(rows, stocks, date_str) + df = pd.DataFrame(rows) if "code" in df.columns: df = df.set_index("code", drop=False) @@ -491,6 +496,41 @@ class SanguoMiniQmtProvider(MiniQMTProvider): # type: ignore[misc] df = df[keep] return df + def _apply_local_market_caps( + self, + rows: List[Dict[str, Any]], + stocks: List[str], + date_str: Optional[str], + ) -> None: + """market_cap/circulating_market_cap 优先本地 unified 估值 parquet(亿元)。 + + 全池同一时点口径(EOD 快照), 盘中 Capital 缓存缺/旧不再影响市值排序; + 单股本地缺(新股未入昨日估值表/NaN)保留 _build_row 已算值回退 xt 路径; + unified 整体异常静默回退——开盘永不因本地读挂死。fields 按需短路只读 + 估值表, 大池(全市场)亦在秒级(LocalParquetProvider 批量路径实证)。 + """ + try: + df = self._unified.get_fundamentals_df( + stocks, + date=(date_str or datetime.now().strftime("%Y-%m-%d")), + fields=["market_cap", "circulating_market_cap"], + ) + except Exception as exc: # noqa: BLE001 - 本地失败回退 Capital 路径 + logger.warning("本地市值委托失败, 回退 Capital 路径: %s", exc) + return + if df is None or df.empty or "code" not in df.columns: + return + by_code = {r["code"]: r for _, r in df.iterrows()} + for row in rows: + local = by_code.get(row.get("code")) + if local is None: + continue + for col in ("market_cap", "circulating_market_cap"): + v = local.get(col) + if v is None or pd.isna(v): + continue + row[col] = float(v) + def get_fundamentals_df_ex( self, stocks: List[str], diff --git a/tests/portfolio/test_provider.py b/tests/portfolio/test_provider.py index 017aa23..c40463a 100644 --- a/tests/portfolio/test_provider.py +++ b/tests/portfolio/test_provider.py @@ -6,6 +6,7 @@ xtquant 没 装,通过 mock_xtquant fixture 注入 sys.modules。 from __future__ import annotations import math +from unittest.mock import MagicMock import pandas as pd import pytest @@ -90,6 +91,80 @@ class TestGetFundamentalsDf: assert df.empty +class TestFundamentalsMarketCapLocalization: + """2026-08-24 P0: 市值改本地估值委托, 开盘不再依赖盘中 Capital 下载。 + + 实锤: 开盘 Capital 下载常超时 → close×total_capital=NaN → 策略 sort_values + 无操作保持代码序, 平安银行(≈3800亿)混进 small_cap 买入清单。修 = market_cap/ + circulating_market_cap 优先本地 unified 估值 parquet(EOD, 亿元同单位, 全池 + 同一时点口径); 本地缺/异常静默回退 xt Capital 路径。""" + + @staticmethod + def _local_unified(rows=None, side_effect=None): + m = MagicMock() + if side_effect is not None: + m.get_fundamentals_df.side_effect = side_effect + else: + m.get_fundamentals_df.return_value = pd.DataFrame(rows or []) + return m + + def test_local_market_cap_wins_over_capital_path(self, mock_xtquant): + """本地有值 → 覆盖 close×Capital(全池同口径优先, 非「仅补 NaN」)。""" + provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False}) + provider._unified = self._local_unified([ + {"code": "600519.XSHG", "market_cap": 3500.0, + "circulating_market_cap": 3400.0}, + ]) + df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") + assert abs(float(df.iloc[0]["market_cap"]) - 3500.0) < 1e-6 + assert abs(float(df.iloc[0]["circulating_market_cap"]) - 3400.0) < 1e-6 + + def test_local_fills_nan_when_capital_missing(self, mock_xtquant): + """Capital 缺(盘中下载超时形态) → 本地补上, 不再 NaN。""" + mock_xtquant["xtdata"].get_financial_data.return_value = {} # 无任何表 + provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False}) + provider._unified = self._local_unified([ + {"code": "600519.XSHG", "market_cap": 21000.0, + "circulating_market_cap": 21000.0}, + ]) + df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") + assert abs(float(df.iloc[0]["market_cap"]) - 21000.0) < 1e-6 + + def test_fallback_to_capital_when_local_empty(self, mock_xtquant): + provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False}) + provider._unified = self._local_unified([]) + df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") + mc = float(df.iloc[0]["market_cap"]) + assert 19000 < mc < 22000 # close×Capital 原路径(≈20096 亿) + + def test_fallback_when_local_raises(self, mock_xtquant): + provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False}) + provider._unified = self._local_unified(side_effect=RuntimeError("parquet io")) + df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") + assert 19000 < float(df.iloc[0]["market_cap"]) < 22000 + + def test_local_nan_keeps_capital_value(self, mock_xtquant): + """本地 NaN(新股未入估值表)不清掉可算值; 同行其它列正常覆盖。""" + provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False}) + provider._unified = self._local_unified([ + {"code": "600519.XSHG", "market_cap": float("nan"), + "circulating_market_cap": 999.0}, + ]) + df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") + assert 19000 < float(df.iloc[0]["market_cap"]) < 22000 # NaN 不覆盖 + assert abs(float(df.iloc[0]["circulating_market_cap"]) - 999.0) < 1e-6 + + def test_none_date_passes_today_to_unified(self, mock_xtquant): + """live date=None(9:30 选股) → 本地委托收到今天(EOD 估值取最新≤今天)。""" + from datetime import datetime as _dt + provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False}) + provider._unified = self._local_unified([]) + provider.get_fundamentals_df(["600519.XSHG"], date=None) + kwargs = provider._unified.get_fundamentals_df.call_args.kwargs + assert kwargs["date"] == _dt.now().strftime("%Y-%m-%d") + assert kwargs["fields"] == ["market_cap", "circulating_market_cap"] + + class TestGetFundamentalsQueryDictMode: def test_dict_with_stocks_returns_dataframe(self, mock_xtquant): provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})