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 参数管理:别让参数成为「魔法数字」

参数管理,看起来小事一桩,但做不好会坑死你。
我见过有人把参数硬编码在策略代码里,想改个均线周期得翻遍整个文件。
嗯,这显然不是好做法。

好的参数管理应该做到:

  1. 参数集中定义:所有参数在初始化时传入,一目了然
  2. 参数校验:不合法的参数尽早报错,别等到回测跑一半才崩
  3. 参数序列化:方便保存和复现
  4. 参数优化支持:能方便地批量测试不同参数组合

我常用的参数管理方式:

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 整体架构图

下面这张图展示了策略接口的整体设计思路:

策略接口架构图 策略接口整体架构 BaseStrategy(策略基类) generate_signals() _validate_params() on_start() / on_end() MA_Cross_Strategy RSI_Strategy Bollinger_Strategy 参数管理:集中定义 + 校验 + 冻结 注册机制:装饰器 / 自动扫描 输出:信号序列(1 / -1 / 0) 策略接口 = 基类约束 + 信号逻辑 + 参数管理 + 注册机制,四者缺一不可

4.6 避坑总结

最后,把我这些年踩过的坑总结一下:

  • 信号和持仓别混在一起:信号是信号,持仓是持仓,框架会帮你算
  • 参数一定要校验:我见过有人把 short_window 设得比 long_window 还大,回测结果看起来很好,其实是错的
  • 注意未来数据:生成 t 时刻的信号,只能用 t-1 及之前的数据
  • 参数冻结是好习惯:运行时不小心改了参数,排查起来真要命
  • 注册机制要早设计:等你有 20 个策略再想加注册机制,改起来就痛苦了

嗯,策略接口这块就聊到这儿。
设计得好,后面加策略就像搭积木一样轻松;设计得不好,每加一个策略都要改框架代码。
我个人建议,花点时间把基类和注册机制做好,后面会省很多事。