4、策略接口设计:策略基类定义、信号生成逻辑、参数管理、策略注册机制
策略接口,说白了就是回测框架和你的交易想法之间的「翻译器」。
你脑子里有个策略——比如「5日均线上穿20日均线就买入」——怎么让框架听懂?
这就是策略接口要干的事。
我刚开始写回测框架时,觉得这步很简单。不就是写个函数吗?
后来发现,接口设计得不好,后面加策略、调参数、做优化,全都会变成噩梦。
嗯,这一章咱们就把这块彻底聊透。
4.1 策略基类:所有策略的「模板」
策略基类,就是给所有具体策略定一个规矩。
你想想看,如果每个策略都自己定义一套接口,框架怎么统一调度?
所以我们需要一个抽象基类,规定好「你必须实现什么方法」。
我个人习惯用 Python 的 abc 模块来做这件事:
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
import pandas as pd
import numpy as np
class BaseStrategy(ABC):
"""策略基类 - 所有策略必须继承此类"""
def __init__(self, params: Optional[Dict[str, Any]] = None):
self.params = params or {}
self.name = self.__class__.__name__
self._validate_params()
@abstractmethod
def generate_signals(self, data: pd.DataFrame) -> pd.Series:
"""
生成交易信号
:param data: 包含 OHLCV 等行情数据的 DataFrame
:return: 信号序列,1=买入,-1=卖出,0=持仓/空仓
"""
pass
@abstractmethod
def _validate_params(self) -> None:
"""校验参数合法性,子类必须实现"""
pass
def on_start(self) -> None:
"""策略启动时的回调,可选覆写"""
pass
def on_end(self) -> None:
"""策略结束时的回调,可选覆写"""
pass
def __repr__(self) -> str:
return f"{self.name}(params={self.params})"
generate_signals是必须实现的方法,返回一个 pandas Series_validate_params在初始化时自动调用,防止参数传错on_start和on_end是钩子方法,给策略留了扩展空间
为什么信号要返回 Series 而不是 DataFrame?
我在项目中遇到过,有人把信号和持仓混在一起返回,结果回测逻辑乱成一团。
信号就是信号,持仓计算是框架的事,各管各的,别混在一起。
4.2 信号生成逻辑:把想法变成数字
信号生成,是策略最核心的部分。
你的交易逻辑,最终要变成三个数字:1(买入)、-1(卖出)、0(不动)。
来看一个具体的例子——双均线策略:
class MovingAverageCrossStrategy(BaseStrategy):
"""双均线交叉策略"""
def _validate_params(self):
required = ['short_window', 'long_window']
for key in required:
if key not in self.params:
raise ValueError(f"缺少必要参数: {key}")
if self.params['short_window'] >= self.params['long_window']:
raise ValueError("短周期必须小于长周期")
def generate_signals(self, data: pd.DataFrame) -> pd.Series:
# 计算均线
short_ma = data['close'].rolling(self.params['short_window']).mean()
long_ma = data['close'].rolling(self.params['long_window']).mean()
# 生成信号
signals = pd.Series(0, index=data.index)
signals[short_ma > long_ma] = 1 # 金叉买入
signals[short_ma < long_ma] = -1 # 死叉卖出
return signals
我曾经在计算均线时忘了用
.shift(1),结果用到了当天的收盘价来生成当天的信号——这在实盘中根本做不到。记住:生成 t 时刻的信号,只能用 t-1 及之前的数据。
信号生成的几个常见模式:
- 阈值触发型:指标超过某个阈值就发信号(如 RSI > 70 卖出)
- 交叉型:两条线交叉时发信号(如均线金叉/死叉)
- 模式识别型:识别特定 K 线组合(如锤子线、吞没形态)
- 多条件组合型:多个条件同时满足才发信号
我个人建议,信号生成函数保持纯函数风格——输入数据,输出信号,不要修改外部状态。
这样测试起来特别方便,也容易并行化。
4.3 参数管理:别让参数成为「魔法数字」
参数管理,看起来小事一桩,但做不好会坑死你。
我见过有人把参数硬编码在策略代码里,想改个均线周期得翻遍整个文件。
嗯,这显然不是好做法。
好的参数管理应该做到:
- 参数集中定义:所有参数在初始化时传入,一目了然
- 参数校验:不合法的参数尽早报错,别等到回测跑一半才崩
- 参数序列化:方便保存和复现
- 参数优化支持:能方便地批量测试不同参数组合
我常用的参数管理方式:
class StrategyParams:
"""策略参数容器"""
def __init__(self, **kwargs):
self._params = kwargs
self._frozen = False
def __getattr__(self, name):
if name in self._params:
return self._params[name]
raise AttributeError(f"未知参数: {name}")
def freeze(self):
"""冻结参数,防止运行时被修改"""
self._frozen = True
def to_dict(self) -> Dict[str, Any]:
return self._params.copy()
@classmethod
def from_dict(cls, params_dict: Dict[str, Any]) -> "StrategyParams":
return cls(**params_dict)
我曾经在回测过程中不小心修改了参数,导致结果不一致,排查了两天才找到原因。
加上
freeze() 之后,运行时修改参数会直接报错,从根源上杜绝了这个问题。
参数管理的另一个要点是「参数命名规范」。
我建议用有意义的英文名,加上类型提示:
| 参数名 | 类型 | 说明 | 示例值 |
|---|---|---|---|
short_window |
int | 短期均线周期 | 5 |
long_window |
int | 长期均线周期 | 20 |
stop_loss_pct |
float | 止损百分比 | 0.02 |
take_profit_pct |
float | 止盈百分比 | 0.05 |
max_position |
int | 最大持仓数量 | 10 |
4.4 策略注册机制:让框架自动发现策略
策略多了之后,手动导入每个策略会变得很烦。
你想想看,每次加一个新策略,都要去改框架的代码——这太不优雅了。
策略注册机制,就是让框架能自动发现和加载策略。
我常用的方式有两种:
方式一:装饰器注册
# strategy_registry.py
_strategies = {}
def register_strategy(name=None):
"""策略注册装饰器"""
def decorator(cls):
key = name or cls.__name__
_strategies[key] = cls
return cls
return decorator
def get_strategy(name: str) -> type:
if name not in _strategies:
raise KeyError(f"未知策略: {name},可用策略: {list(_strategies.keys())}")
return _strategies[name]
def list_strategies() -> list:
return list(_strategies.keys())
使用方式:
@register_strategy("ma_cross")
class MovingAverageCrossStrategy(BaseStrategy):
pass
@register_strategy("rsi_strat")
class RSIStrategy(BaseStrategy):
pass
方式二:模块自动扫描
import importlib
import pkgutil
import inspect
def auto_discover_strategies(package_name: str):
"""自动扫描指定包下的所有策略"""
package = importlib.import_module(package_name)
for _, module_name, _ in pkgutil.iter_modules(package.__path__):
module = importlib.import_module(f"{package_name}.{module_name}")
for name, obj in inspect.getmembers(module):
if (inspect.isclass(obj) and
issubclass(obj, BaseStrategy) and
obj is not BaseStrategy):
register_strategy(name)(obj)
装饰器方式更显式,适合策略数量不多的情况;自动扫描方式更自动化,适合大型项目。
我个人偏好装饰器方式,因为代码可读性更好,而且不会意外注册到不该注册的类。
4.5 整体架构图
下面这张图展示了策略接口的整体设计思路:
4.6 避坑总结
最后,把我这些年踩过的坑总结一下:
- 信号和持仓别混在一起:信号是信号,持仓是持仓,框架会帮你算
- 参数一定要校验:我见过有人把 short_window 设得比 long_window 还大,回测结果看起来很好,其实是错的
- 注意未来数据:生成 t 时刻的信号,只能用 t-1 及之前的数据
- 参数冻结是好习惯:运行时不小心改了参数,排查起来真要命
- 注册机制要早设计:等你有 20 个策略再想加注册机制,改起来就痛苦了
嗯,策略接口这块就聊到这儿。
设计得好,后面加策略就像搭积木一样轻松;设计得不好,每加一个策略都要改框架代码。
我个人建议,花点时间把基类和注册机制做好,后面会省很多事。