Files
sanguo_vnpy_v2/sanguo_portfolio/factors/roic.py
T
claude_dev a68cf4905e feat(portfolio): sanguo_portfolio 组合策略框架(BulletTrade+miniQMT,不用jqdatasdk)
把聚宽"全天候轮动"(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
2026-07-18 19:08:18 +08:00

125 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]