From 0ef0cadaffb1736d5bd4a4c85dbd2063b6012f86 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Fri, 24 Jul 2026 11:26:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(portfolio):=20provider=20=E5=9F=BA=E6=9C=AC?= =?UTF-8?q?=E9=9D=A2=E5=89=8D=E8=A7=86=E5=81=8F=E5=B7=AE=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20ETF=20paused=20+=20roic=20=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local_parquet_provider: 新增 _latest_published_annual(NOTICE_DATE<=date + 年报口径),替旧 _latest_row_before(REPORT_DATE)。修复两个 bug: 1) 前视偏差:旧按报告期过滤会用未披露年报(如 3/31 取上年报,4 月才披露); 2) 周期错配:income/balance 各自取最新致 NP(年)÷权益(季)垃圾值。 income/balance 统一走该 helper → 同报告期 + 无前视。 另:roic 接 calc_roic,_num(v==v)排 NaN 修 g() 对 NaN truthy 问题。 - local_unified_provider: get_price 缺失字段 paused 补 False / high·low_limit 按 close±10% 估,修 bool(NaN)=True 被 bullet_trade get_current_data 误判 停牌致 _process_orders cancel 所有 ETF 订单(0 交易)。 实证:roe/roa 跨时点一致(p50 从 0.016/0.064 不一致 → 0.074/0.070/0.065)。 --- .../providers/local_parquet_provider.py | 57 +++++++++++++++++-- .../providers/local_unified_provider.py | 14 ++++- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/sanguo_portfolio/providers/local_parquet_provider.py b/sanguo_portfolio/providers/local_parquet_provider.py index 5f2d18f..2c4a24f 100644 --- a/sanguo_portfolio/providers/local_parquet_provider.py +++ b/sanguo_portfolio/providers/local_parquet_provider.py @@ -327,6 +327,31 @@ class LocalParquetProvider(DataProvider): # type: ignore[misc] sub = df[df[date_col] <= ts] return sub.iloc[-1] if not sub.empty else None + @staticmethod + def _latest_published_annual( + df: pd.DataFrame, date_str: str, + ) -> Optional[pd.Series]: + """最新**已披露年报**: ``NOTICE_DATE <= date_str`` 且 ``REPORT_TYPE`` 含"年"。 + + 修复前视偏差: 旧逻辑按 ``REPORT_DATE``(报告期)取最新, 会用尚未披露的年报 + (如 3/31 取 REPORT_DATE=上年 12/31 但 NOTICE_DATE=当年 4 月的年报 → 未来信息)。 + 年报口径保证 roe/roa 跨股可比(非季报累计); 最多滞后~1年, 月频策略可接受。 + 无 NOTICE_DATE 列 / 无已披露年报 → 回退 ``_latest_row_before(REPORT_DATE)`` 兜底。 + """ + if df is None or df.empty: + return None + if "NOTICE_DATE" not in df.columns: + return LocalParquetProvider._latest_row_before(df, "REPORT_DATE", date_str) + ts = pd.Timestamp(date_str) + d = df.assign(_notice=pd.to_datetime(df["NOTICE_DATE"], errors="coerce")) + sub = d[d["_notice"] <= ts] + if "REPORT_TYPE" in sub.columns: + sub = sub[sub["REPORT_TYPE"].astype(str).str.contains("年", na=False)] + sub = sub.sort_values("_notice") + if not sub.empty: + return sub.iloc[-1] + return LocalParquetProvider._latest_row_before(df, "REPORT_DATE", date_str) + # ==================== get_fundamentals_df ==================== def get_fundamentals_df( self, @@ -358,8 +383,10 @@ class LocalParquetProvider(DataProvider): # type: ignore[misc] row: Dict[str, Any] = {"code": jq_code} val = self._latest_row_before(self._read_valuation(fc), "date", date_str) - inc = self._latest_row_before(self._read_quarter("income", fc), "REPORT_DATE", date_str) - bal = self._latest_row_before(self._read_quarter("balance", fc), "REPORT_DATE", date_str) + # income/balance 取最新**已披露年报**(NOTICE_DATE<=date, REPORT_TYPE 含"年"): + # 修复旧按 REPORT_DATE 过滤的前视偏差(用了未披露年报) + 年报口径跨股可比 + inc = self._latest_published_annual(self._read_quarter("income", fc), date_str) + bal = self._latest_published_annual(self._read_quarter("balance", fc), date_str) def g(d: Optional[pd.Series], k: str) -> Optional[float]: return _to_float(d.get(k)) if d is not None else None @@ -415,9 +442,29 @@ class LocalParquetProvider(DataProvider): # type: ignore[misc] # gross_profit_margin: 从 financial_abstract 读现成"毛利率"(百分数→小数) fa = self._read_financial_abstract(fc) row["gross_profit_margin"] = _pct_to_decimal(self._latest_indicator(fa, "毛利率")) - # roic: 需有息负债拆分 → V1 NaN - # TODO v2: roic = NOPAT / (权益 + 有息负债 - 现金) - row["roic"] = float("nan") + # roic = NOPAT / (归母权益 + 有息负债 - 货币资金) + # actual_tax_rate akshare 无现成指标, 传 None 让 calc_roic 用 inc_tax/total_profit 兜底 + # _num: g() 的 _to_float 对 NaN 返 float('nan')(truthy), 需 v==v 排除 NaN 才能正确 or 0/条件 + from ..factors.roic import calc_roic + def _num(d, k): + v = g(d, k) + return v if (v is not None and v == v) else None + oper_profit = _num(inc, "OPERATE_PROFIT") + inc_tax = _num(inc, "INCOME_TAX") + profit_before_tax = _num(inc, "TOTAL_PROFIT") + short_loan = _num(bal, "SHORT_LOAN") or 0 + long_loan = _num(bal, "LONG_LOAN") or 0 + bond_pay = (_num(bal, "BOND_PAYABLE") or 0) + (_num(bal, "SHORT_BOND_PAYABLE") or 0) + interest_bearing_debt = short_loan + long_loan + bond_pay + cash_equiv = _num(bal, "MONETARYFUNDS") + _parent = _num(bal, "TOTAL_PARENT_EQUITY") + if oper_profit is not None and _parent and cash_equiv is not None: + row["roic"] = calc_roic( + oper_profit, None, _parent, interest_bearing_debt, cash_equiv, + inc_tax=inc_tax, profit_before_tax=profit_before_tax, + ) + else: + row["roic"] = float("nan") return row @staticmethod diff --git a/sanguo_portfolio/providers/local_unified_provider.py b/sanguo_portfolio/providers/local_unified_provider.py index 86be973..90a435b 100644 --- a/sanguo_portfolio/providers/local_unified_provider.py +++ b/sanguo_portfolio/providers/local_unified_provider.py @@ -203,10 +203,20 @@ class LocalUnifiedProvider(DataProvider): # type: ignore[misc] "open_price": "open", "high_price": "high", "low_price": "low", "close_price": "close", }) - # 缺失字段(如 high_limit)补 NaN + # 缺失字段补默认: paused=False(避免 bool(NaN)=True 被 bullet_trade + # get_current_data 误判停牌→订单 cancel); high_limit/low_limit 按 close±10% 估 + # (与 get_current_tick 同口径, 精确涨跌停/ST/创业科创规则 v2); 其他补 NaN if fields: for f in fields: - if f not in df.columns: + if f in df.columns: + continue + if f == "paused": + df[f] = False + elif f == "high_limit" and "close" in df.columns: + df[f] = df["close"] * 1.1 + elif f == "low_limit" and "close" in df.columns: + df[f] = df["close"] * 0.9 + else: df[f] = float("nan") df = df[[f for f in fields if f in df.columns]] frames[jq_code] = df