Files
sanguo_vnpy_v2/tests/portfolio/test_small_cap.py
T
claude_dev de04a8904b feat(portfolio): 移植3聚宽策略到BulletTrade + 8bug修正 + 数据缺口文档
三策略(聚宽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待做,解锁长回测)
2026-07-28 22:20:49 +08:00

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")