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%/正常/缺失。