From 3b3f7d689cd9c11660fc13a98ba6e4b52602a5c4 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Tue, 1 Sep 2026 18:59:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(provider):=20value=20ROE=20=E9=85=8D?= =?UTF-8?q?=E5=AF=B9=E6=94=B9=E5=85=AC=E5=85=B1=E6=8A=A5=E5=91=8A=E6=9C=9F?= =?UTF-8?q?=E4=BA=A4=E9=9B=86=E6=B2=BB=E8=B4=A2=E6=8A=A5=E5=AD=A3=20vintag?= =?UTF-8?q?e=20=E9=94=99=E4=BD=8D=E6=B8=85=E4=BB=93=E2=80=94=E2=80=9409-01?= =?UTF-8?q?=2009:32=20value=5Fselection=5Fex=20=E6=9C=88=E5=BA=A6=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=20L3=3D0=20=E5=85=A8=E6=B8=85=E4=BB=93=E5=AE=9E?= =?UTF-8?q?=E9=94=A4:roe=5Fseries=20=E6=9E=84=E5=BB=BA=3Dincome/balance=20?= =?UTF-8?q?=E5=90=84=E8=87=AA=20latest-4=20=E5=86=8D=E7=A1=AC=20REPORT=5FD?= =?UTF-8?q?ATE=20=E5=8C=B9=E9=85=8D,=E8=B4=A2=E6=8A=A5=E5=AD=A3=E4=B8=80?= =?UTF-8?q?=E8=A1=A8=E9=A2=86=E5=85=88=E4=B8=80=E5=AD=A3(balance=20?= =?UTF-8?q?=E5=B7=B2=E5=B8=A6=202026=20=E4=B8=AD=E6=8A=A5=E8=80=8C=20incom?= =?UTF-8?q?e=20=E6=9C=AA=E5=88=B7=E6=96=B0,ak-quarter=20=E9=A9=AC=E6=8B=89?= =?UTF-8?q?=E6=9D=BE=2009:32=20=E6=97=B6=2094%/0%=20=E6=92=95=E8=A3=82)?= =?UTF-8?q?=E4=B8=A4=E7=AA=97=E9=94=99=E5=BC=80=E4=B8=80=E6=A0=BC=E5=8F=AA?= =?UTF-8?q?=E4=BA=A4=203=20=E6=9C=9F=E2=86=92=E7=AD=96=E7=95=A5=20len>=3D4?= =?UTF-8?q?=20=E5=85=A8=E4=BD=93=E4=B8=8D=E8=BF=87=E2=86=92821=20=E5=8F=AA?= =?UTF-8?q?=E9=9B=B6=E5=80=99=E9=80=89=E2=86=92=E5=8D=96=20600276/603288?= =?UTF-8?q?=20=E5=85=B1=201.23=20=E4=B8=87=E8=82=A1=E8=B6=B4=20483k=20?= =?UTF-8?q?=E7=8E=B0=E9=87=91;=E4=BF=AE=3D=E4=B8=A4=E8=A1=A8=E5=90=84?= =?UTF-8?q?=E5=8F=96=202=20=E5=80=8D=E7=BC=93=E5=86=B2=E2=86=92REPORT=5FDA?= =?UTF-8?q?TE=20=E5=85=AC=E5=85=B1=E4=BA=A4=E9=9B=86=E5=9B=9E=E6=9C=80?= =?UTF-8?q?=E8=BF=91=204=20=E5=AD=A3(=E5=90=8C=E5=AD=A3=E9=87=8D=E8=BF=B0?= =?UTF-8?q?=E5=8F=96=E6=9C=80=E6=96=B0=20notice),=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E5=B1=82=20len>=3D4=20=E5=A5=91=E7=BA=A6=E4=B8=8D=E5=8F=98,?= =?UTF-8?q?=E5=8E=9F=E7=89=88+=5Fex=20=E5=90=8C=E5=8F=97=E7=9B=8A;+3=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92(=E9=A2=86=E5=85=88=E4=B8=80=E5=AD=A3?= =?UTF-8?q?=E4=BB=8D=E6=BB=A1=204=20=E6=9C=9F/=E9=A2=86=E5=85=88=E5=AD=A3?= =?UTF-8?q?=E7=BB=9D=E4=B8=8D=E8=B7=A8=E5=AD=A3=E9=94=99=E9=85=8D/?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E4=B8=A4=E8=A1=A8=E8=AF=AD=E4=B9=89=E4=B8=8D?= =?UTF-8?q?=E5=8F=98);538=20=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/local_parquet_provider.py | 22 +++-- .../portfolio/test_local_unified_provider.py | 87 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/sanguo_portfolio/providers/local_parquet_provider.py b/sanguo_portfolio/providers/local_parquet_provider.py index 877921a..4084ed8 100644 --- a/sanguo_portfolio/providers/local_parquet_provider.py +++ b/sanguo_portfolio/providers/local_parquet_provider.py @@ -478,24 +478,34 @@ class LocalParquetProvider(DataProvider): # type: ignore[misc] cur_ratio = ca / cl # 近 4 季 ROE(PARENT_NETPROFIT / TOTAL_PARENT_EQUITY, 按报告期对齐) + # 2026-09-01 修(策略01 清仓事故):原「两表各自 latest-4 再硬匹配」对 + # vintage 错位零容忍——财报季一表领先一季(balance 已带中报而 income + # 未刷新)时两窗错开一格只交 3 期 → 策略 len>=4 全体不过 → L3=0 全清仓。 + # 改为两表各取 2 倍缓冲,按 REPORT_DATE 公共交集回最近 4 季。 roe_series: List[float] = [] if not income_df.empty and not balance_df.empty: - inc_rows = self._latest_n_published(income_df, date_str, 4) - bal_rows = self._latest_n_published(balance_df, date_str, 4) + _roe_n = 4 + inc_rows = self._latest_n_published(income_df, date_str, _roe_n * 2) + bal_rows = self._latest_n_published(balance_df, date_str, _roe_n * 2) + bal_by_rdate: dict = {} + for b in bal_rows: + rd = b.get("REPORT_DATE") + if rd is not None: + bal_by_rdate.setdefault(rd, b) # 最新notice优先(同季重述取最新) for inc_row in inc_rows: rdate = inc_row.get("REPORT_DATE") if rdate is None: continue - # 按报告期对齐: 找同 REPORT_DATE 的 balance 行 - bal_match = next( - (b for b in bal_rows if b.get("REPORT_DATE") == rdate), None, - ) + # 按报告期对齐: 找同 REPORT_DATE 的 balance 行(仅公共季入选) + bal_match = bal_by_rdate.get(rdate) if bal_match is None: continue np_ = _to_float(inc_row.get("PARENT_NETPROFIT")) eq = _to_float(bal_match.get("TOTAL_PARENT_EQUITY")) if np_ is not None and eq and eq != 0: roe_series.append(np_ / eq) + if len(roe_series) >= _roe_n: + break # 近 4 季营收同比(OPERATE_INCOME_YOY, 百分数) + 净利润同比(PARENT_NETPROFIT_YOY, 百分数) yoy_series: List[float] = [] diff --git a/tests/portfolio/test_local_unified_provider.py b/tests/portfolio/test_local_unified_provider.py index 70f5d91..4b5586f 100644 --- a/tests/portfolio/test_local_unified_provider.py +++ b/tests/portfolio/test_local_unified_provider.py @@ -713,6 +713,93 @@ class TestGetValueMetricsBatch: ), code +class TestValueMetricsRoeQuarterAlignment: + """2026-09-01 value_selection 清仓事故回归(策略侧根因):roe_series 需 + income×balance 同 REPORT_DATE 配对满 4 期,原实现两表**各自**取最新 4 期再 + 硬匹配——财报季一表领先一季(balance 已带中报而 income 未刷新,09-01 实测 + vintage 撕裂)时两窗错开一格只交出 3 期 → 策略 len>=4 全体不过 → L3=0 → + 月度窗口全清仓。修=两表各取缓冲后按**公共报告期交集**回最近 4 季。""" + + @staticmethod + def _write_income(tmp_path, periods): + """periods: (report_date, notice_date, parent_netprofit) 升序。""" + d = tmp_path / "static" / "income" + d.mkdir(parents=True, exist_ok=True) + pd.DataFrame({ + "SECUCODE": ["600519.SH"] * len(periods), + "REPORT_DATE": [p[0] for p in periods], + "NOTICE_DATE": [p[1] for p in periods], + "REPORT_TYPE": ["季报"] * len(periods), + "PARENT_NETPROFIT": [p[2] for p in periods], + "OPERATE_INCOME_YOY": [10.0] * len(periods), + "PARENT_NETPROFIT_YOY": [10.0] * len(periods), + }).to_parquet(d / "600519.SH_income.parquet", index=False) + + @staticmethod + def _write_balance(tmp_path, periods): + """periods: (report_date, notice_date, total_parent_equity) 升序。""" + d = tmp_path / "static" / "balance" + d.mkdir(parents=True, exist_ok=True) + pd.DataFrame({ + "SECUCODE": ["600519.SH"] * len(periods), + "REPORT_DATE": [p[0] for p in periods], + "NOTICE_DATE": [p[1] for p in periods], + "REPORT_TYPE": ["季报"] * len(periods), + "TOTAL_PARENT_EQUITY": [p[2] for p in periods], + }).to_parquet(d / "600519.SH_balance.parquet", index=False) + + _INCOME_5Q = [ # 停在 2025-12-31(未带 26Q1 = 滞后一季) + ("2024-12-31", "2025-03-26", 1e9), + ("2025-03-31", "2025-04-25", 2e9), + ("2025-06-30", "2025-08-22", 3e9), + ("2025-09-30", "2025-10-28", 4e9), + ("2025-12-31", "2026-03-26", 5e9), + ] + _BALANCE_6Q = [ # 多带 2026-03-31(= 领先一季),权益逐季翻倍使错配可测 + ("2024-12-31", "2025-03-26", 1.0e11), + ("2025-03-31", "2025-04-25", 2.0e11), + ("2025-06-30", "2025-08-22", 4.0e11), + ("2025-09-30", "2025-10-28", 8.0e11), + ("2025-12-31", "2026-03-26", 1.6e12), + ("2026-03-31", "2026-04-24", 3.2e12), + ] + + def test_one_table_leads_a_quarter_still_full_4(self, tmp_path): + """balance 领先一季:roe_series 仍须满 4 期,且按公共季对齐(25Q4..Q1), + 不含领先季。旧实现两窗错位只交 3 期(09-01 L3=0 形态)。""" + self._write_income(tmp_path, self._INCOME_5Q) + self._write_balance(tmp_path, self._BALANCE_6Q) + p = LocalUnifiedProvider({"data_dir": str(tmp_path)}) + m = p.get_value_metrics("600519.XSHG", "2026-06-01") + assert m is not None + assert len(m["roe_series"]) == 4 + exp = [5e9 / 1.6e12, 4e9 / 8e11, 3e9 / 4e11, 2e9 / 2e11] + assert m["roe_series"] == pytest.approx(exp) + + def test_leading_quarter_not_mispaired(self, tmp_path): + """领先季(26Q1)绝不与公共季错配:任何元素都不等于 + 25Q4 净利/26Q1 权益 等跨季错配值。""" + self._write_income(tmp_path, self._INCOME_5Q) + self._write_balance(tmp_path, self._BALANCE_6Q) + p = LocalUnifiedProvider({"data_dir": str(tmp_path)}) + m = p.get_value_metrics("600519.XSHG", "2026-06-01") + assert m is not None + wrong = {5e9 / 3.2e12, 4e9 / 1.6e12, 3e9 / 8e11} + for v in m["roe_series"]: + assert v not in wrong + + def test_aligned_tables_semantics_unchanged(self, tmp_path): + """两表对齐(同 5 期):仍回最近 4 期公共季,语义与修前一致。""" + self._write_income(tmp_path, self._INCOME_5Q) + self._write_balance(tmp_path, self._BALANCE_6Q[:-1]) # 去掉领先季 + p = LocalUnifiedProvider({"data_dir": str(tmp_path)}) + m = p.get_value_metrics("600519.XSHG", "2026-06-01") + assert m is not None + assert len(m["roe_series"]) == 4 + exp = [5e9 / 1.6e12, 4e9 / 8e11, 3e9 / 4e11, 2e9 / 2e11] + assert m["roe_series"] == pytest.approx(exp) + + # ======================== Task 3d: get_limit_status_batch (涨跌停/停牌回测修正) ======================== def _make_limit_fixture(tmp_path): """dbbardata 2 日线(T-1=06-19, T=06-20)覆盖涨停/跌停/停牌/创业板20%/正常/缺失。