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待做,解锁长回测)
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 只
|
|
- ❌ 对冲部分(已删,不测)
|
|
"""
|
|
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,
|
|
price_df_map: Optional[Dict[Any, 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, ...])
|
|
- price_df_map: get_price 按 (security_tuple, fields_tuple, count) 缓存的返回
|
|
"""
|
|
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_price 按 key 缓存
|
|
def _normalize_key(k: Any) -> Any:
|
|
if isinstance(k, tuple) and k and isinstance(k[0], (list, tuple)):
|
|
return (tuple(k[0]),) + tuple(k[1:])
|
|
return k
|
|
|
|
price_df_map = {_normalize_key(k): v for k, v in (price_df_map or {}).items()}
|
|
|
|
def _get_price(security, **kwargs):
|
|
sec_key = tuple(security) if isinstance(security, list) else security
|
|
fields = tuple(kwargs.get("fields") or [])
|
|
if kwargs.get("count") is not None:
|
|
key = (sec_key, fields, kwargs.get("count"))
|
|
else:
|
|
key = (sec_key, fields, kwargs.get("start_date"), kwargs.get("end_date"))
|
|
return price_df_map.get(key, pd.DataFrame())
|
|
|
|
provider.get_price.side_effect = _get_price
|
|
|
|
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_hlc_panel(
|
|
stocks: List[str],
|
|
closes: List[List[float]],
|
|
*,
|
|
highs: Optional[List[List[float]]] = None,
|
|
lows: Optional[List[List[float]]] = None,
|
|
end_date: str = "2024-09-30",
|
|
days: int = 130,
|
|
) -> pd.DataFrame:
|
|
"""构造 panel=False 风格的 close+high+low DataFrame。
|
|
|
|
Args:
|
|
stocks: 股票代码列表
|
|
closes: 每只股票的 close 序列(长度 <= days, 不足重复首值)
|
|
highs: 同 close,None → 取 close
|
|
lows: 同 close,None → 取 close
|
|
days: 总 K 线根数(默认 130)
|
|
"""
|
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
|
dates = [
|
|
(end_dt - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d")
|
|
for i in range(days)
|
|
]
|
|
rows = []
|
|
for idx, code in enumerate(stocks):
|
|
close_list = closes[idx]
|
|
high_list = highs[idx] if highs else close_list
|
|
low_list = lows[idx] if lows else close_list
|
|
c_full = list(close_list) + [close_list[-1]] * (days - len(close_list))
|
|
h_full = list(high_list) + [high_list[-1]] * (days - len(high_list))
|
|
l_full = list(low_list) + [low_list[-1]] * (days - len(low_list))
|
|
for d, c, h, l in zip(dates, c_full, h_full, l_full):
|
|
rows.append({
|
|
"time": pd.Timestamp(d),
|
|
"code": code,
|
|
"close": float(c),
|
|
"high": float(h),
|
|
"low": float(l),
|
|
})
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
# =================== 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 全 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
|
|
|
|
df = _make_hlc_panel(
|
|
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"],
|
|
[flat, rising, falling],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s = make_strategy(price_df_map={
|
|
(("FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"), ("close", "high", "low"), 130): df,
|
|
})
|
|
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: cur=22.9, low=10, high=22.9, ma15=mean([21.5..22.9])≈22.2
|
|
# score = (22.9-10) + (22.9-22.9) + (22.9-22.2) = 12.9 + 0 + 0.7 ≈ 13.6
|
|
assert out.loc["UP.XSHG", "score"] > 0
|
|
# DOWN: cur=10.1, low=10.1, high=23, ma15≈10.8
|
|
# score = (10.1-10.1) + (10.1-23) + (10.1-10.8) ≈ 0 + (-12.9) + (-0.7) ≈ -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)]
|
|
df = _make_hlc_panel(
|
|
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"],
|
|
[flat, rising, falling],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s = make_strategy(price_df_map={
|
|
(("FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"), ("close", "high", "low"), 130): df,
|
|
})
|
|
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_price 返回空 DataFrame
|
|
s.provider.get_price.side_effect = None
|
|
s.provider.get_price.return_value = pd.DataFrame()
|
|
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 price 给所有候选相同 close,看最终名单
|
|
df = _make_hlc_panel(
|
|
["A.XSHG", "D.XSHG"], [[10.0] * 130, [10.0] * 130],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
# _pick_stocks 的 get_price 入参可能是 list 形式
|
|
s.provider.get_price.side_effect = None
|
|
s.provider.get_price.return_value = df
|
|
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,
|
|
)
|
|
df = _make_hlc_panel(
|
|
["SMALL.XSHG", "MID.XSHG"],
|
|
[[10.0] * 130, [10.0] * 130],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s.provider.get_price.side_effect = None
|
|
s.provider.get_price.return_value = df
|
|
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]
|
|
df = _make_hlc_panel(stocks, closes, end_date="2024-09-30", days=130)
|
|
s.provider.get_price.side_effect = None
|
|
s.provider.get_price.return_value = df
|
|
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)]
|
|
df = _make_hlc_panel(
|
|
["DOWN.XSHG", "FLAT.XSHG", "UP.XSHG"],
|
|
[falling, flat, rising], end_date="2024-09-30", days=130,
|
|
)
|
|
s.provider.get_price.side_effect = None
|
|
s.provider.get_price.return_value = df
|
|
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_hlc_panel(["NEW.XSHG"], [[10.0] * 130], end_date="2024-09-30", days=130)
|
|
s.provider.get_price.side_effect = None
|
|
s.provider.get_price.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_hlc_panel(
|
|
["A.XSHG", "B.XSHG"], [[10.0] * 130, [10.0] * 130],
|
|
end_date="2024-09-30", days=130,
|
|
)
|
|
s.provider.get_price.side_effect = None
|
|
s.provider.get_price.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")
|