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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user