"""ValueSelectionStrategy 单元测试(mock provider + mock broker)。 策略层只测**逻辑分支正确**(6 条过滤 / 调仓 / 多期对齐),不测真实数据。 真实数据回测在 VPS 跑,这里只保证策略翻译等价 + bug 已修。 provider.get_value_metrics 接口的契约由 LocalParquetProvider 实现(单测见 test_local_unified_provider / test_local_parquet_provider),本文件只 mock 它的返回。 ⚠️ L1/L2/L3 是"和市场均值比较"(严格 ``>``),单只股票 / 两只股票值相同时 都会被卡死(均值=自身,严格>不过)。所以测试都用 **HIGH vs LOW 双股对照**: HIGH 所有指标都高,LOW 所有指标都低 → HIGH 入选 LOW 不入选。 """ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional from unittest.mock import MagicMock import numpy as np import pandas as pd import pytest from sanguo_portfolio import BrokerFacade from sanguo_portfolio.strategies.value_selection import ( ValueSelectionConfig, ValueSelectionStrategy, ) from tests.portfolio.conftest import FakeContext, FakePosition # ------------------------ 测试 helper ------------------------ def _make_metrics( *, circ_cap: float = 100.0, current_ratio: float = 1.5, roe_series: Optional[List[float]] = None, fcf_series: Optional[List[float]] = None, revenue_yoy_series: Optional[List[float]] = None, netprofit_yoy_series: Optional[List[float]] = None, ) -> Dict[str, Any]: """构造一个 metrics dict。""" return { "circulating_market_cap": circ_cap, "current_ratio": current_ratio, "roe_series": roe_series if roe_series is not None else [0.15, 0.15, 0.15, 0.15], "fcf_series": fcf_series if fcf_series is not None else [1e8, 1e8, 1e8, 1e8, 1e8], "revenue_yoy_series": revenue_yoy_series if revenue_yoy_series is not None else [15.0, 15.0, 15.0, 15.0], "netprofit_yoy_series": netprofit_yoy_series if netprofit_yoy_series is not None else [20.0, 20.0, 20.0, 20.0], } def _make_high_metrics(**overrides) -> Dict[str, Any]: """所有指标都"高"的对照股 → 全 6 条过滤都过(前提是 LOW 在场拉低均值)。""" base = { "circ_cap": 500.0, # L1 > mean(500,10)=255 过 "current_ratio": 3.0, # L2 > mean(3.0,0.5)=1.75 过 "roe_series": [0.3, 0.3, 0.3, 0.3], # L3 > mean(0.3,0.001)=0.15 各季过 "fcf_series": [1e8, 1e8, 1e8, 1e8, 1e8], # L4 5 年正 "revenue_yoy_series": [15.0, 15.0, 15.0, 15.0], # L5 ∈ (6,30) "netprofit_yoy_series": [20.0, 20.0, 20.0, 20.0], # L6 ∈ (8,50) 净利润同比 } base.update(overrides) return _make_metrics(**base) def _make_low_metrics(**overrides) -> Dict[str, Any]: """所有指标都"低"的对照股 → 6 条过滤都不过。""" base = { "circ_cap": 10.0, # L1 < 均值(255) 不过 "current_ratio": 0.5, # L2 < 均值(1.75) 不过 "roe_series": [0.001, 0.001, 0.001, 0.001], # L3 < 均值(0.15) 各季不过 "fcf_series": [-1e8, -1e8, -1e8, -1e8, -1e8], # L4 5 年负 "revenue_yoy_series": [3.0, 3.0, 3.0, 3.0], # L5 <6 不过 "netprofit_yoy_series": [1.0, 1.0, 1.0, 1.0], # L6 <8 不过(净利润同比) } base.update(overrides) return _make_metrics(**base) def make_strategy( *, metrics_map: Optional[Dict[str, Dict[str, Any]]] = None, config: Optional[ValueSelectionConfig] = None, ) -> ValueSelectionStrategy: """构造一个 mock provider + mock broker 驱动的策略。 - metrics_map: dict[code -> metrics_dict] provider.get_value_metrics 返回 """ provider = MagicMock(name="provider") metrics_map = metrics_map or {} def _get_value_metrics(stock, date=None): return metrics_map.get(stock) provider.get_value_metrics.side_effect = _get_value_metrics # 策略 _get_stock_list 走 batch 路径(一次并发取全部候选) provider.get_value_metrics_batch.side_effect = lambda stocks, date=None: { stock: metrics_map.get(stock) for stock in stocks } provider.get_index_stocks.return_value = [] provider.get_security_info.return_value = { "display_name": "NORMAL", "name": "600519", "start_date": datetime(2000, 1, 1), } # get_limit_status_batch: 默认全部"正常交易"(filter 全保留) def _glbs(codes, date=None): return { c: {"is_limit_up": False, "is_limit_down": False, "is_paused": False} for c in codes } provider.get_limit_status_batch.side_effect = _glbs broker = BrokerFacade() broker.order_target_value = MagicMock(return_value=MagicMock(filled=100)) broker.order_value = MagicMock(return_value=MagicMock(filled=100)) broker.set_benchmark = MagicMock() broker.set_option = MagicMock() broker.run_daily = MagicMock() broker.run_monthly = MagicMock() cfg = config or ValueSelectionConfig() return ValueSelectionStrategy(provider=provider, broker=broker, config=cfg) # =================== initialize =================== class TestInitialize: def test_initialize_registers_monthly(self, fake_context): """initialize 注册 run_monthly(monthly_adjustment, day=5, time='9:30')。""" s = make_strategy() s.initialize(fake_context) assert s.broker.run_monthly.called first_call = s.broker.run_monthly.call_args_list[0] assert first_call.args[0].__name__ == "monthly_adjustment" assert first_call.args[1] == 5 assert first_call.args[2] == "9:30" def test_initialize_sets_benchmark(self, fake_context): cfg = ValueSelectionConfig(benchmark="000300.XSHG") s = make_strategy(config=cfg) s.initialize(fake_context) s.broker.set_benchmark.assert_called_with("000300.XSHG") # =================== _get_stock_list (6 条过滤) =================== class TestGetStockList: def test_empty_candidates_returns_empty(self): s = make_strategy() assert s._get_stock_list([], "2024-09-30") == [] def test_all_metrics_missing_returns_empty(self): """所有股票 provider 都返 None → 返回空。""" s = make_strategy(metrics_map={}) out = s._get_stock_list(["A.XSHG", "B.XSHG"], "2024-09-30") assert out == [] def test_single_stock_fails_mean_filters(self): """单只股票: L1/L2/L3 严格 ``> 均值`` 不过(均值=自身,严格>恒 False)。""" s = make_strategy(metrics_map={ "A.XSHG": _make_high_metrics(), }) out = s._get_stock_list(["A.XSHG"], "2024-09-30") # L1 把单股卡死(均值=自身) assert out == [] # ----- L1: 流通市值 > 市场均值 ----- def test_L1_filters_below_mean_market_cap(self): """流通市值低于市场均值的被剔除。""" s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(circ_cap=500), "LOW.XSHG": _make_low_metrics(circ_cap=10), }) out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30") assert "HIGH.XSHG" in out assert "LOW.XSHG" not in out def test_L1_nan_market_cap_excluded_from_mean(self): """circ_cap NaN 的股票不入选, 也不参与均值计算(避免拉低均值)。""" s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(circ_cap=500), "NAN.XSHG": _make_high_metrics(circ_cap=float("nan")), }) out = s._get_stock_list(["HIGH.XSHG", "NAN.XSHG"], "2024-09-30") # 均值 = 500(HIGH 一只, NaN 排除), HIGH 严格 > 500 不过 # 这验证 NaN 不被算入 mean assert "NAN.XSHG" not in out # ----- L2: 流动比率 > 市场均值 ----- def test_L2_filters_below_mean_current_ratio(self): s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(current_ratio=3.0), "LOW.XSHG": _make_low_metrics(current_ratio=0.5), }) out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30") assert "HIGH.XSHG" in out assert "LOW.XSHG" not in out # ----- L3: 近 4 季 ROE > 各季市场均值 ----- def test_L3_takes_intersection_of_4_quarters(self): """4 季 ROE 都 > 各季市场均值才过(交集语义)。 LOW 作分母拉低均值(让 HIGH/BADQ3 能在 L1/L2 过)。 HIGH 各季 ROE 都比 BADQ3 高 → HIGH 各季过; BADQ3 第3季 ROE 低 → 不过。 """ s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.3, 0.3]), "BADQ3.XSHG": _make_high_metrics(roe_series=[0.1, 0.1, 0.001, 0.1]), "LOW.XSHG": _make_low_metrics(), }) out = s._get_stock_list(["HIGH.XSHG", "BADQ3.XSHG", "LOW.XSHG"], "2024-09-30") assert "HIGH.XSHG" in out assert "BADQ3.XSHG" not in out def test_L3_insufficient_roe_quarters_filtered(self): """ROE series < 4 季 → 该股剔除。""" s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.3, 0.3]), "SHORT.XSHG": _make_high_metrics(roe_series=[0.3, 0.3]), "LOW.XSHG": _make_low_metrics(), }) out = s._get_stock_list( ["HIGH.XSHG", "SHORT.XSHG", "LOW.XSHG"], "2024-09-30", ) assert "SHORT.XSHG" not in out # ----- L4: 近 5 年 FCF 每年为正 ----- def test_L4_requires_all_5_years_positive(self): """FCF 5 年必须都 > 0。""" s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(fcf_series=[1, 1, 1, 1, 1]), "LAST_NEG.XSHG": _make_high_metrics(fcf_series=[1, 1, 1, 1, -1]), "LOW.XSHG": _make_low_metrics(), }) out = s._get_stock_list( ["HIGH.XSHG", "LAST_NEG.XSHG", "LOW.XSHG"], "2024-09-30", ) assert "HIGH.XSHG" in out assert "LAST_NEG.XSHG" not in out def test_L4_insufficient_fcf_years_filtered(self): """FCF 年数 < 5 → 剔除。""" s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(fcf_series=[1, 1, 1, 1, 1]), "SHORT.XSHG": _make_high_metrics(fcf_series=[1, 1, 1]), "LOW.XSHG": _make_low_metrics(), }) out = s._get_stock_list( ["HIGH.XSHG", "SHORT.XSHG", "LOW.XSHG"], "2024-09-30", ) assert "SHORT.XSHG" not in out # ----- L5: 近 4 季营收同比 6%~30% ----- def test_L5_revenue_yoy_must_be_6_to_30_all_quarters(self): """营收同比 4 季都 ∈ (6, 30)。""" s = make_strategy(metrics_map={ "IN.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 15]), "HIGH50.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 50]), "LOW3.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 3]), "LOW.XSHG": _make_low_metrics(), }) out = s._get_stock_list( ["IN.XSHG", "HIGH50.XSHG", "LOW3.XSHG", "LOW.XSHG"], "2024-09-30", ) assert "IN.XSHG" in out assert "HIGH50.XSHG" not in out assert "LOW3.XSHG" not in out def test_L5_strict_inequality_at_boundary(self): """原代码 ``>low & 0.5) 导致全程空仓。按注释本意改为净利润同比增长率 8%~50%。 """ s = make_strategy(metrics_map={ "IN.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 20]), "HIGH60.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 60]), "LOW5.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 5]), "LOW.XSHG": _make_low_metrics(), }) out = s._get_stock_list( ["IN.XSHG", "HIGH60.XSHG", "LOW5.XSHG", "LOW.XSHG"], "2024-09-30", ) assert "IN.XSHG" in out assert "HIGH60.XSHG" not in out assert "LOW5.XSHG" not in out def test_L6_strict_inequality_at_boundary(self): """原代码 ``>low & 才过)。""" # BAD2: 后 2 季 < 均值 → 交集为空 → 不过 s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.3, 0.3]), "BAD2.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.001, 0.001]), "LOW.XSHG": _make_low_metrics(), }) out = s._get_stock_list(["HIGH.XSHG", "BAD2.XSHG", "LOW.XSHG"], "2024-09-30") assert "HIGH.XSHG" in out assert "BAD2.XSHG" not in out # =================== NOTICE_DATE 前视偏差过滤 =================== class TestNoticeDateFiltering: """前视偏差修复: provider 返回的 metrics 应只含 NOTICE_DATE <= date 的数据。 策略层契约: 信任 provider 的 NOTICE_DATE 过滤结果, 不再二次过滤(职责分离)。 本测试用 mock 模拟: 验证策略**依赖** provider 过滤(只把 date 传过去)。 """ def test_strategy_passes_date_to_provider(self): """策略层把 previous_date 传给 provider.get_value_metrics_batch(stocks, date)。""" captured_dates: List[Any] = [] def _capture_batch(stocks, date=None): captured_dates.append(date) return { stock: (_make_high_metrics() if "HIGH" in stock else _make_low_metrics()) for stock in stocks } provider = MagicMock() provider.get_value_metrics_batch.side_effect = _capture_batch provider.get_index_stocks.return_value = ["HIGH.XSHG", "LOW.XSHG"] provider.get_security_info.return_value = { "display_name": "A", "name": "A", "start_date": datetime(2000, 1, 1), } provider.get_live_current.return_value = { "paused": False, "last_price": 10.0, "high_limit": 11.0, "low_limit": 9.0, } s = ValueSelectionStrategy(provider=provider, broker=BrokerFacade()) s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30") # provider 收到的 date 应是 "2024-09-30"(由策略层传过去) assert provider.get_value_metrics_batch.called for call in provider.get_value_metrics_batch.call_args_list: # call.args = (stocks, date) 或 call.args = (stocks,) + kwargs if len(call.args) >= 2: assert call.args[1] == "2024-09-30" else: assert call.kwargs.get("date") == "2024-09-30" # =================== 空数据跳过 =================== class TestEmptyDataSkip: """三表损坏/空的股票 → provider.get_value_metrics 返 None → 该股不入选。""" def test_provider_returns_none_stock_excluded(self): """provider 返 None 表示该股三表全空/损坏 → 跳过。""" s = make_strategy(metrics_map={ "HIGH.XSHG": _make_high_metrics(), "BAD.XSHG": None, }) out = s._get_stock_list(["HIGH.XSHG", "BAD.XSHG"], "2024-09-30") assert "BAD.XSHG" not in out # HIGH 单只剩下的情况 → 均值=自身,不过(预期行为,不阻塞主流程) def test_provider_returns_none_stock_excluded(self): """单只异常由 provider 吞为 None → 跳过,不污染整批。 batch 接口契约: provider.get_value_metrics_batch 内部 try/except 单只 失败 → 返 {stock: None}; 策略层只看 None 跳过,不崩。 (原 test_provider_raises_stock_excluded 语义: per-stock 错误不污染整批) """ provider = MagicMock() # HIGH 正常返回 metrics, LOW 返 None(三表损坏/异常由 provider 吞) def _gnm_batch(stocks, date=None): return { stock: (_make_low_metrics() if "HIGH" in stock else None) for stock in stocks } provider.get_value_metrics_batch.side_effect = _gnm_batch s = ValueSelectionStrategy(provider=provider, broker=BrokerFacade()) # 不抛异常 out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30") assert "LOW.XSHG" not in out # HIGH 因均值=自身不过(预期), 但**没有崩** assert isinstance(out, list) # =================== monthly_adjustment (主流程) =================== class TestMonthlyAdjustment: def test_empty_universe_no_trade(self): """候选池空 → 不调仓。""" s = make_strategy(metrics_map={}) s.provider.get_index_stocks.return_value = [] ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30)) s.monthly_adjustment(ctx) assert not s.broker.order_target_value.called def test_sells_positions_not_in_buy_list(self): """卖出不在新名单的持仓(原策略 sell 函数)。""" cfg = ValueSelectionConfig(universe="IDX.XSHG") # 构造 HIGH 入选 LOW 不入选的场景 s = make_strategy( metrics_map={ "HIGH.XSHG": _make_high_metrics(), "LOW.XSHG": _make_low_metrics(), }, config=cfg, ) s.provider.get_index_stocks.return_value = ["HIGH.XSHG", "LOW.XSHG"] ctx = FakeContext( current_dt=datetime(2024, 10, 8, 9, 30), positions={ "OLD.XSHG": FakePosition("OLD.XSHG", avg_cost=10, price=11), }, ) s.monthly_adjustment(ctx) # OLD 被卖出(order_target_value(code, 0)) sell_calls = [ c for c in s.broker.order_target_value.call_args_list if c.args[1] == 0 ] assert any(c.args[0] == "OLD.XSHG" for c in sell_calls) def test_buys_new_stocks_equal_value(self): """买入 buy_list 里的新股(等额)。 构造 4 只: HIGH_A / HIGH_B 入选, LOW_X / LOW_Y 拉低均值不入。 """ cfg = ValueSelectionConfig(universe="IDX.XSHG") s = make_strategy( metrics_map={ "HA.XSHG": _make_high_metrics(), "HB.XSHG": _make_high_metrics(), "LX.XSHG": _make_low_metrics(), "LY.XSHG": _make_low_metrics(), }, config=cfg, ) s.provider.get_index_stocks.return_value = [ "HA.XSHG", "HB.XSHG", "LX.XSHG", "LY.XSHG", ] ctx = FakeContext( current_dt=datetime(2024, 10, 8, 9, 30), positions={}, cash=1_000_000, ) s.monthly_adjustment(ctx) # HA / HB 被买入(value != 0) buy_calls = [ c for c in s.broker.order_target_value.call_args_list if c.args[1] != 0 ] buy_codes = {c.args[0] for c in buy_calls} assert "HA.XSHG" in buy_codes assert "HB.XSHG" in buy_codes def test_per_value_is_cash_divided_by_target_num(self): """等额: per_value = available_cash / len(buy_list)。""" cfg = ValueSelectionConfig(universe="IDX.XSHG") s = make_strategy( metrics_map={ "HA.XSHG": _make_high_metrics(), "HB.XSHG": _make_high_metrics(), "LX.XSHG": _make_low_metrics(), "LY.XSHG": _make_low_metrics(), }, config=cfg, ) s.provider.get_index_stocks.return_value = [ "HA.XSHG", "HB.XSHG", "LX.XSHG", "LY.XSHG", ] ctx = FakeContext( current_dt=datetime(2024, 10, 8, 9, 30), positions={}, cash=1_000_000, ) s.monthly_adjustment(ctx) buy_calls = [ c for c in s.broker.order_target_value.call_args_list if c.args[1] != 0 ] # 入选 2 只 (HA, HB), per_value = 1_000_000 / 2 = 500_000 for c in buy_calls: assert c.args[1] == pytest.approx(500_000, rel=0.01) # =================== Config 默认值(对齐原策略) =================== class TestConfigDefaults: def test_default_params_match_original(self): """关键阈值与原策略 source.py 第 91-97 行注释 + 第 105-171 行代码一致。""" cfg = ValueSelectionConfig() assert cfg.roe_quarters == 4 assert cfg.fcf_years == 5 assert cfg.revenue_yoy_low == 6.0 assert cfg.revenue_yoy_high == 30.0 assert cfg.revenue_yoy_quarters == 4 # ⚠️ 第 6 条: VPS 实测后改为净利润同比增长率 8~50(原代码 EPS 笔误与 L1 矛盾) assert cfg.earnings_growth_low == 8.0 assert cfg.earnings_growth_high == 50.0 assert cfg.earnings_growth_quarters == 4