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