a68cf4905e
把聚宽"全天候轮动"(post48819)搬到 BulletTrade。融合=pip+扩展点注入 (SanguoMiniQmtProvider 继承 MiniQMTProvider 只 override get_fundamentals, set_data_provider 公开 API 注入, BulletTrade 源码 0 改动)。 - providers: SanguoMiniQmtProvider 补 get_fundamentals(PershareIndex+自算PE/PS/PB/PCF/市值/ROIC) - strategies/all_weather: 4选股函数+大小盘轮动+ETF兜底+涨停止损(聚宽风格翻译) - factors(估值/ROIC自算) + filters(ST/涨跌停/次新/停牌) - 88/88 测试 Mac+VPS 双过; VPS 回测 pipeline 跑通(修9bug:Capital单位/日期格式/百分数口径/11字段alias) - 实盘 runner_live+runbook 就绪等交易日; DEFAULT_DATA_PROVIDER=miniqmt env 不装 jqdatasdk - 文档: sanguo_portfolio_plan / portfolio_backtest_result / portfolio_live_runbook
125 lines
5.0 KiB
Python
125 lines
5.0 KiB
Python
"""ROIC(Return on Invested Capital)= NOPAT / 投入资本。
|
||
|
||
聚宽因子库 ``roic_ttm`` 在 miniQMT 没有直接对应字段,需自算。
|
||
公式:NOPAT = 营业利润 × (1 - 实际税率);投入资本 = 归母权益 + 有息负债 - 现金等价物。
|
||
|
||
字段来源(miniQMT 已实证可用):
|
||
- ``oper_profit`` ← Income 表 营业利润
|
||
- ``actual_tax_rate`` ← PershareIndex 实际税率(口径见下)
|
||
- ``tot_shrhldr_eqy`` ← Balance 表 归母权益(合计权益 - 少数股东权益)
|
||
- ``interest_bearing_debt`` ← Balance 表(短期借款 + 长期借款 + 应付债券)
|
||
- ``cash_equivalents`` ← Balance 表 货币资金
|
||
|
||
actual_tax_rate 口径警告:PershareIndex 的 ``actual_tax_rate`` 实证时茅台 NaN,
|
||
且有"百分数(20.0)"与"小数(0.2)"两种口径风险。这里用启发式:
|
||
- 若 |值| > 1,认为是百分数 → 除以 100
|
||
- 若 |值| <= 1,认为是小数 → 直接用
|
||
- 若 NaN,返回 NaN(不强行兜底,留给上层用 Income.inc_tax / 利润总额 兜底)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Optional, Union
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
NumberLike = Union[float, int, "pd.Series", "np.ndarray"]
|
||
|
||
_EPS = 1e-9
|
||
|
||
|
||
def normalize_tax_rate(actual_tax_rate: Optional[NumberLike]) -> NumberLike:
|
||
"""把 actual_tax_rate 归一化到小数(0.2 = 20%)。
|
||
|
||
启发式:|v| > 1 → 原值是百分数,除以 100;|v| <= 1 → 已经是小数。
|
||
NaN 透传(让上层用兜底算法处理)。
|
||
"""
|
||
if actual_tax_rate is None:
|
||
return float("nan")
|
||
if isinstance(actual_tax_rate, (pd.Series, np.ndarray)):
|
||
arr = pd.Series(actual_tax_rate) if isinstance(actual_tax_rate, np.ndarray) else actual_tax_rate
|
||
# |v|>1 视为百分数;注意 NaN 比较返回 False,自动透传
|
||
need_div = arr.abs() > 1
|
||
return arr.where(~need_div, arr / 100.0)
|
||
if isinstance(actual_tax_rate, float) and math.isnan(actual_tax_rate):
|
||
return float("nan")
|
||
if abs(actual_tax_rate) > 1:
|
||
return actual_tax_rate / 100.0
|
||
return float(actual_tax_rate)
|
||
|
||
|
||
def fallback_tax_rate(
|
||
inc_tax: Optional[NumberLike], profit_before_tax: Optional[NumberLike]
|
||
) -> NumberLike:
|
||
"""actual_tax_rate NaN 时的兜底:所得税 / 利润总额。
|
||
|
||
聚宽口径:``actual_tax_rate = income_tax / profit_before_tax``(利润总额为正时)。
|
||
利润总额 <= 0 时返回 0(避免负税率扭曲 NOPAT)。
|
||
"""
|
||
if inc_tax is None or profit_before_tax is None:
|
||
return float("nan")
|
||
if isinstance(profit_before_tax, (pd.Series, np.ndarray)):
|
||
arr = pd.Series(profit_before_tax) if isinstance(profit_before_tax, np.ndarray) else profit_before_tax
|
||
tax = pd.Series(inc_tax) if isinstance(inc_tax, np.ndarray) else inc_tax
|
||
# 利润总额>0 才算,否则 0
|
||
safe = arr.where(arr > _EPS, _EPS)
|
||
rate = tax / safe
|
||
return rate.where(arr > _EPS, 0.0)
|
||
if profit_before_tax <= _EPS:
|
||
return 0.0
|
||
return inc_tax / profit_before_tax
|
||
|
||
|
||
def calc_roic(
|
||
oper_profit: NumberLike,
|
||
actual_tax_rate: Optional[NumberLike],
|
||
tot_shrhldr_eqy: NumberLike,
|
||
interest_bearing_debt: NumberLike,
|
||
cash_equivalents: NumberLike,
|
||
*,
|
||
inc_tax: Optional[NumberLike] = None,
|
||
profit_before_tax: Optional[NumberLike] = None,
|
||
) -> NumberLike:
|
||
"""ROIC = NOPAT / 投入资本。
|
||
|
||
NOPAT = 营业利润 × (1 - 实际税率)
|
||
投入资本 = 归母权益 + 有息负债 - 现金等价物
|
||
|
||
参数:
|
||
oper_profit: 营业利润(单期,**不是 TTM**,对账聚宽 roic_ttm 时注意)
|
||
actual_tax_rate: 实际税率(百分数或小数都行,内部归一)
|
||
tot_shrhldr_eqy: 归母权益(时点值)
|
||
interest_bearing_debt: 有息负债(短期+长期借款+应付债券)
|
||
cash_equivalents: 货币资金
|
||
inc_tax / profit_before_tax: 可选兜底,actual_tax_rate NaN 时启用
|
||
|
||
分母(投入资本) <= 0 时返回 NaN(避免负/零投入资本产生误导性 ROIC)。
|
||
"""
|
||
# Step 1: 税率(actual_tax_rate → 兜底)
|
||
rate = normalize_tax_rate(actual_tax_rate)
|
||
if isinstance(rate, (pd.Series, np.ndarray)):
|
||
arr = pd.Series(rate)
|
||
if inc_tax is not None and profit_before_tax is not None:
|
||
fb = fallback_tax_rate(inc_tax, profit_before_tax)
|
||
fb = pd.Series(fb) if isinstance(fb, np.ndarray) else fb
|
||
rate = arr.where(arr.notna(), fb)
|
||
else:
|
||
if (rate != rate) and inc_tax is not None and profit_before_tax is not None:
|
||
rate = fallback_tax_rate(inc_tax, profit_before_tax)
|
||
|
||
nopat = oper_profit * (1 - rate)
|
||
invested_capital = tot_shrhldr_eqy + interest_bearing_debt - cash_equivalents
|
||
|
||
if isinstance(invested_capital, (pd.Series, np.ndarray)):
|
||
arr = pd.Series(invested_capital) if isinstance(invested_capital, np.ndarray) else invested_capital
|
||
safe = arr.where(arr > _EPS, _EPS)
|
||
result = nopat / safe
|
||
return result.where(arr > _EPS, float("nan"))
|
||
if invested_capital <= _EPS:
|
||
return float("nan")
|
||
return nopat / invested_capital
|
||
|
||
|
||
__all__ = ["calc_roic", "normalize_tax_rate", "fallback_tax_rate"]
|