de04a8904b
三策略(聚宽py2→BulletTrade 0.9.2,BrokerFacade注入跨版本兼容): - momentum_timing 动量择时(牛熊分界+行业RPS+均线,切回10中证行业指数) - value_selection 价值精选(6条基本面过滤,切回沪深300) - small_cap 小市值(去IC对冲,切回000985中证全指) 框架: - runner_backtest 加 --strategy 分发(原硬编码all_weather) - provider 加 get_value_metrics(价值精选6条基本面,NOTICE_DATE治前视偏差) - 72单测全过(21+27+24) 修8个回测实测发现的真bug: - 01第⑥条EPS绝对值0.08~0.5与①大盘矛盾→6条交集恒空致全程空仓,按注释本意改净利润同比8~50% - 03原帖calRPS取数区间错(get_price start=end只取1天)→涨跌幅恒0 RPS失效;date.today()取真实今天非回测日 - 02 universe 000985不在constituent_unified→候选池空 VPS实测(短区间验证逻辑,非长期表现): 01价值+23%/03行业轮动+48%/02选出20只小盘 数据缺口(详见docs/research/joinquant_strategies/SUMMARY.md + data_gaps_fix_plan.md): - 三表"1/3损坏"误报已撤回(全扫5530文件/表0损坏,沪深95%+健康,仅北交所920xxx空,不做北交所) - 真实缺口: 行业成份股(G1已补)/000985(G2已补)/IC期货(02对冲去掉)/provider批量接口(G5待做,解锁长回测)
546 lines
23 KiB
Python
546 lines
23 KiB
Python
"""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
|
|
provider.get_index_stocks.return_value = []
|
|
provider.get_security_info.return_value = {
|
|
"display_name": "NORMAL",
|
|
"name": "600519",
|
|
"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,
|
|
}
|
|
provider.get_current_tick.return_value = {
|
|
"paused": False, "last_price": 10.0,
|
|
"high_limit": 11.0, "low_limit": 9.0,
|
|
}
|
|
|
|
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 & <high`` 严格不等式: 6.0/30.0 边界不过。"""
|
|
s = make_strategy(metrics_map={
|
|
"EDGE6.XSHG": _make_high_metrics(revenue_yoy_series=[6.0, 15, 15, 15]),
|
|
"EDGE30.XSHG": _make_high_metrics(revenue_yoy_series=[30.0, 15, 15, 15]),
|
|
"IN.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 15]),
|
|
"LOW.XSHG": _make_low_metrics(),
|
|
})
|
|
out = s._get_stock_list(
|
|
["EDGE6.XSHG", "EDGE30.XSHG", "IN.XSHG", "LOW.XSHG"], "2024-09-30",
|
|
)
|
|
assert "EDGE6.XSHG" not in out
|
|
assert "EDGE30.XSHG" not in out
|
|
assert "IN.XSHG" in out
|
|
|
|
# ----- L6: 近 4 季净利润同比增长率 8%~50% -----
|
|
def test_L6_netprofit_yoy_must_be_8_to_50_all_quarters(self):
|
|
"""⚠️ 修正 VPS 实测 bug:原代码用 EPS 绝对值 0.08~0.5 与 L1 矛盾(大盘股 EPS 普遍 >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 & <high`` 严格不等式: 8.0/50.0 边界不过。"""
|
|
s = make_strategy(metrics_map={
|
|
"EDGE8.XSHG": _make_high_metrics(netprofit_yoy_series=[8.0, 20, 20, 20]),
|
|
"EDGE50.XSHG": _make_high_metrics(netprofit_yoy_series=[50.0, 20, 20, 20]),
|
|
"IN.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 20]),
|
|
"LOW.XSHG": _make_low_metrics(),
|
|
})
|
|
out = s._get_stock_list(
|
|
["EDGE8.XSHG", "EDGE50.XSHG", "IN.XSHG", "LOW.XSHG"], "2024-09-30",
|
|
)
|
|
assert "EDGE8.XSHG" not in out
|
|
assert "EDGE50.XSHG" not in out
|
|
assert "IN.XSHG" in out
|
|
|
|
# ----- 交集语义 -----
|
|
def test_intersection_of_all_6_filters(self):
|
|
"""全部 6 条都过才入选(HIGH 入选, LOW 全部不过)。"""
|
|
s = make_strategy(metrics_map={
|
|
"HIGH.XSHG": _make_high_metrics(),
|
|
"LOW.XSHG": _make_low_metrics(),
|
|
})
|
|
out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30")
|
|
assert "HIGH.XSHG" in out
|
|
assert "LOW.XSHG" not in out
|
|
|
|
|
|
# =================== pd.Panel 改写后的多期对齐 ===================
|
|
class TestMultiPeriodAlignment:
|
|
"""原策略用 ``pd.Panel`` 做多期对齐, 移植后改为 ``dict[field, list]``。
|
|
验证多期对齐语义正确。"""
|
|
|
|
def test_roe_per_quarter_market_mean_comparison(self):
|
|
"""L3: 每季分别比较市场均值,不是整体均值。
|
|
|
|
反例: BADQ3 整体 ROE 大部分高,但第3季 ROE 低于该季市场均值 → 第3季被剔 → 整体被剔。
|
|
LOW 在场拉低均值,让 HIGH/BADQ3 在 L1/L2/其他季能过。
|
|
"""
|
|
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.3, 0.3, 0.001, 0.3]),
|
|
"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_per_quarter_filter_is_intersection(self):
|
|
"""L3 是 4 季的交集(每季都 > 才过)。"""
|
|
# 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(stock, date)。"""
|
|
captured_dates: List[Any] = []
|
|
|
|
def _capture(stock, date):
|
|
captured_dates.append(date)
|
|
return _make_high_metrics()
|
|
|
|
provider = MagicMock()
|
|
provider.get_value_metrics.side_effect = _capture
|
|
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,
|
|
}
|
|
|
|
# 让第二只 metrics 全空, 这样均值 = HIGH 自身, HIGH 不过(均值=自身)
|
|
# 改为返回 LOW metrics 拉低均值, HIGH 才能过
|
|
provider.get_value_metrics.side_effect = lambda stock, date: (
|
|
_make_high_metrics() if "HIGH" in stock else _make_low_metrics()
|
|
)
|
|
s = ValueSelectionStrategy(provider=provider, broker=BrokerFacade())
|
|
s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30")
|
|
# provider 收到的 date 应是 "2024-09-30"(由策略层传过去)
|
|
# 验证 side_effect 被调用时收到 date 参数
|
|
assert provider.get_value_metrics.called
|
|
for call in provider.get_value_metrics.call_args_list:
|
|
# call.args = (stock, date) 或 call.args = (stock,) + 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_raises_stock_excluded(self):
|
|
"""provider 异常 → 跳过,不污染整批。"""
|
|
provider = MagicMock()
|
|
# HIGH 正常, LOW 抛异常
|
|
def _gnm(stock, date=None):
|
|
if "LOW" in stock:
|
|
raise RuntimeError("三表损坏")
|
|
return _make_low_metrics()
|
|
|
|
provider.get_value_metrics.side_effect = _gnm
|
|
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
|