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})