Files
sanguo_vnpy_v2/tests/portfolio/test_value_selection.py
T
claude_dev 8862816557 feat(portfolio): B fundamentals批量 + C涨跌停filter修复(get_limit_status_batch接入)
B: value_selection 逐只 get_value_metrics → get_value_metrics_batch(数据session)
- 01 验证 -21.85% vs 改前 -21.63%(微差0.22%, batch实现微差,可接受)

C: filters filter_limitup/limitdown/paused 接入 get_limit_status_batch(数据session)
- 修复回测死代码: filter 取 tick.get(last_price/paused) 恒None → 照买涨停/照卖跌停/照交易停牌
- 三策略调仓预取 status_map 共享一次查询, 向后兼容 all_weather(不传参=原行为)
- 03 短区间(2024Q1)验证: C前+138.7%虚高 → C后+101.6%, filter修复减少照买涨停虚增

验收: 101单测(filters 30含14新status_map口径 + 三策略71)
注意: get_limit_status_batch 44s/800只(数据session待批量化优化), 02/03全周期待优化后
2026-07-30 07:37:03 +08:00

552 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
# 策略 _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 & <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_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