fix(portfolio): provider 基本面前视偏差修复 + ETF paused + roic 接入

- 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)。
This commit is contained in:
2026-07-24 11:26:56 +08:00
parent e7465c342f
commit 0ef0cadaff
2 changed files with 64 additions and 7 deletions
@@ -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
@@ -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