8862816557
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全周期待优化后
565 lines
23 KiB
Python
565 lines
23 KiB
Python
"""SmallCapStrategy 单元测试(mock provider + mock broker)。
|
|
|
|
策略层只测**逻辑分支正确**(选股排序 / eps 过滤 / 创业板过滤 / 动量评分 / 5 日周期 / 调仓),
|
|
不测真实数据。真实数据回测在 VPS 跑。
|
|
|
|
⚠️ 移植验证范围:
|
|
- ✅ 选股排序:市值最小 100 只(过滤 eps≤0 / 创业板 300xxx / 上市<120 天)
|
|
- ✅ 动量评分公式:(cur-low_130) + (cur-high_130) + (cur-ma15),升序
|
|
- ✅ 5 日调仓周期:day_count % tc == 0 时选股+调仓,其他日 no-op
|
|
- ✅ 等权 20 只
|
|
- ❌ 对冲部分(已删,不测)
|
|
|
|
性能改造后:策略用 ``get_closes_panel`` 批量取 close 宽表(替代 get_price+逐只循环),
|
|
**用 close.min/max 代理 low/high**(有意决策,见 small_cap._cal_momentum_score docstring)。
|
|
测试 mock 同步切到 ``get_closes_panel`` 返回 close 宽表。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
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.small_cap import (
|
|
SmallCapConfig,
|
|
SmallCapStrategy,
|
|
)
|
|
from tests.portfolio.conftest import FakeContext, FakePosition
|
|
|
|
|
|
# ------------------------ 测试 helper ------------------------
|
|
def make_strategy(
|
|
*,
|
|
universe_stocks: Optional[List[str]] = None,
|
|
fundamentals_df: Optional[pd.DataFrame] = None,
|
|
panel_map: Optional[Dict[Any, pd.DataFrame]] = None,
|
|
default_panel: Optional[pd.DataFrame] = None,
|
|
config: Optional[SmallCapConfig] = None,
|
|
) -> SmallCapStrategy:
|
|
"""构造一个 mock provider + mock broker 驱动的策略。
|
|
|
|
- universe_stocks: get_index_stocks(universe, date) 返回的全市场候选列表
|
|
- fundamentals_df: get_fundamentals_df 返回(index=code, cols=[market_cap, eps, ...])
|
|
- panel_map: get_closes_panel 按 (symbols_tuple,) 缓存的宽表返回
|
|
- default_panel: 所有未命中 panel_map 的查询返回的默认宽表(便于 _pick_stocks 流程测试)
|
|
"""
|
|
provider = MagicMock(name="provider")
|
|
|
|
universe_stocks = universe_stocks or []
|
|
|
|
def _get_index_stocks(index_symbol, date=None):
|
|
return list(universe_stocks)
|
|
|
|
provider.get_index_stocks.side_effect = _get_index_stocks
|
|
|
|
# get_security_info(filter_st/filter_new 默认放过)
|
|
provider.get_security_info.return_value = {
|
|
"display_name": "NORMAL",
|
|
"name": "600519",
|
|
"start_date": datetime(2000, 1, 1),
|
|
}
|
|
# get_live_current:不停牌不涨跌停
|
|
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,
|
|
}
|
|
|
|
# get_fundamentals_df
|
|
if fundamentals_df is not None:
|
|
provider.get_fundamentals_df.return_value = fundamentals_df
|
|
else:
|
|
provider.get_fundamentals_df.return_value = pd.DataFrame()
|
|
|
|
# get_closes_panel 按 key 缓存:支持 (tuple(symbols),) 通配与精确 (tuple, start, end)
|
|
def _normalize_syms(k: Any) -> Any:
|
|
if isinstance(k, tuple) and k and isinstance(k[0], (list, tuple)):
|
|
return (tuple(k[0]),) + tuple(k[1:])
|
|
return k
|
|
|
|
panel_map = {_normalize_syms(k): v for k, v in (panel_map or {}).items()}
|
|
|
|
def _get_closes_panel(symbols, start=None, end=None, interval="d", fq="raw"):
|
|
syms_key = tuple(symbols) if isinstance(symbols, list) else symbols
|
|
exact = panel_map.get((syms_key, start, end))
|
|
if exact is not None:
|
|
return exact
|
|
fallback = panel_map.get((syms_key,))
|
|
if fallback is not None:
|
|
return fallback
|
|
if default_panel is not None:
|
|
return default_panel
|
|
return pd.DataFrame(index=pd.DatetimeIndex([]))
|
|
|
|
provider.get_closes_panel.side_effect = _get_closes_panel
|
|
|
|
# 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()
|
|
|
|
return SmallCapStrategy(provider=provider, broker=broker, config=config)
|
|
|
|
|
|
def _make_fundamentals_df(
|
|
stocks_with_cap_eps: List[tuple[str, float, float]],
|
|
) -> pd.DataFrame:
|
|
"""构造 fundamentals DataFrame(index=code, cols=[code, market_cap, eps])。
|
|
|
|
Args:
|
|
stocks_with_cap_eps: [(code, market_cap_亿, eps), ...]
|
|
"""
|
|
rows = [
|
|
{"code": c, "market_cap": cap, "eps": eps}
|
|
for c, cap, eps in stocks_with_cap_eps
|
|
]
|
|
df = pd.DataFrame(rows, columns=["code", "market_cap", "eps"])
|
|
return df.set_index("code", drop=False)
|
|
|
|
|
|
def _make_close_wide(
|
|
stocks: List[str],
|
|
closes: List[List[float]],
|
|
*,
|
|
end_date: str = "2024-09-30",
|
|
days: int = 130,
|
|
) -> pd.DataFrame:
|
|
"""构造 get_closes_panel 风格的 close 宽表 (index=DatetimeIndex, columns=stocks)。
|
|
|
|
Args:
|
|
stocks: 股票代码列表(列名)
|
|
closes: 每只股票的 close 序列(长度 <= days, 不足重复首值)
|
|
days: 总 K 线根数(默认 130)
|
|
"""
|
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
|
dates = pd.DatetimeIndex([
|
|
end_dt - timedelta(days=days - 1 - i) for i in range(days)
|
|
])
|
|
data: Dict[str, List[float]] = {}
|
|
for code, close_list in zip(stocks, closes):
|
|
full = list(close_list) + [close_list[-1]] * (days - len(close_list))
|
|
data[code] = [float(c) for c in full]
|
|
return pd.DataFrame(data, index=dates)
|
|
|
|
|
|
# =================== initialize ===================
|
|
class TestInitialize:
|
|
def test_initialize_registers_daily_handle_data(self, fake_context):
|
|
s = make_strategy()
|
|
s.initialize(fake_context)
|
|
assert s.broker.run_daily.called
|
|
first_call = s.broker.run_daily.call_args_list[0]
|
|
assert first_call.args[0].__name__ == "handle_data"
|
|
assert first_call.args[1] == "9:30"
|
|
|
|
def test_initialize_sets_benchmark(self, fake_context):
|
|
cfg = SmallCapConfig(benchmark="000300.XSHG")
|
|
s = make_strategy(config=cfg)
|
|
s.initialize(fake_context)
|
|
s.broker.set_benchmark.assert_called_with("000300.XSHG")
|
|
|
|
|
|
# =================== Config 默认值(对齐原策略) ===================
|
|
class TestConfigDefaults:
|
|
def test_default_params_match_original(self):
|
|
"""关键参数与原策略 source.py set_params 一致。"""
|
|
cfg = SmallCapConfig()
|
|
assert cfg.tc == 5 # g.tc
|
|
assert cfg.pick_stock_count == 100 # g.pick_stock_count
|
|
assert cfg.buy_stock_count == 20 # g.buy_stock_count
|
|
assert cfg.ma_window == 130 # attribute_history(stock, 130, ...)
|
|
assert cfg.ma_short == 15 # data[stock].mavg(15, 'close')
|
|
assert cfg.new_stock_days == 120 # 原策略 120 天过滤
|
|
|
|
def test_default_universe_is_csi_allshare(self):
|
|
"""✅ universe 默认是 000985.XSHG(中证全指 5128 只),G2 补全后切回原版。
|
|
|
|
此前 000985 不在 constituent_unified 降级用 932000(中证2000);2026-07-28 G2
|
|
补全 000985 后切回,恢复原策略"全市场市值最小100"意图。
|
|
"""
|
|
cfg = SmallCapConfig()
|
|
assert cfg.universe == "000985.XSHG"
|
|
# 防回退到 932000(降级版)
|
|
assert cfg.universe != "932000.XSHG"
|
|
|
|
|
|
# =================== _stock_pool (创业板/科创北交过滤) ===================
|
|
class TestStockPool:
|
|
def test_filter_kcbj_excluded(self):
|
|
"""创业板 300xxx / 科创 688xxx / 北交 8/4 开头都被剔除。"""
|
|
s = make_strategy(universe_stocks=[
|
|
"600519.XSHG", # 沪市主板 - 保留
|
|
"000001.XSHE", # 深市主板 - 保留
|
|
"300001.XSHE", # 创业板 - 剔除
|
|
"688001.XSHG", # 科创板 - 剔除
|
|
"830001.XSHG", # 北交 - 剔除
|
|
"430001.XSHG", # 北交 - 剔除
|
|
])
|
|
out = s._stock_pool("ANY.XSHG", "2024-09-30")
|
|
assert set(out) == {"600519.XSHG", "000001.XSHE"}
|
|
assert "300001.XSHE" not in out
|
|
assert "688001.XSHG" not in out
|
|
|
|
def test_max_pool_limits_count(self):
|
|
"""max_pool > 0 时截断候选池前 N 只。"""
|
|
s = make_strategy(
|
|
universe_stocks=[f"60000{i}.XSHG" for i in range(10)],
|
|
config=SmallCapConfig(max_pool=3),
|
|
)
|
|
out = s._stock_pool("ANY.XSHG", "2024-09-30")
|
|
assert len(out) == 3
|
|
|
|
|
|
# =================== _cal_momentum_score (动量评分) ===================
|
|
class TestCalMomentumScore:
|
|
def test_empty_input_returns_empty(self):
|
|
s = make_strategy()
|
|
out = s._cal_momentum_score([], end_date="2024-09-30")
|
|
assert out.empty
|
|
|
|
def test_score_formula_is_cur_minus_low_high_ma15(self):
|
|
"""score = (cur-low_130) + (cur-high_130) + (cur-ma15)。
|
|
|
|
改造后用 close.min/max 代理 low/high(有意决策,见 small_cap docstring)。
|
|
构造已知序列验证公式:
|
|
- close 全 10(平):low=high=ma15=10,cur=10,score=0
|
|
- close 上升:cur>low/high/ma15 → score 正
|
|
- close 下降:cur<low/high/ma15 → score 负
|
|
"""
|
|
flat = [10.0] * 130
|
|
rising = [10.0 + i * 0.1 for i in range(130)] # 10→22.9,cur=22.9
|
|
falling = [23.0 - i * 0.1 for i in range(130)] # 23→10.1,cur=10.1
|
|
|
|
panel = _make_close_wide(
|
|
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"],
|
|
[flat, rising, falling],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s = make_strategy(panel_map={
|
|
(("FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"),): panel,
|
|
})
|
|
out = s._cal_momentum_score(
|
|
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"], end_date="2024-09-30",
|
|
)
|
|
|
|
# FLAT: score = 0(全部相同)
|
|
assert out.loc["FLAT.XSHG", "score"] == pytest.approx(0.0, abs=0.01)
|
|
# UP: 用 close 代理后 low=10(close min)、high=22.9(close max)、
|
|
# ma15=mean([21.5..22.9])≈22.2,score = (22.9-10) + (22.9-22.9) + (22.9-22.2) ≈ 13.6
|
|
assert out.loc["UP.XSHG", "score"] > 0
|
|
# DOWN: 用 close 代理后 low=10.1、high=23、ma15≈10.8
|
|
# score = (10.1-10.1) + (10.1-23) + (10.1-10.8) ≈ -13.6
|
|
assert out.loc["DOWN.XSHG", "score"] < 0
|
|
|
|
def test_score_sorted_ascending(self):
|
|
"""升序:分数低的排前(原策略 df.sort ascending=True)。"""
|
|
flat = [10.0] * 130
|
|
rising = [10.0 + i * 0.1 for i in range(130)]
|
|
falling = [23.0 - i * 0.1 for i in range(130)]
|
|
panel = _make_close_wide(
|
|
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"],
|
|
[flat, rising, falling],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s = make_strategy(panel_map={
|
|
(("FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"),): panel,
|
|
})
|
|
out = s._cal_momentum_score(
|
|
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"], end_date="2024-09-30",
|
|
)
|
|
# 升序:DOWN(负) < FLAT(0) < UP(正)
|
|
assert list(out.index) == ["DOWN.XSHG", "FLAT.XSHG", "UP.XSHG"]
|
|
|
|
def test_insufficient_data_skipped(self):
|
|
"""K 线序列不足/空 → 该股跳过(不在结果里)。"""
|
|
s = make_strategy()
|
|
# 让 provider.get_closes_panel 返回空 DataFrame
|
|
s.provider.get_closes_panel.side_effect = None
|
|
s.provider.get_closes_panel.return_value = pd.DataFrame(index=pd.DatetimeIndex([]))
|
|
out = s._cal_momentum_score(["EMPTY.XSHG"], end_date="2024-09-30")
|
|
assert out.empty
|
|
|
|
|
|
# =================== _pick_stocks (主选股流程) ===================
|
|
class TestPickStocks:
|
|
def test_empty_universe_returns_empty(self):
|
|
s = make_strategy(universe_stocks=[])
|
|
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
|
|
assert s._pick_stocks(ctx) == []
|
|
|
|
def test_filters_stocks_with_eps_le_zero(self):
|
|
"""eps ≤ 0 的股票被剔除(原策略 indicator.eps > 0)。"""
|
|
# 4 只股,eps 分别为 0.5(过) / -0.1(剔) / 0(剔,严格>) / 0.3(过)
|
|
# market_cap 都一样保证不卡排序
|
|
fund = _make_fundamentals_df([
|
|
("A.XSHG", 10.0, 0.5),
|
|
("B.XSHG", 11.0, -0.1),
|
|
("C.XSHG", 12.0, 0.0),
|
|
("D.XSHG", 13.0, 0.3),
|
|
])
|
|
s = make_strategy(universe_stocks=["A.XSHG", "B.XSHG", "C.XSHG", "D.XSHG"],
|
|
fundamentals_df=fund)
|
|
# 不传 price → _cal_momentum_score 会拿到空 df → 结果可能为空
|
|
# 我们只验证 eps 过滤生效:在 fundamentals 过滤后 top_candidates 不含 B/C
|
|
# 直接调 _pick_stocks 会因 price 空导致评分为空 → 返回空
|
|
# 这里通过 mock get_closes_panel 给所有候选相同 close,看最终名单
|
|
panel = _make_close_wide(
|
|
["A.XSHG", "D.XSHG"], [[10.0] * 130, [10.0] * 130],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s.provider.get_closes_panel.side_effect = None
|
|
s.provider.get_closes_panel.return_value = panel
|
|
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
|
|
out = s._pick_stocks(ctx)
|
|
# eps>0 的 A/D 都进入候选,B/C 被剔
|
|
assert "B.XSHG" not in out
|
|
assert "C.XSHG" not in out
|
|
# A/D 都在最终名单(因 score 相同,顺序由 sort_values 保留)
|
|
assert set(out) == {"A.XSHG", "D.XSHG"} or set(out).issubset({"A.XSHG", "D.XSHG"})
|
|
|
|
def test_sorts_by_market_cap_asc_takes_top100(self):
|
|
"""按 market_cap 升序取前 pick_stock_count。"""
|
|
# 3 只股,市值依次升序,eps 都 > 0
|
|
fund = _make_fundamentals_df([
|
|
("SMALL.XSHG", 5.0, 0.3), # 最小,必入
|
|
("MID.XSHG", 50.0, 0.3),
|
|
("BIG.XSHG", 500.0, 0.3), # 最大,在 pick_stock_count=2 时被剔
|
|
])
|
|
cfg = SmallCapConfig(pick_stock_count=2, buy_stock_count=2)
|
|
s = make_strategy(
|
|
universe_stocks=["SMALL.XSHG", "MID.XSHG", "BIG.XSHG"],
|
|
fundamentals_df=fund,
|
|
config=cfg,
|
|
)
|
|
panel = _make_close_wide(
|
|
["SMALL.XSHG", "MID.XSHG"],
|
|
[[10.0] * 130, [10.0] * 130],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s.provider.get_closes_panel.side_effect = None
|
|
s.provider.get_closes_panel.return_value = panel
|
|
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
|
|
out = s._pick_stocks(ctx)
|
|
# market_cap 升序后前 2 只 = SMALL/MID,BIG 被剔
|
|
assert "BIG.XSHG" not in out
|
|
assert "SMALL.XSHG" in out
|
|
assert "MID.XSHG" in out
|
|
|
|
def test_takes_buy_stock_count_from_scored(self):
|
|
"""动量评分后取前 buy_stock_count 只(默认 20)。"""
|
|
# 构造 25 只股,确保 buy_stock_count=20 截断
|
|
stocks = [f"S{i:03d}.XSHG" for i in range(25)]
|
|
fund = _make_fundamentals_df([
|
|
(c, float(i + 1), 0.3) for i, c in enumerate(stocks)
|
|
])
|
|
cfg = SmallCapConfig(pick_stock_count=25, buy_stock_count=20)
|
|
s = make_strategy(
|
|
universe_stocks=stocks, fundamentals_df=fund, config=cfg,
|
|
)
|
|
# 所有股票 close 相同 → score 相同 → 顺序由 sort_values stable 决定
|
|
closes = [[10.0] * 130 for _ in stocks]
|
|
panel = _make_close_wide(stocks, closes, end_date="2024-09-30", days=130)
|
|
s.provider.get_closes_panel.side_effect = None
|
|
s.provider.get_closes_panel.return_value = panel
|
|
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
|
|
out = s._pick_stocks(ctx)
|
|
assert len(out) == 20
|
|
|
|
def test_momentum_score_ranks_low_first(self):
|
|
"""动量评分升序:分数低(底部反弹)的优先入选。"""
|
|
# 3 只候选,close 走势不同:
|
|
# DOWN: 持续下跌 → score 最负(最优先)
|
|
# FLAT: 平盘 → score = 0
|
|
# UP: 持续上涨 → score 最正(最后)
|
|
# buy_stock_count=2 时,DOWN/FLAT 入选,UP 被剔
|
|
fund = _make_fundamentals_df([
|
|
("DOWN.XSHG", 10.0, 0.3),
|
|
("FLAT.XSHG", 11.0, 0.3),
|
|
("UP.XSHG", 12.0, 0.3),
|
|
])
|
|
cfg = SmallCapConfig(pick_stock_count=3, buy_stock_count=2)
|
|
s = make_strategy(
|
|
universe_stocks=["DOWN.XSHG", "FLAT.XSHG", "UP.XSHG"],
|
|
fundamentals_df=fund, config=cfg,
|
|
)
|
|
flat = [10.0] * 130
|
|
rising = [10.0 + i * 0.1 for i in range(130)]
|
|
falling = [23.0 - i * 0.1 for i in range(130)]
|
|
panel = _make_close_wide(
|
|
["DOWN.XSHG", "FLAT.XSHG", "UP.XSHG"],
|
|
[falling, flat, rising], end_date="2024-09-30", days=130,
|
|
)
|
|
s.provider.get_closes_panel.side_effect = None
|
|
s.provider.get_closes_panel.return_value = panel
|
|
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
|
|
out = s._pick_stocks(ctx)
|
|
# 顺序:DOWN(score 最负) → FLAT(0),UP 被剔
|
|
assert out[0] == "DOWN.XSHG"
|
|
assert "UP.XSHG" not in out
|
|
|
|
|
|
# =================== handle_data (5 日调仓周期) ===================
|
|
class TestHandleDataPeriod:
|
|
def test_first_day_is_rebalance_day(self):
|
|
"""day_count=0 → 0%5=0 → 调仓日(对齐原策略 g.t=0 时调仓)。"""
|
|
cfg = SmallCapConfig(tc=5)
|
|
s = make_strategy(config=cfg)
|
|
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30), cash=1_000_000)
|
|
s.handle_data(ctx)
|
|
assert s.day_count == 1 # 调仓后 +1
|
|
# in_position_stocks 被赋值(pick_stocks 调用过,即使返回空也是赋值)
|
|
assert isinstance(s.in_position_stocks, list)
|
|
|
|
def test_non_rebalance_day_no_trade(self):
|
|
"""day_count=1..4 → 1%5..4%5 != 0 → 不调仓,持仓不变。"""
|
|
cfg = SmallCapConfig(tc=5)
|
|
s = make_strategy(config=cfg)
|
|
# 预置持仓名单(模拟上一次调仓的状态)
|
|
s.in_position_stocks = ["PREV1.XSHG", "PREV2.XSHG"]
|
|
s.day_count = 1
|
|
ctx = FakeContext(
|
|
current_dt=datetime(2024, 10, 9, 9, 30),
|
|
positions={"PREV1.XSHG": FakePosition("PREV1.XSHG", 10, 11)},
|
|
cash=1_000_000,
|
|
)
|
|
s.handle_data(ctx)
|
|
# 非调仓日:pick_stocks 未被调用,in_position_stocks 不变
|
|
assert s.in_position_stocks == ["PREV1.XSHG", "PREV2.XSHG"]
|
|
# 没有下单
|
|
assert not s.broker.order_target_value.called
|
|
|
|
def test_period_5_triggers_rebalance_every_5_days(self):
|
|
"""tc=5 → 每 5 个交易日触发一次选股调仓。"""
|
|
cfg = SmallCapConfig(tc=5)
|
|
s = make_strategy(config=cfg)
|
|
# 模拟 11 个交易日,应在 day_count=0,5,10 触发
|
|
rebalance_days = []
|
|
for _ in range(11):
|
|
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30), cash=1_000_000)
|
|
before = s.day_count
|
|
is_rebal = (before % cfg.tc) == 0
|
|
if is_rebal:
|
|
rebalance_days.append(before)
|
|
s.handle_data(ctx)
|
|
# day 0, 5, 10 是调仓日
|
|
assert rebalance_days == [0, 5, 10]
|
|
|
|
|
|
# =================== handle_data (调仓行为) ===================
|
|
class TestHandleDataRebalance:
|
|
def test_sells_positions_not_in_target(self):
|
|
"""调仓时卖出不在新名单的持仓。"""
|
|
fund = _make_fundamentals_df([
|
|
("NEW.XSHG", 5.0, 0.3),
|
|
])
|
|
cfg = SmallCapConfig(pick_stock_count=1, buy_stock_count=1)
|
|
s = make_strategy(
|
|
universe_stocks=["NEW.XSHG"],
|
|
fundamentals_df=fund,
|
|
config=cfg,
|
|
)
|
|
df = _make_close_wide(["NEW.XSHG"], [[10.0] * 130], end_date="2024-09-30", days=130)
|
|
s.provider.get_closes_panel.side_effect = None
|
|
s.provider.get_closes_panel.return_value = df
|
|
ctx = FakeContext(
|
|
current_dt=datetime(2024, 10, 8, 9, 30),
|
|
positions={
|
|
"OLD.XSHG": FakePosition("OLD.XSHG", avg_cost=10, price=11),
|
|
},
|
|
cash=1_000_000,
|
|
)
|
|
s.handle_data(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):
|
|
"""等额买入名单中的新股(等权 = cash / buy_stock_count)。"""
|
|
# 构造 2 只候选,都入选
|
|
fund = _make_fundamentals_df([
|
|
("A.XSHG", 5.0, 0.3),
|
|
("B.XSHG", 6.0, 0.3),
|
|
])
|
|
cfg = SmallCapConfig(pick_stock_count=2, buy_stock_count=2)
|
|
s = make_strategy(
|
|
universe_stocks=["A.XSHG", "B.XSHG"],
|
|
fundamentals_df=fund,
|
|
config=cfg,
|
|
)
|
|
df = _make_close_wide(
|
|
["A.XSHG", "B.XSHG"], [[10.0] * 130, [10.0] * 130],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s.provider.get_closes_panel.side_effect = None
|
|
s.provider.get_closes_panel.return_value = df
|
|
ctx = FakeContext(
|
|
current_dt=datetime(2024, 10, 8, 9, 30),
|
|
positions={}, cash=1_000_000,
|
|
)
|
|
s.handle_data(ctx)
|
|
# A / B 都被买入(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 "A.XSHG" in buy_codes
|
|
assert "B.XSHG" in buy_codes
|
|
# 等额:per_value = 1_000_000 / 2 = 500_000
|
|
for c in buy_calls:
|
|
assert c.args[1] == pytest.approx(500_000, rel=0.01)
|
|
|
|
|
|
# =================== 移植差异验证(原策略对照) ===================
|
|
class TestPortingDifferences:
|
|
"""验证移植后的"无对冲"差异点(确保对冲逻辑被正确去掉)。"""
|
|
|
|
def test_no_subportfolio_attribute(self):
|
|
"""策略实例不应有 SubPortfolio / 期货相关属性。"""
|
|
s = make_strategy()
|
|
assert not hasattr(s, "subportfolios")
|
|
assert not hasattr(s, "pre_future")
|
|
assert not hasattr(s, "futures_margin_rate")
|
|
assert not hasattr(s, "futures_symbol")
|
|
|
|
def test_no_statsmodels_import(self):
|
|
"""模块不应 import statsmodels(原代码 import 但未实际用)。"""
|
|
import sanguo_portfolio.strategies.small_cap as mod
|
|
assert "statsmodels" not in dir(mod)
|
|
# sys.modules 不应有 statsmodels.regression(由 small_cap 间接 import 的)
|
|
# 注意:其他模块可能 import statsmodels,只验证 small_cap 不引用
|
|
|
|
def test_rebalance_does_not_call_transfer_cash(self):
|
|
"""_rebalance 不应调用 transfer_cash(原策略双账户调配已删)。"""
|
|
s = make_strategy()
|
|
# broker 没暴露 transfer_cash(BrokerFacade 无此字段)
|
|
assert not hasattr(s.broker, "transfer_cash")
|
|
|
|
def test_handle_data_no_hedge_logic(self):
|
|
"""handle_data 主流程只做选股+调仓,不调 compute_hedge_ratio。"""
|
|
s = make_strategy()
|
|
# 策略实例没有 _compute_hedge_ratio 方法
|
|
assert not hasattr(s, "_compute_hedge_ratio")
|
|
assert not hasattr(s, "_get_next_month_future")
|