Files
sanguo_vnpy_v2/tests/portfolio/test_factors.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

292 lines
10 KiB
Python

"""valuation / roic 因子纯函数测试(AAA 模式)。"""
from __future__ import annotations
import math
import numpy as np
import pandas as pd
import pytest
from sanguo_portfolio import factors
# =================== valuation: calc_market_cap ===================
class TestCalcMarketCap:
def test_scalar_inputs_returns_close_times_capital(self):
# Arrange
close = 10.0
total_capital = 1_000_000_000 # 10 亿股
# Act
out = factors.valuation.calc_market_cap(close, total_capital)
# Assert
assert out == pytest.approx(1.0e10)
def test_series_inputs_broadcast_correctly(self):
# Arrange
close = pd.Series([10.0, 20.0, 30.0])
cap = pd.Series([1e6, 2e6, 3e6])
# Act
out = factors.valuation.calc_market_cap(close, cap)
# Assert
assert list(out) == [1e7, 4e7, 9e7]
def test_zero_capital_returns_zero(self):
# Arrange
# Act
out = factors.valuation.calc_market_cap(10.0, 0.0)
# Assert
assert out == 0.0
# =================== valuation: calc_circulating_market_cap ===================
class TestCalcCirculatingMarketCap:
def test_scalar_inputs_returns_close_times_circulating(self):
out = factors.valuation.calc_circulating_market_cap(5.0, 2e8)
assert out == pytest.approx(1e9)
# =================== valuation: calc_pe ===================
class TestCalcPe:
def test_positive_net_profit_normal_pe(self):
# close=10, cap=1e9, profit=2.5e8 → 单期 PE = 10e9 / (2.5e8 * 4) = 10
out = factors.valuation.calc_pe(10.0, 2.5e8, 1e9)
assert out == pytest.approx(10.0)
def test_negative_net_profit_negative_pe(self):
# 亏损 → 负 PE
out = factors.valuation.calc_pe(10.0, -2.5e8, 1e9)
assert out < 0
def test_zero_net_profit_does_not_inflate_to_inf(self):
# 分母保护:0 → eps(1e-9),不会是 inf
out = factors.valuation.calc_pe(10.0, 0.0, 1e9)
assert math.isfinite(out)
assert out > 1e9 # 极大值
def test_series_inputs(self):
out = factors.valuation.calc_pe(
pd.Series([10.0, 20.0]),
pd.Series([2.5e8, -2.5e8]),
pd.Series([1e9, 1e9]),
)
assert out.iloc[0] == pytest.approx(10.0)
assert out.iloc[1] < 0
# =================== valuation: calc_pb ===================
class TestCalcPb:
def test_positive_equity_normal_pb(self):
# close=10, cap=1e9, equity=5e9 → PB = 1e10/5e9 = 2
out = factors.valuation.calc_pb(10.0, 5e9, 1e9)
assert out == pytest.approx(2.0)
def test_zero_equity_protected(self):
out = factors.valuation.calc_pb(10.0, 0.0, 1e9)
assert math.isfinite(out)
# =================== valuation: calc_ps / calc_pcf ===================
class TestCalcPsPcf:
def test_ps_uses_revenue_times_4(self):
# close=10, cap=1e9, revenue=2.5e8 → PS = 1e10/(2.5e8*4) = 10
out = factors.valuation.calc_ps(10.0, 2.5e8, 1e9)
assert out == pytest.approx(10.0)
def test_pcf_uses_cash_flow_times_4(self):
out = factors.valuation.calc_pcf(10.0, 1e8, 1e9)
# 1e10 / (1e8 * 4) = 25
assert out == pytest.approx(25.0)
# =================== valuation: to_yi ===================
class TestToYi:
def test_yuan_to_yi(self):
assert factors.valuation.to_yi(1e8) == pytest.approx(1.0)
def test_series_to_yi(self):
out = factors.valuation.to_yi(pd.Series([1e8, 2e8]))
assert list(out) == [1.0, 2.0]
# =================== roic: normalize_tax_rate ===================
class TestNormalizeTaxRate:
def test_percent_form_divided_by_100(self):
# 25.0 视为百分数 → 0.25
out = factors.roic.normalize_tax_rate(25.0)
assert out == pytest.approx(0.25)
def test_decimal_form_kept(self):
out = factors.roic.normalize_tax_rate(0.25)
assert out == pytest.approx(0.25)
def test_none_returns_nan(self):
out = factors.roic.normalize_tax_rate(None)
assert math.isnan(out)
def test_nan_passes_through(self):
out = factors.roic.normalize_tax_rate(float("nan"))
assert math.isnan(out)
def test_series_mixed(self):
s = pd.Series([25.0, 0.15, float("nan")])
out = factors.roic.normalize_tax_rate(s)
assert out.iloc[0] == pytest.approx(0.25)
assert out.iloc[1] == pytest.approx(0.15)
assert math.isnan(out.iloc[2])
# =================== roic: fallback_tax_rate ===================
class TestFallbackTaxRate:
def test_normal_inc_tax_and_profit(self):
out = factors.roic.fallback_tax_rate(25.0, 100.0)
assert out == pytest.approx(0.25)
def test_zero_or_negative_profit_returns_zero(self):
out = factors.roic.fallback_tax_rate(25.0, 0.0)
assert out == 0.0
out2 = factors.roic.fallback_tax_rate(25.0, -100.0)
assert out2 == 0.0
def test_none_returns_nan(self):
out = factors.roic.fallback_tax_rate(None, 100.0)
assert math.isnan(out)
# =================== roic: calc_roic ===================
class TestCalcRoic:
def test_high_quality_business_high_roic(self):
# oper=100, tax=25%(decimal), equity=200, debt=0, cash=0
# NOPAT = 100*(1-0.25)=75; IC = 200; ROIC = 75/200 = 0.375
out = factors.roic.calc_roic(
oper_profit=100.0,
actual_tax_rate=0.25,
tot_shrhldr_eqy=200.0,
interest_bearing_debt=0.0,
cash_equivalents=0.0,
)
assert out == pytest.approx(0.375)
def test_percent_form_tax_rate_normalized(self):
# actual_tax_rate=25 (percent form) → 0.25
out = factors.roic.calc_roic(
oper_profit=100.0,
actual_tax_rate=25.0,
tot_shrhldr_eqy=200.0,
interest_bearing_debt=0.0,
cash_equivalents=0.0,
)
assert out == pytest.approx(0.375)
def test_cash_reduces_invested_capital(self):
# cash=50 → IC = 200-50=150; NOPAT=75 → ROIC = 0.5
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=200.0, interest_bearing_debt=0.0,
cash_equivalents=50.0,
)
assert out == pytest.approx(0.5)
def test_debt_increases_invested_capital(self):
# debt=100 → IC=300; NOPAT=75 → ROIC = 0.25
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=200.0, interest_bearing_debt=100.0,
cash_equivalents=0.0,
)
assert out == pytest.approx(0.25)
def test_non_positive_invested_capital_returns_nan(self):
# IC = 0 - 0 - 0 = 0 → NaN
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=0.0, interest_bearing_debt=0.0,
cash_equivalents=0.0,
)
assert math.isnan(out)
def test_negative_invested_capital_returns_nan(self):
# cash > equity + debt → IC < 0
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=100.0, interest_bearing_debt=0.0,
cash_equivalents=200.0,
)
assert math.isnan(out)
def test_nan_tax_uses_fallback(self):
# actual_tax_rate=NaN + inc_tax=25, profit=100 → rate=0.25 → ROIC=0.375
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=float("nan"),
tot_shrhldr_eqy=200.0, interest_bearing_debt=0.0,
cash_equivalents=0.0,
inc_tax=25.0, profit_before_tax=100.0,
)
assert out == pytest.approx(0.375)
# =================== roic: Series 批量路径(覆盖数组分支) ===================
class TestCalcRoicSeries:
def test_series_input_returns_series(self):
s = pd.Series([100.0, 200.0])
out = factors.roic.calc_roic(
oper_profit=s,
actual_tax_rate=pd.Series([0.25, 0.25]),
tot_shrhldr_eqy=pd.Series([200.0, 400.0]),
interest_bearing_debt=pd.Series([0.0, 0.0]),
cash_equivalents=pd.Series([0.0, 0.0]),
)
assert isinstance(out, pd.Series)
assert out.iloc[0] == pytest.approx(0.375)
assert out.iloc[1] == pytest.approx(0.375)
def test_series_with_zero_invested_capital_returns_nan(self):
# IC=0 的那一行 NaN
out = factors.roic.calc_roic(
oper_profit=pd.Series([100.0, 100.0]),
actual_tax_rate=pd.Series([0.25, 0.25]),
tot_shrhldr_eqy=pd.Series([200.0, 0.0]),
interest_bearing_debt=pd.Series([0.0, 0.0]),
cash_equivalents=pd.Series([0.0, 0.0]),
)
assert isinstance(out, pd.Series)
assert out.iloc[0] == pytest.approx(0.375)
assert math.isnan(out.iloc[1])
def test_series_normalize_tax_rate_with_mixed(self):
# 25(百分数)+ 0.15(小数) → [0.25, 0.15]
out = factors.roic.normalize_tax_rate(pd.Series([25.0, 0.15]))
assert out.iloc[0] == pytest.approx(0.25)
assert out.iloc[1] == pytest.approx(0.15)
def test_series_fallback_tax_rate_zero_profit_row(self):
# 第 2 行利润总额=0 → 税率=0
out = factors.roic.fallback_tax_rate(
pd.Series([25.0, 25.0]),
pd.Series([100.0, 0.0]),
)
assert out.iloc[0] == pytest.approx(0.25)
assert out.iloc[1] == 0.0
def test_series_nan_tax_uses_fallback_per_row(self):
# 一行正常税率 + 一行 NaN,补 fallback
out = factors.roic.calc_roic(
oper_profit=pd.Series([100.0, 100.0]),
actual_tax_rate=pd.Series([0.25, float("nan")]),
tot_shrhldr_eqy=pd.Series([200.0, 200.0]),
interest_bearing_debt=pd.Series([0.0, 0.0]),
cash_equivalents=pd.Series([0.0, 0.0]),
inc_tax=pd.Series([25.0, 25.0]),
profit_before_tax=pd.Series([100.0, 100.0]),
)
assert out.iloc[0] == pytest.approx(0.375)
assert out.iloc[1] == pytest.approx(0.375)
def test_numpy_array_input(self):
out = factors.roic.normalize_tax_rate(np.array([25.0, 0.15]))
assert out[0] == pytest.approx(0.25)
assert out[1] == pytest.approx(0.15)
def test_fallback_numpy_input(self):
out = factors.roic.fallback_tax_rate(np.array([25.0]), np.array([0.0]))
assert out[0] == 0.0