"""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