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
292 lines
10 KiB
Python
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
|