Files
sanguo_vnpy_v2/tests/portfolio/test_small_cap.py
T
claude_dev 32dbcb8958 feat(portfolio): P2/P3 策略层向量化 + fundamentals批量提速解锁长回测
P2 行情向量化(get_price→get_closes_panel,口径实证 max_abs_diff=0.0 零偏差):
- momentum_timing: _cal_rps/_select_stocks/_cal_buy_sign 三处向量化
- small_cap: _cal_momentum_score 用 close.min/max 代理 low/high(方案A)

P3 fundamentals 批量(small_cap _pick_stocks 加 fields=[market_cap,eps],
对接数据session f416a17 get_fundamentals_df 按需短路):
- 02 000985 全市场 5128只 ~19min卡死 → 195s 跑通解锁

验收: 72单测全过; VPS 03短回测+138%(口径与get_price一致diff=0)/
02全市场-11%(2024Q1小盘股灾期合理)/01沪深300可跑
2026-07-29 07:49:35 +08:00

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